diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..a8cd8c26da2b394c4f32dcd3b492b04532ea4136 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +auths/ +data/ +storage/ +*.tmp +.git/ +.github/ +.gitignore +docs/ +scripts/__pycache__/ +# 根目录已提交/未提交的二进制产物不进构建上下文(与 .gitignore 对齐) +/login +/credit +/wb2api +/signin_bin +/wb2api.exe +/login.exe +/credit.exe +# 真实配置不进镜像;镜像内模板是 config.default.json +config.json +PLAN.md +SPEC.md +LOOP.md +README.md +README.upstream.md diff --git a/.env.example b/.env.example deleted file mode 100644 index f69756d8e44fe7fe4645f48a7ab0df0b70aa8692..0000000000000000000000000000000000000000 --- a/.env.example +++ /dev/null @@ -1,179 +0,0 @@ -# ================================================================ -# GCLI2API 环境变量配置示例文件 -# 复制此文件为 .env 并根据需要修改配置值 -# ================================================================ - -# ================================================================ -# 服务器配置 -# ================================================================ - -# 服务器监听地址 -# 默认: 0.0.0.0 (监听所有网络接口) -HOST=0.0.0.0 - -# 服务器端口 -# 默认: 7861 -PORT=7861 - -# ================================================================ -# 密码配置 (支持分离密码) -# ================================================================ - -# 聊天API访问密码 (用于OpenAI和Gemini API端点认证) -# 默认: 继承通用密码或 pwd -API_PASSWORD=your_api_password - -# 控制面板访问密码 (用于Web界面登录认证) -# 默认: 继承通用密码或 pwd -PANEL_PASSWORD=your_panel_password - -# 通用访问密码 (兼容性保留) -# 设置后会覆盖上述两个专用密码,优先级最高 -# 如果只想使用一个密码,设置此项即可 -# 默认: pwd -PASSWORD=pwd - -# ================================================================ -# 存储配置 -# ================================================================ - -# 存储后端优先级: PostgreSQL > MongoDB > 本地sqlite文件存储 -# 系统会自动选择可用的最高优先级存储后端 - -# PostgreSQL 分布式存储模式配置 (最高优先级) -# 设置 POSTGRESQL_URI 后自动启用 PostgreSQL 模式 -# 本地 PostgreSQL: postgresql://user:password@localhost:5432/gcli2api -# 带 SSL: postgresql://user:password@host:5432/gcli2api?sslmode=require -# 默认: 无 (不启用 PostgreSQL 存储) -POSTGRESQL_URI=postgresql://user:password@localhost:5432/gcli2api - -# MongoDB 分布式存储模式配置 (第二优先级) -# 设置 MONGODB_URI 后自动启用 MongoDB 模式,不再使用本地文件存储 - -# Redis 缓存存储配置 -# 设置 REDIS_URL 后自动启用 Redis 模式,性能最佳,可大幅降低 MongoDB 的读写压力 -# 本地 Redis: redis://127.0.0.1:6379/0 -# 带密码: redis://:password@127.0.0.1:6379/0 -# 默认: 无 (不启用 Redis 缓存) -REDIS_URL=redis://127.0.0.1:6379/0 - -# MongoDB 连接字符串 (设置后启用 MongoDB 分布式存储模式) -# 本地 MongoDB: mongodb://localhost:27017 -# 带认证: mongodb://admin:password@localhost:27017/admin -# MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net -# 副本集: mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=rs0 -# 默认: 无 (使用本地文件存储) -MONGODB_URI=mongodb://localhost:27017 - -# MongoDB 数据库名称 (仅在启用 MongoDB 模式时有效) -# 默认: gcli2api -MONGODB_DATABASE=gcli2api - -# ================================================================ -# Google API 配置 -# ================================================================ - -# 凭证文件目录 (仅在文件存储模式下使用) -# 默认: ./creds -CREDENTIALS_DIR=./creds - - -# 代理配置 (可选) -# 支持 http, https, socks5 代理 -# 格式: http://proxy:port, https://proxy:port, socks5://proxy:port -PROXY=http://localhost:7890 - -# Google API 代理 URL 配置 (可选) - -# Google Code Assist API 端点 -# 默认: https://cloudcode-pa.googleapis.com -CODE_ASSIST_ENDPOINT=https://cloudcode-pa.googleapis.com -# 用于Google OAuth2认证的代理URL -# 默认: https://oauth2.googleapis.com -OAUTH_PROXY_URL=https://oauth2.googleapis.com - -# 用于Google APIs调用的代理URL -# 默认: https://www.googleapis.com -GOOGLEAPIS_PROXY_URL=https://www.googleapis.com - -# 用于Google Cloud Resource Manager API的URL -# 默认: https://cloudresourcemanager.googleapis.com -RESOURCE_MANAGER_API_URL=https://cloudresourcemanager.googleapis.com - -# 用于Google Cloud Service Usage API的URL -# 默认: https://serviceusage.googleapis.com -SERVICE_USAGE_API_URL=https://serviceusage.googleapis.com - -# 用于Google Antigravity API的URL (反重力模式) -# 默认: https://daily-cloudcode-pa.googleapis.com -ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.googleapis.com - -# ================================================================ -# 错误处理和重试配置 -# ================================================================ - -# 是否启用自动封禁功能 -# 当凭证返回特定错误码时自动禁用该凭证 -# 默认: false -AUTO_BAN=false - -# 自动封禁的错误码列表 (逗号分隔) -# 默认: 400,403 -AUTO_BAN_ERROR_CODES=403 - -# 是否启用 429 错误重试 -# 默认: true -RETRY_429_ENABLED=true - -# 429 错误最大重试次数 -# 默认: 5 -RETRY_429_MAX_RETRIES=5 - -# 429 错误重试间隔 (秒) -# 默认: 1 -RETRY_429_INTERVAL=1 - -# ================================================================ -# 日志配置 -# ================================================================ - -# 日志级别 -# 可选值: debug, info, warning, error, critical -# 默认: info -LOG_LEVEL=info - -# 日志文件路径 -# 默认: log.txt -LOG_FILE=log.txt - -# ================================================================ -# 高级功能配置 -# ================================================================ - -# 流式抗截断最大尝试次数 -# 用于 "流式抗截断/" 前缀的模型 -# 默认: 3 -ANTI_TRUNCATION_MAX_ATTEMPTS=3 - -# ================================================================ -# 环境变量使用说明 -# ================================================================ - -# 1. 存储模式配置 (按优先级自动选择): -# - PostgreSQL 分布式模式 (最高优先级): 设置 POSTGRESQL_URI,数据存储在 PostgreSQL 数据库 -# - Redis 缓存: 同时设置 REDIS_URI和 MONGODB_URI时,数据缓存在 Redis 数据库,持久化在MONGODB,性能最佳 -# - MongoDB 分布式模式: 设置 MONGODB_URI,数据存储在 MongoDB 数据库 -# - 文件存储模式 (默认): 不设置上述 URI,数据存储在本地 creds/ 目录 -# - 自动切换: 系统根据可用的存储配置自动选择最高优先级的存储后端 - -# 2. 密码配置优先级: -# a) PASSWORD 环境变量 (最高优先级,设置后覆盖其他密码) -# b) API_PASSWORD / PANEL_PASSWORD 环境变量 (专用密码) -# c) 默认值 "pwd" -# -# 3. 通用配置优先级: -# 环境变量 > 默认值 - -# 4. 布尔值环境变量: -# true/1/yes/on 表示启用 -# false/0/no/off 表示禁用 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 61f97efc9f3c6172e807210b7a6576d008c65a23..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Bug 报告 -description: 报告项目使用中遇到的问题 -title: "[Bug]: " -labels: ["bug", "待处理"] -body: - - type: markdown - attributes: - value: | - ## 感谢你的反馈! - 请填写以下信息以帮助我们更快定位问题。 - - - type: checkboxes - id: checklist - attributes: - label: 提交前确认 - options: - - label: 我已经搜索过现有的 issues,确认这不是重复问题 - required: true - - label: 我已经阅读过项目文档 - required: true - - - type: dropdown - id: latest-version - attributes: - label: 是否是最新版 - description: 请确认你使用的是否是最新版本 - options: - - 是,使用最新版 - - 否,使用旧版本 - validations: - required: true - - - type: input - id: channel - attributes: - label: 调用的是哪个渠道 - description: 例如 geminicli 或者 antigravity - placeholder: "例如: geminicli" - validations: - required: true - - - type: input - id: model - attributes: - label: 调用的是哪个模型 - description: 例如 gemini-2.5-flash - placeholder: "例如: gemini-2.5-flash" - validations: - required: true - - - type: dropdown - id: format - attributes: - label: 调用的是哪个格式 - description: 选择你使用的 API 格式 - options: - - gemini 格式 - - openai 格式 - - claude 格式 - - 其他格式 - validations: - required: true - - - type: textarea - id: error-content - attributes: - label: 具体报错内容 - description: 请粘贴完整的错误信息或截图 - placeholder: | - 请在这里粘贴完整的错误日志或堆栈信息 - render: shell - validations: - required: true - - - type: textarea - id: error-description - attributes: - label: 错误描述 - description: 详细描述问题的发生场景、预期行为和实际行为 - placeholder: | - 1. 我在做什么操作时遇到了这个问题 - 2. 我期望的结果是... - 3. 但实际上发生了... - validations: - required: true - - - type: textarea - id: additional-context - attributes: - label: 补充信息(可选) - description: 其他任何有助于解决问题的信息 - placeholder: 例如:操作系统、Python 版本、相关配置等 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index ceaef7e8914dfd70fd470aff137cf443d927cbb3..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: 使用问题讨论 - url: https://github.com/su-kaka/gcli2api/issues - about: 如果是使用方面的问题,请在 issues 中提问 - - name: 项目文档 - url: https://github.com/su-kaka/gcli2api - about: 查看完整文档和使用指南 \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index fc40808e83ba8cf46964756cc6bf6cf0ee516bcb..0000000000000000000000000000000000000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Docker Build and Publish - -on: - workflow_run: - workflows: ["Update Version File"] - types: - - completed - branches: - - master - - main - push: - tags: - - 'v*' - pull_request: - branches: - - master - - main - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push: - runs-on: ubuntu-latest - # 只在 workflow_run 成功时运行,或者非 workflow_run 触发时运行 - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - # workflow_run 触发时需要获取最新的代码(包括 version.txt 的更新) - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=tag - type=ref,event=pr - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix={{branch}}- - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} - VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} - REVISION=${{ github.sha }} \ No newline at end of file diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml deleted file mode 100644 index 93b018ca576fb727cd3d12f74d893db1ce29129a..0000000000000000000000000000000000000000 --- a/.github/workflows/update-version.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Update Version File - -on: - push: - branches: - - master - - main - -jobs: - update-version: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Update version.txt - run: | - # 获取最新commit信息 - FULL_HASH=$(git log -1 --format=%H) - SHORT_HASH=$(git log -1 --format=%h) - MESSAGE=$(git log -1 --format=%s) - DATE=$(git log -1 --format=%ci) - - # 写入version.txt - echo "full_hash=$FULL_HASH" > version.txt - echo "short_hash=$SHORT_HASH" >> version.txt - echo "message=$MESSAGE" >> version.txt - echo "date=$DATE" >> version.txt - - echo "Version file updated:" - cat version.txt - - - name: Commit version.txt if changed - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - - # 检查是否有变化 - if git diff --quiet version.txt; then - echo "No changes to version.txt" - else - git add version.txt - git commit -m "chore: update version.txt [skip ci]" - git push - fi diff --git a/.gitignore b/.gitignore index c1fab8d850b5af68cbb41d50159527d6234a578f..de668052b6cc8dd79d05afe4d140d114c8aa9689 100644 --- a/.gitignore +++ b/.gitignore @@ -1,99 +1,46 @@ -# Credential files - should never be committed -*.json -!package.json -!package-lock.json -!tsconfig.json -*.toml -!pyproject.toml -creds/ -CLAUDE.md -GEMINI.md -.kiro -# Environment configuration +# Auth & secrets +auths/ +data/ +*.key +*.pem +*.env .env +.env.* -# Python -uv.lock -.python-version -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec +# Binaries (anchored to repo root only — bare "login"/"credit" would also ignore cmd/login/, cmd/credit/) +/login +/credit +/wb2api +/signin_bin -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Config with secrets (use config.example.json) +config.json -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ +# Build artifacts +*.tmp +__pycache__/ -# Virtual environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +# Backups (contains auth tokens) +backups/ # IDE -.vscode/ .idea/ -.claude/ -*.swp -*.swo -*~ - -# OS -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Logs -*.log -log.txt - -# Temporary files -*.tmp -*.temp -*.bak - -tools/ +.vscode/ +LOOP.md +PLAN.md +SPEC.md +docs/ + +# 设计/计划文档(但 README.md 必须进版本库) +*.md +!README.md + +# Windows binaries +/wb2api.exe +/login.exe +/credit.exe + +# 本地验证工具(判据逆向过程脚本,结论沉淀于 data/desktop-task-protocol.md) +/cmd/desktopverify*/ +*.zip +server.exe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 2e237c1dbe43b760b1e2aa2cac6546b69eb7c735..0000000000000000000000000000000000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,169 +0,0 @@ -# Contributing to gcli2api - -First off, thank you for considering contributing to gcli2api! It's people like you that make gcli2api such a great tool. - -## Code of Conduct - -This project is intended for personal learning and research purposes only. By participating, you are expected to uphold this code and respect the CNC-1.0 license restrictions on commercial use. - -## How Can I Contribute? - -### Reporting Bugs - -Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible: - -* **Use a clear and descriptive title** -* **Describe the exact steps to reproduce the problem** -* **Provide specific examples** - Include code snippets, configuration files, or log outputs -* **Describe the behavior you observed** and what you expected to see -* **Include environment details**: OS, Python version, Docker version (if applicable) - -### Suggesting Enhancements - -Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include: - -* **Use a clear and descriptive title** -* **Provide a detailed description** of the suggested enhancement -* **Explain why this enhancement would be useful** -* **List any alternative solutions** you've considered - -### Pull Requests - -1. Fork the repo and create your branch from `master` -2. If you've added code that should be tested, add tests -3. If you've changed APIs, update the documentation -4. Ensure the test suite passes -5. Make sure your code follows the existing style -6. Write a clear commit message - -## Development Setup - -### Prerequisites - -* Python 3.12 or higher -* pip or uv package manager - -### Setting Up Your Development Environment - -```bash -# Clone your fork -git clone https://github.com/YOUR_USERNAME/gcli2api.git -cd gcli2api - -# Install development dependencies -make install-dev -# or -pip install -e ".[dev]" - -# Copy environment example -cp .env.example .env -# Edit .env with your configuration -``` - -### Development Workflow - -```bash -# Run tests -make test - -# Format code -make format - -# Run linters -make lint - -# Run the application locally -make run -``` - -### Testing - -We use pytest for testing. All new features should include appropriate tests. - -```bash -# Run all tests -make test - -# Run with coverage -make test-cov - -# Run specific test file -python -m pytest test_tool_calling.py -v -``` - -### Code Style - -* We use [Black](https://black.readthedocs.io/) for code formatting (line length: 100) -* We use [flake8](https://flake8.pycqa.org/) for linting -* We use [mypy](http://mypy-lang.org/) for type checking (optional, but encouraged) - -```bash -# Format your code before committing -make format - -# Check if code is properly formatted -make format-check - -# Run linters -make lint -``` - -## Project Structure - -``` -gcli2api/ -├── src/ # Main source code -│ ├── auth.py # Authentication and OAuth -│ ├── credential_manager.py # Credential rotation -│ ├── openai_router.py # OpenAI-compatible endpoints -│ ├── gemini_router.py # Gemini native endpoints -│ ├── openai_transfer.py # Format conversion -│ ├── storage/ # Storage backends (Redis, MongoDB, Postgres, File) -│ └── ... -├── front/ # Frontend static files -├── tests/ # Test directory (to be created) -├── test_*.py # Test files (root level) -├── web.py # Main application entry point -├── config.py # Configuration management -└── requirements.txt # Production dependencies -``` - -## Coding Guidelines - -### Python Style - -* Follow PEP 8 guidelines -* Use type hints where appropriate -* Write docstrings for classes and functions -* Keep functions focused and concise - -### Commit Messages - -* Use the present tense ("Add feature" not "Added feature") -* Use the imperative mood ("Move cursor to..." not "Moves cursor to...") -* Limit the first line to 72 characters or less -* Reference issues and pull requests liberally after the first line - -### Documentation - -* Update the README.md if you change functionality -* Comment your code where necessary -* Update the .env.example if you add new configuration options - -## License - -By contributing to gcli2api, you agree that your contributions will be licensed under the CNC-1.0 license. This is a strict anti-commercial license - see [LICENSE](LICENSE) for details. - -### Important License Restrictions - -* ❌ No commercial use -* ❌ No use by companies with revenue > $1M USD -* ❌ No use by VC-backed or publicly traded companies -* ✅ Personal learning, research, and educational use only -* ✅ Open source integration (must follow same license) - -## Questions? - -Feel free to open an issue with your question or reach out to the maintainers. - -Thank you for contributing! 🎉 diff --git a/Dockerfile b/Dockerfile index 41eb618803d03279685e25ca7f0d898e3b326b28..86ba8a8ba85cd258c597bae61ebc1fda4587b27c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,39 @@ -# Multi-stage build for gcli2api -FROM python:3.13-slim as base - -# Set environment variables -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - TZ=Asia/Shanghai - -# Install tzdata and set timezone -# Added: nodejs (for afp.wasm 音频指纹生成), ffmpeg (音频转码) -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - tzdata \ - nodejs \ - ffmpeg \ - && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ - echo "Asia/Shanghai" > /etc/timezone && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Copy only requirements first for better caching -COPY requirements.txt . - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code +# syntax=docker/dockerfile:1 +# ---------------------------------------------------------------- build stage +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download COPY . . +# 全静态二进制:镜像里直接跑脚本,无需 libc。 +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/signin_bin ./cmd/signin \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/login ./cmd/login \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/credit ./cmd/credit + +# -------------------------------------------------------------- runtime stage +FROM alpine:3.20 +# python3:login.sh 的 JSON 解析 + 入口脚本的配置校验;tzdata:Asia/Shanghai。 +RUN apk add --no-cache wget ca-certificates tzdata python3 bash \ + && mkdir -p /app/auths /app/data +ENV TZ=Asia/Shanghai +WORKDIR /app -# Expose port +COPY --from=build /out/wb2api /app/wb2api +COPY --from=build /out/signin_bin /app/signin_bin +COPY --from=build /out/login /app/login +COPY --from=build /out/credit /app/credit +COPY login.sh signin.sh credit.sh /app/ +COPY scripts/probe_active.py /app/scripts/probe_active.py +# config.default.json 只是「镜像内模板」:仅当持久卷上还没有 config.json 时被拷过去一次。 +# 真正的运行配置住在私有 Storage Bucket 上(见 docker-entrypoint.sh),公开仓库里不放 api_key。 +COPY config.default.json /app/config.json +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN sed -i 's/\r$//' /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh \ + && chmod 755 /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh + +# 以 root 运行:Storage Bucket 卷由挂载器创建,非 root 用户可能写不进去。 EXPOSE 7861 - -# Default command -CMD ["python", "web.py"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD wget -qO- http://127.0.0.1:7861/healthz || exit 1 +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE index 2317eb517b7aa0e90bc5b81db74b82ff885336e5..7a7d27d9be1cced2adc8c460680e4c57c8db6215 100644 --- a/LICENSE +++ b/LICENSE @@ -1,83 +1,22 @@ -Cooperative Non-Commercial License (CNC-1.0) - -Copyright (c) 2024 gcli2api contributors - -Permission is hereby granted, free of charge, to any person or organization -obtaining a copy of this software and associated documentation files (the -"Software"), to use, copy, modify, merge, publish, distribute, and/or -sublicense the Software, subject to the following conditions: - -TERMS AND CONDITIONS: - -1. NON-COMMERCIAL USE ONLY - The Software may only be used for non-commercial purposes. Commercial use - is strictly prohibited without explicit written permission from the - copyright holders. - -2. DEFINITION OF COMMERCIAL USE - "Commercial use" includes but is not limited to: - a) Using the Software to provide paid services or products - b) Integrating the Software into commercial products or services - c) Using the Software in any business operation that generates revenue - d) Offering the Software as part of a paid subscription or service - e) Using the Software to compete with the original project commercially - -3. COPYLEFT REQUIREMENT - Any derivative works, modifications, or substantial portions of the Software - must be licensed under the same or substantially similar terms. This ensures - that all derivatives remain non-commercial and freely available. - -4. SOURCE CODE AVAILABILITY - If you distribute the Software or any derivative works, you must make the - complete source code available under the same license terms at no charge. - -5. ATTRIBUTION REQUIREMENT - You must retain all copyright notices, license notices, and attribution - statements in all copies or substantial portions of the Software. - -6. ANTI-CORPORATE CLAUSE - This Software may not be used by corporations with annual revenue exceeding - $1 million USD, venture capital backed companies, or publicly traded - companies without explicit written permission from the copyright holders. - -7. EDUCATIONAL AND RESEARCH EXEMPTION - Use by educational institutions, non-profit research organizations, and - individual researchers for educational or research purposes is explicitly - permitted and encouraged. - -8. MODIFICATION AND CONTRIBUTION - Modifications and contributions to the Software are welcomed and encouraged, - provided they comply with these license terms. Contributors grant the same - license to their contributions. - -9. PATENT GRANT - Each contributor grants you a non-exclusive, worldwide, royalty-free patent - license to make, have made, use, offer to sell, sell, import, and otherwise - transfer the Work for non-commercial purposes only. - -10. TERMINATION - This license automatically terminates if you violate any of its terms. - Upon termination, you must cease all use and distribution of the Software - and destroy all copies in your possession. - -11. LIABILITY DISCLAIMER - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - -12. JURISDICTION - This license shall be governed by and construed in accordance with the laws - of the jurisdiction where the copyright holder resides. - -SUMMARY: -This license allows free use, modification, and distribution of the Software -for non-commercial purposes only. It explicitly prohibits commercial use and -ensures that all derivatives remain freely available under the same terms. -The license promotes cooperative development while preventing commercial -exploitation of the community's work. - -For commercial licensing inquiries, please contact the copyright holders. \ No newline at end of file +MIT License + +Copyright (c) 2026 Sliverkiss (original project: https://github.com/Sliverkiss/workbuddy2api) +Copyright (c) 2026 linguo2625469 (this fork: https://github.com/linguo2625469/workbuddy2api-panel) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b83c1c55c03a288357d602181c49902ea3793d6c..fb3430498b55ce2acfaf284545c500bdaceaf394 100644 --- a/README.md +++ b/README.md @@ -1,891 +1,67 @@ --- -title: gcli2api -colorFrom: blue -colorTo: green +title: WorkBuddy2API Panel +emoji: 🧩 +colorFrom: indigo +colorTo: blue sdk: docker app_port: 7861 pinned: false +short_description: WorkBuddy 账号池网关,带面板,数据落私有存储桶 --- -# GeminiCLI to API -**将 GeminiCLI 和 Antigravity 转换为 OpenAI 、GEMINI 和 Claude API 兼容接口** +# WorkBuddy2API Panel — Hugging Face Spaces 部署 -[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) -[![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](LICENSE) -[![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api) +把腾讯 WorkBuddy 账号变成 OpenAI 兼容 API 的多账号网关(上游: +[linguo2625469/workbuddy2api-panel](https://github.com/linguo2625469/workbuddy2api-panel))。 +本目录是**面向 HF Spaces 的部署分支**:唯一的改动是「持久化」。 -[English](docs/README_EN.md) | 中文 | [日本語](docs/README_JA.md) +## 这个分支改了什么 -## 🚀 快速部署 +Spaces 的容器根文件系统是 ephemeral 的 —— 重启、休眠唤醒、重新构建都会清空。 +原版把账号和状态放在容器内的 `./auths`、`./data`,在 Spaces 上等于每次重启都要重新登录账号。 -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka) -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api) ---- - -## 安装指南 - -### Termux 环境 - -**初始安装** -```bash -curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh -``` - -**重启服务** -```bash -cd gcli2api -bash termux-start.sh -``` - -### Windows 环境 - -**初始安装** -```powershell -iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content -``` - -**重启服务** -双击执行 `start.bat` - -### Linux 环境 - -**初始安装** -```bash -curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh -``` - -**重启服务** -```bash -cd gcli2api -bash start.sh -``` - -### macOS 环境 - -**初始安装** -```bash -curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh -``` - -**重启服务** -```bash -cd gcli2api -bash start.sh -``` - -### Docker 环境 - -**Docker 运行命令** -```bash -# 使用通用密码 -docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest - -# 使用分离密码 -docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Mac** -```bash -# 使用通用密码 -docker run -d \ - --name gcli2api \ - -p 7861:7861 \ - -p 8080:8080 \ - -e PASSWORD=pwd \ - -e PORT=7861 \ - -v "$(pwd)/data/creds":/app/creds \ - ghcr.io/su-kaka/gcli2api:latest -``` - -```bash -# 使用分离密码 -docker run -d \ ---name gcli2api \ --p 7861:7861 \ --p 8080:8080 \ --e API_PASSWORD=api_pwd \ --e PANEL_PASSWORD=panel_pwd \ --e PORT=7861 \ --v $(pwd)/data/creds:/app/creds \ -ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Compose 运行命令** -1. 将以下内容保存为 `docker-compose.yml` 文件: - ```yaml - version: '3.8' - - services: - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api - restart: unless-stopped - network_mode: host - environment: - # 使用通用密码(推荐用于简单部署) - - PASSWORD=pwd - - PORT=7861 - # 或使用分离密码(推荐用于生产环境) - # - API_PASSWORD=your_api_password - # - PANEL_PASSWORD=your_panel_password - volumes: - - ./data/creds:/app/creds - healthcheck: - test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - ``` -2. 启动服务: - ```bash - docker-compose up -d - ``` - -## 核心功能 - -### 🔄 API 端点和格式支持 - -**多端点多格式支持** -- **OpenAI 兼容端点**:`/v1/chat/completions` 和 `/v1/models` - - 支持标准 OpenAI 格式(messages 结构) - - 支持 Gemini 原生格式(contents 结构) - - 自动格式检测和转换,无需手动切换 - - 支持多模态输入(文本 + 图像) -- **Gemini 原生端点**:`/v1/models/{model}:generateContent` 和 `streamGenerateContent` - - 支持完整的 Gemini 原生 API 规范 - - 多种认证方式:Bearer Token、x-goog-api-key 头部、URL 参数 key -- **Claude 格式兼容**:完整支持 Claude API 格式 - - 端点:`/v1/messages`(遵循 Claude API 规范) - - 支持 Claude 标准的 messages 格式 - - 支持 system 参数和 Claude 特有功能 - - 自动转换为后端支持的格式 -- **Antigravity API 支持**:同时支持 OpenAI、Gemini 和 Claude 格式 - - OpenAI 格式端点:`/antigravity/v1/chat/completions` - - Gemini 格式端点:`/antigravity/v1/models/{model}:generateContent` 和 `streamGenerateContent` - - Claude 格式端点:`/antigravity/v1/messages` - - 支持所有 Antigravity 模型(Claude、Gemini 等) - - 自动模型名称映射和思维模式检测 - -### 🔐 认证和安全管理 - -**灵活的密码管理** -- **分离密码支持**:API 密码(聊天端点)和控制面板密码可独立设置 -- **多种认证方式**:支持 Authorization Bearer、x-goog-api-key 头部、URL 参数等 -- **JWT Token 认证**:控制面板支持 JWT 令牌认证 -- **用户邮箱获取**:自动获取和显示 Google 账户邮箱地址 - -### 📊 智能凭证管理系统 - -**高级凭证管理** -- 多个 Google OAuth 凭证自动轮换 -- 通过冗余认证增强稳定性 -- 负载均衡与并发请求支持 -- 自动故障检测和凭证禁用 -- 凭证使用统计和配额管理 -- 支持手动启用/禁用凭证文件 -- 批量凭证文件操作(启用、禁用、删除) - -**凭证状态监控** -- 实时凭证健康检查 -- 错误码追踪(429、403、500 等) -- 自动封禁机制(可配置) - -### 🌊 流式传输和响应处理 - -**多种流式支持** -- 真正的实时流式响应 -- 假流式模式(用于兼容性) -- 流式抗截断功能(防止回答被截断) -- 异步任务管理和超时处理 - -**响应优化** -- 思维链(Thinking)内容分离 -- 推理过程(reasoning_content)处理 -- 多轮对话上下文管理 -- 兼容性模式(将 system 消息转换为 user 消息) - -### 🎛️ Web 管理控制台 - -**全功能 Web 界面** -- OAuth 认证流程管理(支持 GCLI 和 Antigravity 双模式) -- 凭证文件上传、下载、管理 -- 实时日志查看(WebSocket) -- 系统配置管理 -- 使用统计和监控面板 -- 移动端适配界面 - -**批量操作支持** -- ZIP 文件批量上传凭证(GCLI 和 Antigravity) -- 批量启用/禁用/删除凭证 -- 批量获取用户邮箱 -- 批量配置管理 -- 统一批量上传界面管理所有凭证类型 - -### 📈 使用监控 - -**实时监控** -- WebSocket 实时日志流 -- 系统状态监控 -- 凭证健康状态 - -### 🔧 高级配置和自定义 - -**网络和代理配置** -- HTTP/HTTPS 代理支持 -- 代理端点配置(OAuth、Google APIs、元数据服务) -- 超时和重试配置 -- 网络错误处理和恢复 - -**性能和稳定性配置** -- 429 错误自动重试(可配置间隔和次数) -- 抗截断最大重试次数 - -**日志和调试** -- 多级日志系统(DEBUG、INFO、WARNING、ERROR) -- 日志文件管理 -- 实时日志流 -- 日志下载和清空 - -### 🔄 环境变量和配置管理 - -**灵活的配置方式** -- 环境变量配置 -- 热配置更新(部分配置项) -- 配置锁定(环境变量优先级) - -## 支持的模型 - -所有模型均具备 1M 上下文窗口容量。每个凭证文件提供 1000 次请求额度。 - -### 🤖 基础模型 -- `gemini-2.5-pro` -- `gemini-3-pro-preview` -- `gemini-3.1-pro-preview` - -### 🧠 思维模型(Thinking Models) -- `gemini-2.5-pro-high`:思考模式 -- `gemini-2.5-pro-low`:低思考模式 -- 支持自定义思考预算配置 -- 自动分离思维内容和最终回答 - -### 🔍 搜索增强模型 -- `gemini-2.5-pro-search`:集成搜索功能的模型 - -### 🖼️ 图像生成模型(Antigravity) -- `gemini-3.1-flash-image`:基础图像生成模型 -- **分辨率后缀**: - - `-2k`:2K 分辨率 - - `-4k`:4K 高清分辨率 -- **比例后缀**: - - `-1x1`:正方形(头像) - - `-16x9`:横屏(电脑壁纸) - - `-9x16`:竖屏(手机壁纸) - - `-21x9`:超宽屏(带鱼屏) - - `-4x3`:传统显示器 - - `-3x4`:竖版海报 -- **组合使用示例**: - - `gemini-3.1-flash-image-4k-16x9`:4K 横屏 - - `gemini-3.1-flash-image-2k-9x16`:2K 竖屏 -- 不指定比例时,API 自动决定横竖比例 - -### 🌊 特殊功能变体 -- **假流式模式**:在任何模型名称后添加 `-假流式` 后缀 - - 例:`gemini-2.5-pro-假流式` - - 用于需要流式响应但服务端不支持真流式的场景 -- **流式抗截断模式**:在模型名称前添加 `流式抗截断/` 前缀 - - 例:`流式抗截断/gemini-2.5-pro` - - 自动检测响应截断并重试,确保完整回答 - -### 🔧 模型功能自动检测 -- 系统自动识别模型名称中的功能标识 -- 透明地处理功能模式转换 -- 支持功能组合使用 - ---- - -## 配置说明 - -1. 访问 `http://127.0.0.1:7861` (默认端口,可通过 PORT 环境变量修改) -2. 完成 OAuth 认证流程(默认密码:`pwd`,可通过环境变量修改) - - **GCLI 模式**:用于获取 Google Cloud Gemini API 凭证 - - **Antigravity 模式**:用于获取 Google Antigravity API 凭证 -3. 配置客户端: - -**OpenAI 兼容客户端:** - - **端点地址**:`http://127.0.0.1:7861/v1` - - **API 密钥**:`pwd`(默认值,可通过 API_PASSWORD 或 PASSWORD 环境变量修改) - -**Gemini 原生客户端:** - - **端点地址**:`http://127.0.0.1:7861` - - **认证方式**: - - `Authorization: Bearer your_api_password` - - `x-goog-api-key: your_api_password` - - URL 参数:`?key=your_api_password` - -### 🌟 双认证模式支持 - -**GCLI 认证模式** -- 标准的 Google Cloud Gemini API 认证 -- 支持 OAuth2.0 认证流程 -- 自动启用必需的 Google Cloud API - -**Antigravity 认证模式** -- Google Antigravity API 专用认证 -- 独立的凭证管理系统 -- 支持批量上传和管理 -- 与 GCLI 凭证完全隔离 - -**统一管理界面** -- 在"批量上传"标签页中可一次性管理两种凭证 -- 上半部分:GCLI 凭证批量上传(蓝色主题) -- 下半部分:Antigravity 凭证批量上传(绿色主题) -- 各自独立的凭证管理标签页 - -## 💾 数据存储模式 - -### 🌟 存储后端支持 - -gcli2api 支持两种存储后端:**本地 SQLite(默认)** 和 **MongoDB(云端分布式存储)** - -### 📁 本地 SQLite 存储(默认) - -**默认存储方式** -- 无需配置,开箱即用 -- 数据存储在本地 SQLite 数据库中 -- 适合单机部署和个人使用 -- 自动创建和管理数据库文件 - -### 🍃 MongoDB 云端存储模式 - -**云端分布式存储方案** - -当需要多实例部署或云端存储时,可以启用 MongoDB 存储模式。 - -### ⚙️ 启用 MongoDB 模式 - -**步骤 1: 配置 MongoDB 连接** -```bash -# 本地 MongoDB -export MONGODB_URI="mongodb://localhost:27017" - -# MongoDB Atlas 云服务 -export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net" - -# 带认证的 MongoDB -export MONGODB_URI="mongodb://admin:password@localhost:27017/admin" - -# 可选:自定义数据库名称(默认: gcli2api) -export MONGODB_DATABASE="my_gcli_db" -``` - -**步骤 2: 启动应用** -```bash -# 应用会自动检测 MongoDB 配置并使用 MongoDB 存储 -python web.py -``` - -**Docker 环境使用 MongoDB** -```bash -# 单机 MongoDB 部署 -docker run -d --name gcli2api \ - -e MONGODB_URI="mongodb://mongodb:27017" \ - -e API_PASSWORD=your_password \ - --network your_network \ - ghcr.io/su-kaka/gcli2api:latest - -# 使用 MongoDB Atlas -docker run -d --name gcli2api \ - -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \ - -e API_PASSWORD=your_password \ - -p 7861:7861 \ - ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Compose 示例** -```yaml -version: '3.8' - -services: - mongodb: - image: mongo:7 - container_name: gcli2api-mongodb - restart: unless-stopped - environment: - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: password123 - volumes: - - mongodb_data:/data/db - ports: - - "27017:27017" - - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api - restart: unless-stopped - depends_on: - - mongodb - environment: - - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin - - MONGODB_DATABASE=gcli2api - - API_PASSWORD=your_api_password - - PORT=7861 - ports: - - "7861:7861" +本分支的做法: -volumes: - mongodb_data: -``` - - -### 🔧 高级配置 - -**MongoDB 连接优化** -```bash -# 连接池和超时配置 -export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000" - -# 副本集配置 -export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet" - -# 读写分离配置 -export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred" -``` - -### 环境变量配置 - -**基础配置** -- `PORT`: 服务端口(默认:7861) -- `HOST`: 服务器监听地址(默认:0.0.0.0) - -**密码配置** -- `API_PASSWORD`: 聊天 API 访问密码(默认:继承 PASSWORD 或 pwd) -- `PANEL_PASSWORD`: 控制面板访问密码(默认:继承 PASSWORD 或 pwd) -- `PASSWORD`: 通用密码,设置后覆盖上述两个(默认:pwd) - -**性能和稳定性配置** -- `RETRY_429_ENABLED`: 启用 429 错误自动重试(默认:true) -- `RETRY_429_MAX_RETRIES`: 429 错误最大重试次数(默认:3) -- `RETRY_429_INTERVAL`: 429 错误重试间隔,秒(默认:1.0) -- `ANTI_TRUNCATION_MAX_ATTEMPTS`: 抗截断最大重试次数(默认:3) - -**网络和代理配置** -- `PROXY`: HTTP/HTTPS 代理地址(格式:`http://host:port`) -- `OAUTH_PROXY_URL`: OAuth 认证代理端点 -- `GOOGLEAPIS_PROXY_URL`: Google APIs 代理端点 -- `METADATA_SERVICE_URL`: 元数据服务代理端点 - -**自动化配置** -- `AUTO_BAN`: 启用凭证自动封禁(默认:true) -- `AUTO_LOAD_ENV_CREDS`: 启动时自动加载环境变量凭证(默认:false) - -**兼容性配置** -- `COMPATIBILITY_MODE`: 启用兼容性模式,将 system 消息转为 user 消息(默认:false) - -**日志配置** -- `LOG_LEVEL`: 日志级别(DEBUG/INFO/WARNING/ERROR,默认:INFO) -- `LOG_FILE`: 日志文件路径(默认:log.txt) - -**存储配置** - -**SQLite 配置(默认)** -- 无需配置,自动使用本地 SQLite 数据库 -- 数据库文件自动创建在项目目录 - -**MongoDB 配置(可选云端存储)** -- `MONGODB_URI`: MongoDB 连接字符串(设置后启用 MongoDB 模式) -- `MONGODB_DATABASE`: MongoDB 数据库名称(默认:gcli2api) - -**Docker 使用示例** -```bash -# 使用通用密码 -docker run -d --name gcli2api \ - -e PASSWORD=mypassword \ - -e PORT=7861 \ - ghcr.io/su-kaka/gcli2api:latest - -# 使用分离密码 -docker run -d --name gcli2api \ - -e API_PASSWORD=my_api_password \ - -e PANEL_PASSWORD=my_panel_password \ - -e PORT=7861 \ - ghcr.io/su-kaka/gcli2api:latest -``` - -注意:当设置了凭证环境变量时,系统将优先使用环境变量中的凭证,忽略 `creds` 目录中的文件。 - -### API 使用方式 - -本服务支持三套完整的 API 端点: - -#### 1. OpenAI 兼容端点(GCLI) - -**端点:** `/v1/chat/completions` -**认证:** `Authorization: Bearer your_api_password` - -支持两种请求格式,会自动检测并处理: - -**OpenAI 格式:** -```json -{ - "model": "gemini-2.5-pro", - "messages": [ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "Hello"} - ], - "temperature": 0.7, - "stream": true -} -``` - -**Gemini 原生格式:** -```json -{ - "model": "gemini-2.5-pro", - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ], - "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]}, - "generationConfig": { - "temperature": 0.7 - } -} -``` - -#### 2. Gemini 原生端点(GCLI) - -**非流式端点:** `/v1/models/{model}:generateContent` -**流式端点:** `/v1/models/{model}:streamGenerateContent` -**模型列表:** `/v1/models` - -**认证方式(任选一种):** -- `Authorization: Bearer your_api_password` -- `x-goog-api-key: your_api_password` -- URL 参数:`?key=your_api_password` - -**请求示例:** -```bash -# 使用 x-goog-api-key 头部 -curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \ - -H "x-goog-api-key: your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' - -# 使用 URL 参数 -curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' -``` - -#### 3. Claude API 格式端点 - -**端点:** `/v1/messages` -**认证:** `x-api-key: your_api_password` 或 `Authorization: Bearer your_api_password` - -**请求示例:** -```bash -curl -X POST "http://127.0.0.1:7861/v1/messages" \ - -H "x-api-key: your_api_password" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gemini-2.5-pro", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, Claude!"} - ] - }' -``` - -**支持 system 参数:** -```json -{ - "model": "gemini-2.5-pro", - "max_tokens": 1024, - "system": "You are a helpful assistant", - "messages": [ - {"role": "user", "content": "Hello"} - ] -} -``` - -**说明:** -- 完全兼容 Claude API 格式规范 -- 自动转换为 Gemini 格式调用后端 -- 支持 Claude 的所有标准参数 -- 响应格式符合 Claude API 规范 - -#### 4. Antigravity API 端点 - -**支持三种格式:OpenAI、Gemini 和 Claude** - -##### Antigravity OpenAI 格式端点 +- 一个**私有 Storage Bucket** 以卷的形式挂载到容器 `/app/storage`(读写); +- `docker-entrypoint.sh` 把运行配置指到卷上: + - `/app/storage/config.json` — 运行配置(含 `api_key`,私有卷上,公开仓库里没有) + - `/app/storage/auths/*.json` — 账号凭证 + - `/app/storage/data/{state,usage,model,output_probes}.json`、`data/request-logs/` — 运行状态与请求归档 +- 启动时做一次「写 + rename」自检(程序所有落盘都走 tmp+rename,FUSE 卷不支持 rename 的话持久化就是假的); +- **安全闸门**:`api_key` 为空等于完全关闭鉴权,入口脚本检测到空 key 会直接拒绝启动。 -**端点:** `/antigravity/v1/chat/completions` -**认证:** `Authorization: Bearer your_api_password` +## 一次性配置(已经做完,此处仅为记录) -**请求示例:** -```bash -curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \ - -H "Authorization: Bearer your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5", - "messages": [ - {"role": "user", "content": "Hello"} - ], - "stream": true - }' -``` - -##### Antigravity Gemini 格式端点 - -**非流式端点:** `/antigravity/v1/models/{model}:generateContent` -**流式端点:** `/antigravity/v1/models/{model}:streamGenerateContent` - -**认证方式(任选一种):** -- `Authorization: Bearer your_api_password` -- `x-goog-api-key: your_api_password` -- URL 参数:`?key=your_api_password` - -**请求示例:** -```bash -# Gemini 格式非流式请求 -curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \ - -H "x-goog-api-key: your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ], - "generationConfig": { - "temperature": 0.7 - } - }' - -# Gemini 格式流式请求 -curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' -``` - -##### Antigravity Claude 格式端点 - -**端点:** `/antigravity/v1/messages` -**认证:** `x-api-key: your_api_password` - -**请求示例:** -```bash -curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \ - -H "x-api-key: your_api_password" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello"} - ] - }' -``` - -**支持的 Antigravity 模型:** -- Claude 系列:`claude-sonnet-4-5`、`claude-opus-4-5` 等 -- Gemini 系列:`gemini-2.5-flash`、`gemini-2.5-pro` 等 -- 自动支持思维模型(thinking models) - -**Gemini 原生示例:** ```python -from io import BytesIO -from PIL import Image -from google.genai import Client -from google.genai.types import HttpOptions -from google.genai import types -# The client gets the API key from the environment variable `GEMINI_API_KEY`. - -client = Client( - api_key="pwd", - http_options=HttpOptions(base_url="http://127.0.0.1:7861"), - ) - -prompt = ( - """ - 画一只猫 - """ +from huggingface_hub import HfApi, Volume +api = HfApi() +api.create_bucket("a3216/wb2api-storage", private=True, exist_ok=True) +api.set_space_volumes( + "a3216/gcli2api", + volumes=[Volume(type="bucket", source="a3216/wb2api-storage", mount_path="/app/storage")], ) - -response = client.models.generate_content( - model="gemini-3.1-flash-image", - contents=[prompt], - config=types.GenerateContentConfig( - image_config=types.ImageConfig( - aspect_ratio="16:9", - ) - ) -) -for part in response.candidates[0].content.parts: - if part.text is not None: - print(part.text) - elif part.inline_data is not None: - image = Image.open(BytesIO(part.inline_data.data)) - image.save("generated_image.png") - -``` - -**说明:** -- OpenAI 端点返回 OpenAI 兼容格式 -- Gemini 端点返回 Gemini 原生格式 -- 两种端点使用相同的 API 密码 - -## 📋 完整 API 参考 - -### Web 控制台 API - -**认证端点** -- `POST /auth/login` - 用户登录 -- `POST /auth/start` - 开始 OAuth 认证(支持 GCLI 和 Antigravity 模式) -- `POST /auth/callback` - 处理 OAuth 回调 -- `POST /auth/callback-url` - 从回调 URL 直接完成认证 -- `GET /auth/status/{project_id}` - 检查认证状态 - -**凭证管理端点**(支持 `mode=geminicli` 或 `mode=antigravity` 参数) -- `POST /creds/upload` - 批量上传凭证文件(支持 JSON 和 ZIP) -- `GET /creds/status` - 获取凭证状态列表(支持分页和筛选) -- `GET /creds/detail/{filename}` - 获取单个凭证详情 -- `POST /creds/action` - 单个凭证操作(启用/禁用/删除) -- `POST /creds/batch-action` - 批量凭证操作 -- `GET /creds/download/{filename}` - 下载单个凭证文件 -- `GET /creds/download-all` - 打包下载所有凭证 -- `POST /creds/fetch-email/{filename}` - 获取用户邮箱 -- `POST /creds/refresh-all-emails` - 批量刷新用户邮箱 -- `POST /creds/deduplicate-by-email` - 按邮箱去重凭证 -- `POST /creds/verify-project/{filename}` - 检验凭证 Project ID -- `GET /creds/quota/{filename}` - 获取凭证额度信息(仅 Antigravity) - -**配置管理端点** -- `GET /config/get` - 获取当前配置 -- `POST /config/save` - 保存配置 - -**日志管理端点** -- `POST /logs/clear` - 清空日志 -- `GET /logs/download` - 下载日志文件 -- `WebSocket /logs/stream` - 实时日志流 - -**版本信息端点** -- `GET /version/info` - 获取版本信息(可选 `check_update=true` 参数检查更新) - -### 聊天 API 功能特性 - -**多模态支持** -```json -{ - "model": "gemini-2.5-pro", - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "描述这张图片"}, - { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..." - } - } - ] - } - ] -} -``` - -**思维模式支持** -```json -{ - "model": "gemini-2.5-pro-maxthinking", - "messages": [ - {"role": "user", "content": "复杂数学问题"} - ] -} -``` - -响应将包含分离的思维内容: -```json -{ - "choices": [{ - "message": { - "role": "assistant", - "content": "最终答案", - "reasoning_content": "详细的思考过程..." - } - }] -} -``` - -**流式抗截断使用** -```json -{ - "model": "流式抗截断/gemini-2.5-pro", - "messages": [ - {"role": "user", "content": "写一篇长文章"} - ], - "stream": true -} ``` -**兼容性模式** -```bash -# 启用兼容性模式 -export COMPATIBILITY_MODE=true -``` -此模式下,所有 `system` 消息会转换为 `user` 消息,提高与某些客户端的兼容性。 - ---- - -## 💬 交流群 - -欢迎加入 QQ 群交流讨论! - -**QQ 群号:1083250744** - -QQ群二维码 - ---- - -## 许可证与免责声明 - -本项目仅供学习和研究用途。使用本项目表示您同意: -- 不将本项目用于任何商业用途 -- 承担使用本项目的所有风险和责任 -- 遵守相关的服务条款和法律法规 +`set_space_volumes` 是**替换**语义;要追加卷,先读 `get_space_runtime(...).volumes` 再一起传进去。 -项目作者对因使用本项目而产生的任何直接或间接损失不承担责任。 +## 环境变量 -## ⚠️ 许可证声明 +| 变量 | 作用 | +| --- | --- | +| `WB2A_API_KEY` | 覆盖配置文件里的 `api_key`(本项目默认不设,key 存在卷上的 config.json 里) | +| `WB2A_PERSIST_DIR` | 卷挂载点,默认 `/app/storage` | +| `WB2A_LISTEN` / `WB2A_AUTH_DIR` / `WB2A_STATE_FILE` … | 上游已有的同名覆盖,见上游 README | -**本项目采用 Cooperative Non-Commercial License (CNC-1.0)** +## 运维 -这是一个反商业化的严格开源协议,详情请查看 [LICENSE](LICENSE) 文件。 +- **改 API 密钥**:面板「配置」页改完即热生效,并写回 `/app/storage/config.json`,重启不丢。 +- **加账号**:面板里走 OAuth 添加,或把 `workbuddy-*.json` 丢进桶的 `auths/`。 +- **备份**:桶本身就是备份。本地拉一份: + `hf buckets sync hf://buckets/a3216/wb2api-storage ./backup` +- **看日志**:`hf spaces logs a3216/gcli2api`(构建日志加 `--build`)。 +- **免费硬件会自动休眠**:cpu-basic 上 48 小时没有 HTTP 流量就被暂停,下次有人访问自动唤醒。 + 唤醒后账号和状态从桶里恢复;网关的定时任务(签到/领积分)在暂停期间不会执行。 -### ✅ 允许的用途: -- 个人学习、研究、教育用途 -- 非营利组织使用 -- 开源项目集成(需遵循相同协议) -- 学术研究和论文发表 +## 说明 -### ❌ 禁止的用途: -- 任何形式的商业使用 -- 年收入超过100万美元的企业使用 -- 风投支持或公开交易的公司使用 -- 提供付费服务或产品 -- 商业竞争用途 +上游项目与本文档的许可证见 `LICENSE`(上游为 MIT,本分支的部署脚本同样遵循)。 diff --git a/cmd/credit/main.go b/cmd/credit/main.go new file mode 100644 index 0000000000000000000000000000000000000000..50cf3e0d786e50ec0f4616d2b1e2e2c7f7052975 --- /dev/null +++ b/cmd/credit/main.go @@ -0,0 +1,294 @@ +// credit.go — WorkBuddy 积分查询(全部账号 + 总计),JSON 输出到 stdout。 +// +// 用法: +// +// go run ./cmd/credit # 或编译后 ./credit +// +// 输出结构: +// +// {"service":"workbuddy","ts":N, +// "total":{"remain":N,"used":N,"size":N,"accounts":N,"ok":N,"failed":N}, +// "accounts":[{"uid","nickname","remain","used","size","packages","ok","error?"}]} +// +// 接口与聚合逻辑:POST codebuddy.cn/v2/billing/meter/get-user-resource,聚合所有 package 的 +// Cycle* 字段,TotalDosage 作 size 下限。 +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const billingBaseCN = "https://www.codebuddy.cn" + +// billingBaseGlobal 国际版计费域。global 账号打 CN 域会得到 401: +// www.codebuddy.cn 不认 workbuddy.ai 的 token(实测 401,workbuddy.ai 同 token 为 code=0)。 +const billingBaseGlobal = "https://www.workbuddy.ai" + +// billingBaseFor 按账号 realm 选择计费域。 +// +// 判定口径与 internal/auth.Realm() 一致:显式 realm=global 或 domain 落在 +// workbuddy.ai 家族,都按国际版处理。cmd/credit 早先对所有账号硬编码 CN 域, +// 导致 global 账号余额查询恒返回 401(面板显示的是池内缓存值,不是实时的)。 +func billingBaseFor(af *authFile) string { + if strings.EqualFold(strings.TrimSpace(af.Auth.Realm), "global") { + return billingBaseGlobal + } + d := strings.ToLower(strings.TrimSpace(af.Auth.Domain)) + if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") { + return billingBaseGlobal + } + return billingBaseCN +} + +type authFile struct { + Auth struct { + AccessToken string `json:"accessToken"` + Domain string `json:"domain"` + Realm string `json:"realm"` + } `json:"auth"` + Account struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } `json:"account"` +} + +type accountResult struct { + UID string `json:"uid"` + Nickname string `json:"nickname"` + Remain *int64 `json:"remain"` + Used *int64 `json:"used"` + Size *int64 `json:"size"` + Packages int `json:"packages,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type resourcePackage struct { + CapacityRemain int64 `json:"CapacityRemain"` + CapacityUsed int64 `json:"CapacityUsed"` + CapacitySize int64 `json:"CapacitySize"` + CycleCapacityRemain int64 `json:"CycleCapacityRemain"` + CycleCapacityUsed int64 `json:"CycleCapacityUsed"` + CycleCapacitySize int64 `json:"CycleCapacitySize"` +} + +// packageRemainUsed 与 billing.go:203-258 一致 +func packageRemainUsed(a resourcePackage) (remain, used, size int64) { + if a.CycleCapacitySize > 0 { + remain = a.CycleCapacityRemain + size = a.CycleCapacitySize + if remain < 0 { + remain = 0 + } + if remain > size { + remain = size + } + used = size - remain + if a.CycleCapacityUsed > used { + used = a.CycleCapacityUsed + if size >= used { + remain = size - used + } + } + return remain, used, size + } + remain = a.CapacityRemain + used = a.CapacityUsed + size = a.CapacitySize + if used == 0 && size > remain { + used = size - remain + } + return remain, used, size +} + +func fetchUserResource(af *authFile) (remain, used, size int64, packs int, err error) { + now := time.Now() + body, _ := json.Marshal(map[string]any{ + "PageNumber": 1, + "PageSize": 100, + "ProductCode": "p_tcaca", + "Status": []int{0, 3}, + "PackageEndTimeRangeBegin": now.Format("2006-01-02 15:04:05"), + "PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format("2006-01-02 15:04:05"), + }) + base := billingBaseFor(af) + // global 域无 /v2 前缀(与 internal/upstream 的 billingMeterPaths 同口径)。 + path := "/v2/billing/meter/get-user-resource" + if base == billingBaseGlobal { + path = "/billing/meter/get-user-resource" + } + req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body)) + if err != nil { + return 0, 0, 0, 0, err + } + req.Header.Set("Authorization", "Bearer "+af.Auth.AccessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if af.Account.UID != "" { + req.Header.Set("X-User-Id", af.Account.UID) + } + if af.Account.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", af.Account.EnterpriseID) + req.Header.Set("X-Tenant-Id", af.Account.EnterpriseID) + } + if af.Auth.Domain != "" { + req.Header.Set("X-Domain", af.Auth.Domain) + } + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, 0, 0, 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, 0, 0, 0, fmt.Errorf("http %d", resp.StatusCode) + } + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Response struct { + Data struct { + TotalDosage int64 `json:"TotalDosage"` + Accounts []resourcePackage `json:"Accounts"` + } `json:"Data"` + } `json:"Response"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + return 0, 0, 0, 0, err + } + if env.Code != 0 { + return 0, 0, 0, 0, fmt.Errorf("code=%d %s", env.Code, env.Msg) + } + for _, a := range env.Data.Response.Data.Accounts { + r, u, s := packageRemainUsed(a) + remain += r + used += u + size += s + } + packs = len(env.Data.Response.Data.Accounts) + if size > 0 { + if derived := size - remain; derived > used { + used = derived + } + } + if dosage := env.Data.Response.Data.TotalDosage; dosage > size { + size = dosage + if derived := size - remain; derived > used { + used = derived + } + } + return remain, used, size, packs, nil +} + +func main() { + pretty := len(os.Args) > 1 && os.Args[1] == "-pretty" + authDir := "./auths" + if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { + authDir = v + } + files, _ := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json")) + sort.Strings(files) + + accounts := make([]accountResult, 0, len(files)) + for _, f := range files { + var af authFile + raw, err := os.ReadFile(f) + if err != nil || json.Unmarshal(raw, &af) != nil { + continue + } + res := accountResult{UID: af.Account.UID, Nickname: af.Account.Nickname} + if af.Auth.AccessToken == "" { + res.Error = "no accessToken" + accounts = append(accounts, res) + continue + } + remain, used, size, packs, err := fetchUserResource(&af) + if err != nil { + res.Error = err.Error() + } else { + res.Remain = &remain + res.Used = &used + res.Size = &size + res.Packages = packs + res.OK = true + } + accounts = append(accounts, res) + time.Sleep(200 * time.Millisecond) + } + + var totalRemain, totalUsed, totalSize int64 + okCount := 0 + for _, a := range accounts { + if a.OK { + okCount++ + if a.Remain != nil { + totalRemain += *a.Remain + } + if a.Used != nil { + totalUsed += *a.Used + } + if a.Size != nil { + totalSize += *a.Size + } + } + } + out := map[string]any{ + "service": "workbuddy", + "ts": time.Now().Unix(), + "total": map[string]any{ + "remain": totalRemain, + "used": totalUsed, + "size": totalSize, + "accounts": len(accounts), + "ok": okCount, + "failed": len(accounts) - okCount, + }, + "accounts": accounts, + } + if pretty { + printPretty(accounts, totalRemain, totalUsed, totalSize, okCount) + return + } + raw, _ := json.Marshal(out) + fmt.Println(string(raw)) +} + +// printPretty 人类可读日报:四行汇总,无账号明细。 +func printPretty(accounts []accountResult, totalRemain, totalUsed, totalSize int64, okCount int) { + withBalance := 0 + var failed []string + for _, a := range accounts { + if a.OK && a.Remain != nil && *a.Remain > 0 { + withBalance++ + } + if !a.OK { + name := a.Nickname + if name == "" && len(a.UID) >= 8 { + name = a.UID[:8] + } + failed = append(failed, name+" "+a.Error) + } + } + pct := int64(0) + if totalSize > 0 { + pct = totalRemain * 100 / totalSize + } + fmt.Printf("📊 WorkBuddy 积分日报\n") + fmt.Printf("账号: %d/%d\n", withBalance, len(accounts)) + fmt.Printf("总计: %d/%d\n", totalRemain, totalSize) + fmt.Printf("剩余: %d%%\n", pct) + for _, f := range failed { + fmt.Printf("⚠️ %s\n", f) + } +} diff --git a/cmd/login/main.go b/cmd/login/main.go new file mode 100644 index 0000000000000000000000000000000000000000..d1b36a7b92ddbd3e4adc6e48be39bac00aa784ca --- /dev/null +++ b/cmd/login/main.go @@ -0,0 +1,336 @@ +// login.go — WorkBuddy OAuth 登录(设备授权流程,CN realm;--realm=global 供国际版)。 +// +// 两个子命令,由 login.sh 顺序驱动: +// +// login [--realm=cn|global] url → POST /v2/plugin/auth/state?platform=CLI 拿 state+authUrl, +// state 落 /tmp/wb2api-login-state.json,stdout 打印授权 URL +// login [--realm=cn|global] poll → 读 state,GET /v2/plugin/auth/token?state= 一次, +// 成功再 GET /v2/plugin/login/account?state= 拿 uid/nickname, +// stdout 打印完整 token+account JSON(含 realm 键) +// +// --realm 默认 cn。按 realm 切换上游端点与 Origin/Referer: +// +// cn → https://copilot.tencent.com(Origin: https://www.codebuddy.cn) +// global → https://www.workbuddy.ai(Origin: https://www.workbuddy.ai) +// +// state 落盘带 realm,poll 读回校验与命令行 --realm 一致(防混域)。 +// 无 PKCE(workbuddy 设备流由服务端签发 state)。 +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "strings" + "time" + + auth2 "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// 上游常量:CN → copilot.tencent.com(Origin 为 codebuddy.cn);global → www.workbuddy.ai +// (base 与 Origin/Referer 同域)。端点 URL 由 realmConfig 按 realm 动态拼出,不再硬编码。 +const ( + upstreamBaseCN = "https://copilot.tencent.com" + upstreamBaseGlobal = "https://www.workbuddy.ai" + clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" + originRefererCN = "https://www.codebuddy.cn" + originRefererGlobal = "https://www.workbuddy.ai" +) + +// 登录 state 落盘路径(var 便于测试替换临时文件) +// Portable across OSes: the upstream hardcoded "/tmp/...", which on +// Windows resolves to :\tmp\... and aborts the OAuth flow with +// "The system cannot find the path specified". os.TempDir() is /tmp on Linux. +var stateFile = filepath.Join(os.TempDir(), "wb2api-login-state.json") + +// exitFunc 供测试替换(默认 os.Exit;测试持临时替换为 panic 以进程内捕获 fatal)。 +var exitFunc = os.Exit + +// realmConfig 按 realm 返回上游 base 与 Origin/Referer origin:global → +// (www.workbuddy.ai, www.workbuddy.ai);cn/非法/缺省 → (copilot.tencent.com, codebuddy.cn)。 +func realmConfig(realm string) (base, origin string) { + if realm == realmGlobal { + return upstreamBaseGlobal, originRefererGlobal + } + return upstreamBaseCN, originRefererCN +} + +// commonHeaders 按 origin 设置通用请求头(Origin/Referer 随 realm 变化)。 +// 返回 func(*http.Request),由调用方按 realm 选定的 origin 构造一次后复用。 +func commonHeaders(origin string) func(*http.Request) { + return func(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Origin", origin) + req.Header.Set("Referer", origin+"/") + req.Header.Set("User-Agent", clientUA) + } +} + +// apiEnvelope 与 main.go:429-433 一致 +type apiEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// doJSON 与 oauth.go:33-66 一致:{code,msg,data} 信封,code!=0 → error +func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + if headers != nil { + headers(req) + } else { + // 缺省头:CN origin(与原 commonHeaders() 行为一致,零回归) + commonHeaders(originRefererCN)(req) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) + } + if resp.StatusCode >= 300 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream redirect %d", resp.StatusCode) + } + var env apiEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) + } + if env.Code != 0 { + return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) + } + return env.Data, resp.StatusCode, nil +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "login: "+format+"\n", args...) + exitFunc(1) +} + +type loginState struct { + State string `json:"state"` + Realm string `json:"realm,omitempty"` // url 落盘时写回的 realm,poll 读回校验防混域 +} + +// realm 取值枚举(与 internal/auth 的 Realm() 归一化输出一致)。 +const ( + realmCN = "cn" + realmGlobal = "global" +) + +// parseRealmArgs 解析开头的 --realm=cn|global(或分离式 --realm )flag,缺省 cn。 +// 大小写不敏感归一化;非法值/缺值报错。桌椅剩余参数(子命令)顺序不变。 +func parseRealmArgs(args []string) (realm string, rest []string, err error) { + realm = realmCN + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--realm": + if i+1 >= len(args) { + return "", nil, fmt.Errorf("--realm requires a value") + } + v := strings.ToLower(strings.TrimSpace(args[i+1])) + if v != realmCN && v != realmGlobal { + return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", args[i+1]) + } + realm = v + i++ + case strings.HasPrefix(a, "--realm="): + v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--realm="))) + if v != realmCN && v != realmGlobal { + return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", v) + } + realm = v + default: + rest = append(rest, a) + } + } + return realm, rest, nil +} + +// resolveRealmInput 把交互式选域的一行输入归一化为 realm(纯函数,login.sh 交互分支 +// 的核心决策,可测)。规则: +// +// "1"/"cn"(大小写不敏感)/""(回车默认)→ cn +// "2"/"global" → global +// 其他 → ("", false)(调用方回默认 cn) +func resolveRealmInput(input string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "", "1", "cn": + return realmCN, true + case "2", "global": + return realmGlobal, true + } + return "", false +} + +// promptRealm 交互式选域:向 out 打印选项提示(out 接 stderr,stdout 留给 realm 本身), +// 从 in 读一行,返回归一化 realm。非法输入警告后回落 cn;EOF(非交互/管道)回落 cn。 +func promptRealm(in io.Reader, out io.Writer) string { + fmt.Fprintln(out, "选择登录版本: 1) 国内版(cn) 2) 国际版(global) [默认 1/cn]: ") + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && line == "" { + // EOF/非交互 → 回落默认 cn + return realmCN + } + if realm, ok := resolveRealmInput(line); ok { + return realm + } + fmt.Fprintln(out, "无效选择,默认国内版 cn") + return realmCN +} + +// validateRealmMatch 校验 state 文件 realm 与命令行 --realm 一致(防混域): +// state 无 realm(旧文件)放行;非空且不一致 → error。 +func validateRealmMatch(stateRealm, cliRealm string) error { + if stateRealm != "" && stateRealm != cliRealm { + return fmt.Errorf("realm mismatch: state file realm=%q, command --realm=%q(url 与 poll 需同一 realm)", stateRealm, cliRealm) + } + return nil +} + +// runURL 执行 url 子命令:向 upstreamBase 的 state 端点 POST 取授权 URL, +// state 落盘(带 realm),stdout 打印 authURL。out 接 stdout;stateFile 为落盘路径 +// (可注入临时文件便于测试)。空 realm 视为缺省(调用方已归一)。 +func runURL(base, origin, realm, statePath string, client *http.Client, out io.Writer) { + headers := commonHeaders(origin) + data, _, err := doJSON(client, http.MethodPost, base+"/v2/plugin/auth/state?platform=CLI", headers, bytes.NewReader([]byte("{}"))) + if err != nil { + fatal("auth state failed: %v", err) + } + var st struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` + } + if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { + fatal("auth state: missing state or authUrl") + } + raw, _ := json.Marshal(loginState{State: st.State, Realm: realm}) + if err := os.WriteFile(statePath, raw, 0o600); err != nil { + fatal("write state: %v", err) + } + fmt.Fprintln(out, st.AuthURL) +} + +// runPoll 执行 poll 子命令:读 state 文件(realm 校验),向 upstreamBase 的 token 端点 +// GET 一次,成功再 GET login/account(带 Bearer),stdout 打印完整 token+account JSON。 +// statePath 可注入临时文件便于测试。 +func runPoll(base, origin, realm, statePath string, client *http.Client, out io.Writer) { + raw, err := os.ReadFile(statePath) + if err != nil { + fatal("read state: %v (先跑 login url)", err) + } + var ls loginState + if err := json.Unmarshal(raw, &ls); err != nil { + fatal("parse state: %v", err) + } + // 防混域:state 落盘 realm 与命令行 --realm 不一致则拒绝(url 与 poll 必须同域) + if err := validateRealmMatch(ls.Realm, realm); err != nil { + fatal("%v", err) + } + headers := commonHeaders(origin) + // handlePollLogin (oauth.go:108-162):auth/token 是权威登录状态端点, + // pending 时业务 code 非 0("login ing"),完成时 code=0 + token bundle + tokRaw, status, errTok := doJSON(client, http.MethodGet, base+"/v2/plugin/auth/token?state="+ls.State, headers, nil) + if errTok != nil { + if status == 0 || status >= 500 { + fatal("token endpoint error: %v", errTok) + } + fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y") + } + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + } + if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { + fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y") + } + // login/account 拿 uid/nickname(带 Bearer) + var acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } + acctHeaders := func(r *http.Request) { + headers(r) + r.Header.Set("Authorization", "Bearer "+tok.AccessToken) + } + if acctRaw, _, errAcct := doJSON(client, http.MethodGet, base+"/v2/plugin/login/account?state="+ls.State, acctHeaders, nil); errAcct == nil { + _ = json.Unmarshal(acctRaw, &acct) + } + oraw, _ := json.Marshal(buildLoginOutput(tok, realm, acct)) + fmt.Fprintln(out, string(oraw)) + os.Remove(statePath) +} + +// buildLoginOutput 组装 poll 输出的完整 JSON(login.sh 据此落盘 auth 文件)。 +// realm 永不空:显式 --realm 优先(ResolveRealm 处理),否则按上游返回的 domain 推断—— +// 保证登录落盘的 auth 文件恒带 realm 键。 +func buildLoginOutput(tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` +}, realm string, acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` +}) map[string]any { + return map[string]any{ + "access_token": tok.AccessToken, + "refresh_token": tok.RefreshToken, + "expires_in": tok.ExpiresIn, + "domain": tok.Domain, + "realm": auth2.ResolveRealm(realm, tok.Domain), + "uid": acct.UID, + "enterprise_id": acct.EnterpriseID, + "nickname": acct.Nickname, + } +} + +func main() { + realm, rest, err := parseRealmArgs(os.Args[1:]) + if err != nil { + fatal("%v (usage: login [--realm=cn|global] )", err) + } + if len(rest) < 1 { + fatal("usage: login [--realm=cn|global] ") + } + // 每个流程独立 cookie jar(oauth.go:22-29:多账号登录互不串会话) + jar, _ := cookiejar.New(nil) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + + base, origin := realmConfig(realm) + + switch rest[0] { + case "url": + runURL(base, origin, realm, stateFile, client, os.Stdout) + + case "poll": + runPoll(base, origin, realm, stateFile, client, os.Stdout) + + case "realm": + // 交互式选域(login.sh 无 --realm 传参且 stdin 为 tty 时调用)。 + // 提示打到 stderr,stdout 只输出归一化 realm,供 $( ) 捕获。 + realm := promptRealm(os.Stdin, os.Stderr) + fmt.Println(realm) + + default: + fatal("unknown subcommand %q (want url|poll|realm)", rest[0]) + } +} diff --git a/cmd/login/realm_test.go b/cmd/login/realm_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0247464195f5781a39b001daaa27b43aa08706f2 --- /dev/null +++ b/cmd/login/realm_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +// TestParseRealmArgs 覆盖 --realm 参数解析:缺省 cn、等号/分离式写法、 +// 非法值/缺值报错、大小写归一、剥离 flag 后剩余参数保持相对顺序。 +func TestParseRealmArgs(t *testing.T) { + cases := []struct { + name string + args []string + wantRealm string + wantRest []string + wantErr bool + }{ + {name: "缺省 cn", args: []string{"url"}, wantRealm: "cn", wantRest: []string{"url"}}, + {name: "等号 global 在前", args: []string{"--realm=global", "url"}, wantRealm: "global", wantRest: []string{"url"}}, + {name: "等号 cn 在后", args: []string{"poll", "--realm=cn"}, wantRealm: "cn", wantRest: []string{"poll"}}, + {name: "分离式 global", args: []string{"--realm", "global", "url"}, wantRealm: "global", wantRest: []string{"url"}}, + {name: "非法值报错", args: []string{"--realm=foo", "url"}, wantErr: true}, + {name: "分离式缺值报错", args: []string{"--realm", "url"}, wantErr: true}, + {name: "大小写归一", args: []string{"--realm=GLOBAL", "url"}, wantRealm: "global", wantRest: []string{"url"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + realm, rest, err := parseRealmArgs(c.args) + if c.wantErr { + if err == nil { + t.Fatalf("parseRealmArgs(%v) err=nil, want error", c.args) + } + return + } + if err != nil { + t.Fatalf("parseRealmArgs(%v) err=%v", c.args, err) + } + if realm != c.wantRealm { + t.Errorf("realm=%q want %q", realm, c.wantRealm) + } + if !reflect.DeepEqual(rest, c.wantRest) { + t.Errorf("rest=%v want %v", rest, c.wantRest) + } + }) + } +} + +// TestResolveRealmInput 覆盖交互式选域的输入→realm 映射(login.sh 交互分支的核心决策): +// "1"/"cn"/""(回车默认)→ cn;"2"/"global" → global;大小写不敏感;非法 → ("",false)。 +func TestResolveRealmInput(t *testing.T) { + cases := []struct { + input string + want string + }{ + {input: "1", want: "cn"}, + {input: "cn", want: "cn"}, + {input: "CN", want: "cn"}, + {input: "", want: "cn"}, // 回车默认 + {input: "2", want: "global"}, + {input: "global", want: "global"}, + {input: "GLOBAL", want: "global"}, + } + for _, c := range cases { + got, ok := resolveRealmInput(c.input) + if !ok { + t.Errorf("resolveRealmInput(%q) ok=false want true", c.input) + continue + } + if got != c.want { + t.Errorf("resolveRealmInput(%q)=%q want %q", c.input, got, c.want) + } + } + // 非法输入 → (false)。 + for _, bad := range []string{"3", "cnn", "globalx", "foo"} { + if got, ok := resolveRealmInput(bad); ok { + t.Errorf("resolveRealmInput(%q)=(%q,true) want (_,false)", bad, got) + } + } +} + +// TestPromptRealm 覆盖 promptRealm 的 I/O 行为(out 将接 os.Stderr,stdout 只出 realm): +// +// 1 / 2 / 回车默认 → 分别输出来 cn / global / cn;均打印"选择登录版本"提示; +// 非法输入 → 警告并回落 cn;EOF(非交互直接管道)→ 回落 cn。 +func TestPromptRealm(t *testing.T) { + cases := []struct { + name string + input string + want string + wantHint bool // 输出中出现"选择登录版本"提示 + }{ + {name: "选 cn", input: "1\n", want: "cn", wantHint: true}, + {name: "选 global", input: "2\n", want: "global", wantHint: true}, + {name: "回车默认 cn", input: "\n", want: "cn", wantHint: true}, + {name: "非法回落 cn", input: "foo\n", want: "cn", wantHint: true}, + {name: "EOF 回落 cn", input: "", want: "cn", wantHint: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var out bytes.Buffer + got := promptRealm(strings.NewReader(c.input), &out) + if got != c.want { + t.Errorf("promptRealm(%q)=%q want %q", c.input, got, c.want) + } + if c.wantHint && !strings.Contains(out.String(), "选择登录版本") { + t.Errorf("prompt should contain '选择登录版本', got %q", out.String()) + } + }) + } +} + +// TestRealmSubcommandSelection 通过命令形式验证 realm 子命令(login.sh 交互分支调用): +// 子命令剥离 --realm,剩余参数为首个 "realm"。 +func TestRealmSubcommandSelection(t *testing.T) { + realm, rest, err := parseRealmArgs([]string{"realm"}) + if err != nil { + t.Fatalf("parseRealmArgs(realm) err=%v", err) + } + if realm != "cn" { + t.Errorf("realm default=%q want cn", realm) + } + if len(rest) != 1 || rest[0] != "realm" { + t.Errorf("rest=%v want [realm]", rest) + } +} + +// TestBuildLoginOutputRealmAlwaysSet 登录产物(login.sh 据此落盘 auth 文件)恒含 realm 键: +// 显式 --realm 优先,缺省时按 domain 推断(echo 出的 global 账号即便未显式指定也带 global)。 +// 这是「登录落盘永远带 realm 标识」契约的测试载体。 +func TestBuildLoginOutputRealmAlwaysSet(t *testing.T) { + tok := struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + }{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3600, Domain: "www.codebuddy.cn"} + acct := struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + }{UID: "u1", Nickname: "n1"} + + cases := []struct { + name string + realm string + domain string + wantRealm string + }{ + {name: "显式 global 优先", realm: "global", domain: "www.codebuddy.cn", wantRealm: "global"}, + {name: "显式 cn 优先", realm: "cn", domain: "www.workbuddy.ai", wantRealm: "cn"}, + {name: "缺省按 domain 推断 global", realm: "", domain: "www.workbuddy.ai", wantRealm: "global"}, + {name: "缺省空 domain 回落 cn", realm: "", domain: "", wantRealm: "cn"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tok.Domain = c.domain + out := buildLoginOutput(tok, c.realm, acct) + m, ok := out["realm"].(string) + if !ok { + t.Fatalf("missing realm key in login output: %v", out) + } + if m != c.wantRealm { + t.Errorf("realm=%q want %q", m, c.wantRealm) + } + }) + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go new file mode 100644 index 0000000000000000000000000000000000000000..ca77fd8bfefe04d27e95fe64046a2a4ec5e47b91 --- /dev/null +++ b/cmd/server/config.go @@ -0,0 +1,616 @@ +// config.go 加载 JSON 配置 + 环境变量覆盖。 +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/prompt" +) + +// Config 顶层配置。 +type Config struct { + Listen string `json:"listen"` // ":7863" + APIKey string `json:"api_key"` // 空 = 不鉴权 + AuthDir string `json:"auth_dir"` // ./auths + StateFile string `json:"state_file"` // ./data/state.json + + Panel struct { + // PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。 + PackageDetailLimit int `json:"package_detail_limit"` + } `json:"panel"` + + Logging struct { + // RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。 + RequestArchiveEnabled bool `json:"request_archive_enabled"` + // RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。 + RequestRetentionDays int `json:"request_retention_days"` + // RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。 + RequestArchiveMaxMB int `json:"request_archive_max_mb"` + // RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行 + // 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。 + // + // 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索, + // 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要 + // 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。 + // 热生效(经 livecfg 快照),无需重启。 + RequestClientInfo bool `json:"request_client_info"` + } `json:"logging"` + + Cooldown struct { + // hard_credit / err_threshold / err_cooldown 三个历史键已退役: + // 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。 + // 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。 + SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数 + // SoftRateMax 软冷却指数退避的封顶,默认 "2h"。 + // 空值回落默认,非法值报错(处理风格同 soft_rate)。 + SoftRateMax string `json:"soft_rate_max"` // "2h" + } `json:"cooldown"` + + Schedule struct { + CheckinHours []int `json:"checkin_hours"` // [9,21] + TravelHours []int `json:"travel_hours"` // [9,21] + ActivityHours []int `json:"activity_hours"` // [10] + KeepaliveHours []int `json:"keepalive_hours"` // [22] + BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数) + GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行) + // CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。 + // + // 为什么用独立 bool 而不是空数组/哨兵值表意"禁用": + // - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转 + // 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true + // 则对老配置零影响,向后完全兼容。 + // - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。 + // - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。 + // 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。 + CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到 + TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行 + ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报 + KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活 + BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子 + GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程 + + // 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。 + // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。 + BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭 + BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5 + } `json:"schedule"` + + Global struct { + // Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/ + // domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。 + // 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global + // 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号 + // 恒判 cn,global base 只在 realm=global 的账号上被使用。 + Enabled bool `json:"enabled"` + // ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认 + // https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。 + ChatBase string `json:"chat_base"` + BillingBase string `json:"billing_base"` + } `json:"global"` + + Upstream struct { + // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 + TimeoutSeconds int `json:"timeout_seconds"` + // HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。 + HeaderTimeoutSeconds int `json:"header_timeout_seconds"` + // IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。 + IdleTimeoutSeconds int `json:"idle_timeout_seconds"` + // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。 + // 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。 + // 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。 + UserAgent string `json:"user_agent"` + // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/` 与归属头 + // X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。 + ClientVersion string `json:"client_version"` + // CliVersion 出站 UA 中 `CLI/` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。 + CliVersion string `json:"cli_version"` + // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。 + // 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。 + ClientName string `json:"client_name"` + // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。 + // 每号 auth 文件的 device_token 键优先于本项。 + DeviceToken string `json:"device_token"` + // DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。 + DeviceTokenFile string `json:"device_token_file"` + // PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。 + PassthroughIP bool `json:"passthrough_ip"` + } `json:"upstream"` + + Features struct { + // SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。 + SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"` + } `json:"features"` + + Prompt struct { + // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded); + // custom = 网关用自有系统提示词替换客户端 system/developer; + // append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。 + Mode string `json:"mode"` // "passthrough" / "custom" / "append" + // File 提示词文件路径;空 = 内置默认 defaultprompt.md; + // 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。 + File string `json:"file"` + } `json:"prompt"` + + // PromptText 解析后的系统提示词文本(custom/append 模式使用)。 + PromptText string `json:"-"` + + Upstash struct { + URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host + Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:@:6379 + } `json:"upstash"` + + Pool struct { + MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 + MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 + BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 + BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" + BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" + // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈 + // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。 + DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5 + DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m" + DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制) + IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重 + IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0 + // PreferExpiring 最早到期优先路由开关,默认 true。开启且 expiring_soon 窗口内 + // 存在有效批次时,按最早到期时间排序;关闭后完全不使用到期信息选号。 + PreferExpiring bool `json:"prefer_expiring"` + // ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在 + // 此窗口内的积分进入优先集,再按最早到期排序。空/0 = 禁用该路由门槛。 + ExpiringSoon string `json:"expiring_soon"` + // CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0 + // 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切 + // tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有 + // 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为); + // 空值回落默认。 + CostExploreInterval string `json:"cost_explore_interval"` + // CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再 + // 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。 + // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。 + // 默认 0 = 关闭;负值钳 0。 + CreditFloor int64 `json:"credit_floor"` + } `json:"pool"` + + SessionSticky struct { + Enabled bool `json:"enabled"` // 默认 true + TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m" + GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m" + } `json:"session_sticky"` + + // 解析后 + SoftRateDur time.Duration `json:"-"` + SoftRateMaxDur time.Duration `json:"-"` + BreakerCooldownDur time.Duration `json:"-"` + BreakerCooldownMaxD time.Duration `json:"-"` + DegradeCooldownDur time.Duration `json:"-"` + DegradeCooldownMaxD time.Duration `json:"-"` + SessionTTL time.Duration `json:"-"` + SessionGCInterval time.Duration `json:"-"` + BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false) + ExpiringSoonDur time.Duration `json:"-"` + // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。 + CostExploreIntervalDur time.Duration `json:"-"` +} + +// Default 默认配置。 +func Default() *Config { + c := &Config{ + Listen: ":7863", + APIKey: "", + AuthDir: "./auths", + StateFile: "./data/state.json", + } + c.Cooldown.SoftRate = "600s" + c.Cooldown.SoftRateMax = "2h" + c.Panel.PackageDetailLimit = 5 + c.Logging.RequestArchiveEnabled = true + c.Logging.RequestRetentionDays = 7 + c.Logging.RequestArchiveMaxMB = 100 + // 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值, + // 只有显式 false 才关闭来源记录。 + c.Logging.RequestClientInfo = true + c.Schedule.CheckinHours = []int{9, 21} + c.Schedule.TravelHours = []int{9, 21} + c.Schedule.ActivityHours = []int{10} + c.Schedule.KeepaliveHours = []int{22} + c.Schedule.BlackcatHours = []int{23} + c.Schedule.GrowthHours = []int{1} + // 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖, + // 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。 + c.Schedule.CheckinEnabled = true + c.Schedule.GrowthEnabled = true + c.Schedule.TravelEnabled = true + c.Schedule.ActivityEnabled = true + c.Schedule.KeepaliveEnabled = true + c.Schedule.BlackcatEnabled = true + c.Schedule.BalanceRefreshEnabled = true + c.Schedule.BalanceRefreshMinutes = 5 + c.Upstream.TimeoutSeconds = 120 + // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。 + c.Upstream.HeaderTimeoutSeconds = 0 + c.Upstream.IdleTimeoutSeconds = 0 + // Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用); + // ChatBase/BillingBase 缺省空(回落内置默认)。 + c.Global.Enabled = true + c.Features.SanitizeBlacklistFingerprints = true + c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择) + c.Pool.MaxInFlight = 3 + // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复 + // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键 + // 的 0 没有合理语义,回退分档默认最稳)。 + c.Pool.MaxInFlightGlobal = 2 + c.Pool.BreakerThreshold = 3 + c.Pool.BreakerCooldown = "30m" + c.Pool.BreakerCooldownMax = "6h" + c.Pool.DegradeThreshold = 5 + c.Pool.DegradeCooldown = "10m" + c.Pool.DegradeCooldownMax = "2h" + c.Pool.IdleWeightPerHour = 0.5 + c.Pool.IdleWeightMax = 5.0 + c.Pool.PreferExpiring = true + c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期 + // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。 + c.Pool.CostExploreInterval = "30m" + c.SessionSticky.Enabled = true + c.SessionSticky.TTL = "30m" + c.SessionSticky.GCInterval = "5m" + return c +} + +// Load 从文件读,再用 WB2A_* env 覆盖。 +func Load(path string) (*Config, error) { + c := Default() + if path != "" { + // 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录, + // 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。 + if st, statErr := os.Stat(path); statErr == nil && st.IsDir() { + return nil, fmt.Errorf("config %s 是目录而非文件——"+ + "Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+ + "请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + if _, err := ParseConfigInto(raw, c); err != nil { + return nil, err + } + } + applyEnv(c) + if err := c.normalize(); err != nil { + return nil, err + } + return c, nil +} + +// ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。 +// 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。 +func ParseConfigInto(raw []byte, c *Config) (*Config, error) { + if err := json.Unmarshal(raw, c); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if err := c.normalize(); err != nil { + return nil, err + } + return c, nil +} + +// ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。 +func ParseConfig(raw []byte) (*Config, error) { + return ParseConfigInto(raw, Default()) +} + +// WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。 +// 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成: +// 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。 +// 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。 +func WriteDefault(path string) (string, error) { + raw := make([]byte, 18) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("gen api_key: %w", err) + } + key := "sk-" + base64.RawURLEncoding.EncodeToString(raw) + c := Default() + c.APIKey = key + _ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值 + out, err := json.MarshalIndent(c, "", " ") + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir config dir: %w", err) + } + } + // O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", fmt.Errorf("write config: %w", err) + } + defer f.Close() + if _, err := f.Write(out); err != nil { + return "", fmt.Errorf("write config: %w", err) + } + return key, nil +} + +func applyEnv(c *Config) { + if v := os.Getenv("WB2A_LISTEN"); v != "" { + c.Listen = v + } + if v := os.Getenv("WB2A_API_KEY"); v != "" { + c.APIKey = v + } + if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { + c.AuthDir = v + } + if v := os.Getenv("WB2A_STATE_FILE"); v != "" { + c.StateFile = v + } + if v := os.Getenv("WB2A_SOFT_RATE"); v != "" { + c.Cooldown.SoftRate = v + } + if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" { + c.Cooldown.SoftRateMax = v + } + if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Upstream.TimeoutSeconds = n + } + } + if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Upstream.HeaderTimeoutSeconds = n + } + } + if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Upstream.IdleTimeoutSeconds = n + } + } + if v := os.Getenv("WB2A_USER_AGENT"); v != "" { + c.Upstream.UserAgent = v + } + if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" { + c.Upstream.ClientVersion = v + } + if v := os.Getenv("WB2A_CLI_VERSION"); v != "" { + c.Upstream.CliVersion = v + } + if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" { + c.Upstream.ClientName = v + } + if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" { + c.Upstream.DeviceToken = v + } + if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" { + c.Upstream.DeviceTokenFile = v + } + if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + c.Upstream.PassthroughIP = b + } + } + if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + c.Features.SanitizeBlacklistFingerprints = b + } + } + if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" { + c.Prompt.Mode = v + } + if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" { + c.Prompt.File = v + } + if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" { + c.Pool.ExpiringSoon = v + } + if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + c.Pool.PreferExpiring = b + } + } +} + +func (c *Config) normalize() error { + var err error + if c.Panel.PackageDetailLimit <= 0 { + c.Panel.PackageDetailLimit = 5 + } + if c.Logging.RequestRetentionDays <= 0 { + c.Logging.RequestRetentionDays = 7 + } + if c.Logging.RequestArchiveMaxMB <= 0 { + c.Logging.RequestArchiveMaxMB = 100 + } + if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil { + return fmt.Errorf("cooldown.soft_rate: %w", err) + } + // 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。 + if c.Cooldown.SoftRateMax == "" { + c.Cooldown.SoftRateMax = "2h" + } + if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil { + return fmt.Errorf("cooldown.soft_rate_max: %w", err) + } + if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil { + return fmt.Errorf("pool.breaker_cooldown: %w", err) + } + if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil { + return fmt.Errorf("pool.breaker_cooldown_max: %w", err) + } + if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil { + return fmt.Errorf("pool.degrade_cooldown: %w", err) + } + if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil { + return fmt.Errorf("pool.degrade_cooldown_max: %w", err) + } + if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil { + return fmt.Errorf("session_sticky.ttl: %w", err) + } + if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil { + return fmt.Errorf("session_sticky.gc_interval: %w", err) + } + // 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。 + if c.Pool.ExpiringSoon != "" { + if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil { + return fmt.Errorf("pool.expiring_soon: %w", err) + } + } + if c.ExpiringSoonDur < 0 { + c.ExpiringSoonDur = 0 + c.Pool.ExpiringSoon = "0" + } + // costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖 + // 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停 + //("−5m" 无合理语义)。 + if c.Pool.CostExploreInterval == "" { + c.Pool.CostExploreInterval = "30m" + } + if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil { + return fmt.Errorf("pool.cost_explore_interval: %w", err) + } + if c.CostExploreIntervalDur < 0 { + c.CostExploreIntervalDur = 0 + } + // 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。 + if c.Pool.CreditFloor < 0 { + c.Pool.CreditFloor = 0 + } + if c.Pool.BreakerThreshold <= 0 { + c.Pool.BreakerThreshold = 3 + } + // 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。 + if c.Pool.DegradeThreshold <= 0 { + c.Pool.DegradeThreshold = 5 + } + if c.Pool.DegradeCooldown == "" { + c.Pool.DegradeCooldown = "10m" + } + if c.Pool.DegradeCooldownMax == "" { + c.Pool.DegradeCooldownMax = "2h" + } + // global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。 + if c.Pool.MaxInFlightGlobal <= 0 { + c.Pool.MaxInFlightGlobal = 2 + } + if c.Pool.IdleWeightPerHour <= 0 { + c.Pool.IdleWeightPerHour = 0.5 + } + if c.Pool.IdleWeightMax <= 0 { + c.Pool.IdleWeightMax = 5.0 + } + if c.Upstream.TimeoutSeconds <= 0 { + c.Upstream.TimeoutSeconds = 120 + } + // header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。 + // 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。 + if c.Upstream.HeaderTimeoutSeconds <= 0 { + c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds + } + if c.Upstream.IdleTimeoutSeconds <= 0 { + c.Upstream.IdleTimeoutSeconds = 300 + } + if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") { + c.Listen = ":" + c.Listen + } + // 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。 + // 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。 + if len(c.Schedule.CheckinHours) == 0 { + c.Schedule.CheckinHours = []int{9, 21} + } + if len(c.Schedule.TravelHours) == 0 { + c.Schedule.TravelHours = []int{9, 21} + } + if len(c.Schedule.ActivityHours) == 0 { + c.Schedule.ActivityHours = []int{10} + } + if len(c.Schedule.KeepaliveHours) == 0 { + c.Schedule.KeepaliveHours = []int{22} + } + if len(c.Schedule.BlackcatHours) == 0 { + c.Schedule.BlackcatHours = []int{23} + } + if len(c.Schedule.GrowthHours) == 0 { + c.Schedule.GrowthHours = []int{1} + } + // 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。 + if c.Schedule.BalanceRefreshEnabled { + if c.Schedule.BalanceRefreshMinutes <= 0 { + c.Schedule.BalanceRefreshMinutes = 5 + } + c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute + } + if err := c.validateScheduleHours(); err != nil { + return err + } + return c.normalizePrompt() +} + +// normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。 +// +// mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支; +// custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认 +// (两模式共用同一加载路径,PromptText 均非空)。 +// passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。 +func (c *Config) normalizePrompt() error { + switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m { + case "", "passthrough": + c.Prompt.Mode = "passthrough" + case "custom": + c.Prompt.Mode = "custom" + case "append": + c.Prompt.Mode = "append" + default: + return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode) + } + if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" { + text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File) + if err != nil { + return err + } + c.PromptText = text + } + return nil +} + +// validateScheduleHours 校验排程小时落在 0-23。 +// +// 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到, +// 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关 +// (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。 +func (c *Config) validateScheduleHours() error { + if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil { + return err + } + if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil { + return err + } + if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil { + return err + } + if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil { + return err + } + if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil { + return err + } + return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours) +} + +func checkHourRange(field, switchKey string, hours []int) error { + for _, h := range hours { + if h < 0 || h > 23 { + return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey) + } + } + return nil +} diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4c1dd61f1bde6d29f4ed72deab97bcd5c8bf409a --- /dev/null +++ b/cmd/server/config_test.go @@ -0,0 +1,816 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDefault(t *testing.T) { + c := Default() + if c.Listen != ":7863" { + t.Errorf("listen=%s", c.Listen) + } + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.SoftRateDur.Seconds() != 600 { + t.Errorf("soft=%v want 600s", c.SoftRateDur) + } +} + +func TestPanelPackageDetailLimit(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Panel.PackageDetailLimit != 5 { + t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit) + } + + configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`)) + if err != nil { + t.Fatalf("parse configured limit: %v", err) + } + if configured.Panel.PackageDetailLimit != 8 { + t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit) + } + + fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`)) + if err != nil { + t.Fatalf("parse fallback limit: %v", err) + } + if fallback.Panel.PackageDetailLimit != 5 { + t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit) + } +} + +func TestLoggingDefaults(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatal(err) + } + if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 { + t.Fatalf("logging defaults = %+v", c.Logging) + } + // 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。 + if !c.Logging.RequestClientInfo { + t.Fatalf("request_client_info default = false, want true: %+v", c.Logging) + } + configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`)) + if err != nil { + t.Fatal(err) + } + if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 { + t.Fatalf("configured logging = %+v", configured.Logging) + } + off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`)) + if err != nil { + t.Fatal(err) + } + if off.Logging.RequestClientInfo { + t.Fatalf("explicit false ignored: %+v", off.Logging) + } + fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`)) + if err != nil { + t.Fatal(err) + } + if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 { + t.Fatalf("logging fallback = %+v", fallback.Logging) + } +} + +func TestLoadFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Listen != ":9999" || c.APIKey != "k" { + t.Errorf("c=%+v", c) + } +} + +func TestEnvOverride(t *testing.T) { + t.Setenv("WB2A_LISTEN", ":7777") + t.Setenv("WB2A_API_KEY", "envkey") + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Listen != ":7777" || c.APIKey != "envkey" { + t.Errorf("c=%+v", c) + } +} + +func TestBadDuration(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad duration") + } +} + +func TestHardCreditKeyIgnored(t *testing.T) { + // 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatalf("hard_credit must be ignored (not validated): %v", err) + } + if c.SoftRateDur.Seconds() != 30 { + t.Errorf("soft_rate=%v want 30s", c.SoftRateDur) + } +} + +func TestNewPoolConfigDefaults(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Pool.MaxInFlight != 3 { + t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight) + } + if c.Pool.BreakerThreshold != 3 { + t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold) + } + if c.BreakerCooldownDur.Minutes() != 30 { + t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur) + } + if c.BreakerCooldownMaxD.Hours() != 6 { + t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD) + } + if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 { + t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax) + } + if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour { + t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur) + } + if c.SoftRateMaxDur.Hours() != 2 { + t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur) + } + if !c.SessionSticky.Enabled { + t.Error("session_sticky.enabled want true") + } + if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 { + t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval) + } + if c.Upstash.URL != "" || c.Upstash.Token != "" { + t.Errorf("upstash default should be empty: %+v", c.Upstash) + } +} + +func TestPoolConfigParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{ + "upstash":{"url":"https://foo.upstash.io","token":"tok"}, + "pool":{ + "max_in_flight":5, + "breaker_threshold":4, + "breaker_cooldown":"10m", + "breaker_cooldown_max":"2h", + "idle_weight_per_hour":0.7, + "idle_weight_max":8.0, + "prefer_expiring":false, + "expiring_soon":"72h" + }, + "session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"} + }`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" { + t.Errorf("upstash=%+v", c.Upstash) + } + if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 { + t.Errorf("pool=%+v", c.Pool) + } + if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 { + t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD) + } + if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 { + t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax) + } + if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour { + t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur) + } + if c.SessionSticky.Enabled { + t.Error("session_sticky.enabled want false from file") + } + if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 { + t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval) + } +} + +func TestSoftRateMaxParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.SoftRateDur.Minutes() != 5 { + t.Errorf("soft_rate=%v want 5m", c.SoftRateDur) + } + if c.SoftRateMaxDur.Minutes() != 45 { + t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur) + } +} + +func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if !c.Pool.PreferExpiring { + t.Fatal("missing prefer_expiring must preserve default true") + } +} + +func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" { + t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon) + } +} + +func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) { + // 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.SoftRateMaxDur.Hours() != 2 { + t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur) + } +} + +func TestBadSoftRateMax(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad soft_rate_max") + } +} + +func TestBadBreakerCooldown(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad breaker_cooldown") + } +} + +func TestUpstreamTimeoutDefaults(t *testing.T) { + // 默认:header 回落 timeout,idle 回落 300。 + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Upstream.TimeoutSeconds != 120 { + t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds) + } + if c.Upstream.HeaderTimeoutSeconds != 120 { + t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 300 { + t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds) + } +} + +func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) { + // 只设 timeout_seconds:header 回落同值,idle 回落 300。 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstream.HeaderTimeoutSeconds != 60 { + t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 300 { + t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds) + } +} + +func TestUpstreamExplicitHeaderIdle(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstream.HeaderTimeoutSeconds != 30 { + t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 600 { + t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds) + } +} + +func TestUpstreamEnvOverride(t *testing.T) { + t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45") + t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900") + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Upstream.HeaderTimeoutSeconds != 45 { + t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 900 { + t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds) + } +} + +// TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。 +func TestRetiredTravelIntervalKeyIgnored(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatalf("retired key should not fail load: %v", err) + } + if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 { + t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours) + } +} + +// TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true: +// 老 config 不写这些键,行为必须与从前完全一致。 +func TestScheduleEnabledByDefault(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("enabled defaults want true/true, got %v/%v", + c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled) + } + if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { + t.Errorf("travel/activity enabled defaults want true/true, got %v/%v", + c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled) + } + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours) + } +} + +// TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态, +// 新开关缺省 true、新 hours 回落默认——对老配置零影响。 +func TestScheduleLegacyConfigKeepsRunning(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("legacy config must stay enabled: %+v", c.Schedule) + } + if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { + t.Errorf("new switches must default true on legacy config: %+v", c.Schedule) + } + if len(c.Schedule.CheckinHours) != 2 { + t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours) + } + // 新 hours 缺省 → 回落默认(非空)。 + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours) + } +} + +// TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到 +// (issue #27 边界:此前无论怎么配小时都关不掉)。 +func TestScheduleExplicitDisable(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled { + t.Errorf("want both disabled: %+v", c.Schedule) + } + // 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。 + if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 { + t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours) + } + if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 { + t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours) + } +} + +// TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。 +func TestScheduleTravelActivityExplicitDisable(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled { + t.Errorf("want travel/activity disabled: %+v", c.Schedule) + } + // 签到/保活开关缺省 true(互不干扰)。 + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule) + } + // hours 仍回落默认。 + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours) + } +} + +// TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。 +func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) { + cases := []struct{ body, wantSwitch string }{ + {`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"}, + {`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"}, + {`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"}, + {`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"}, + } + for _, tc := range cases { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(tc.body), 0o600) + _, err := Load(fp) + if err == nil { + t.Fatalf("want error for %s", tc.body) + } + if !strings.Contains(err.Error(), tc.wantSwitch) { + t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err) + } + } +} + +// TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。 +func TestScheduleTravelActivityExplicitHours(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 { + t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours) + } +} + +// TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。 +func TestScheduleDisableKeepsExplicitHours(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Schedule.CheckinEnabled { + t.Error("checkin should be disabled") + } + if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 { + t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours) + } +} + +// TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。 +func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) { + cases := map[string]string{ + "absent": `{}`, + "empty": `{"schedule":{}}`, + "null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`, + "emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(body), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 { + t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours) + } + if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 { + t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours) + } + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours) + } + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("empty hours must not imply disabled: %+v", c.Schedule) + } + if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { + t.Errorf("empty hours must not imply disabled: %+v", c.Schedule) + } + }) + } +} + +// TestScheduleInvalidHourRejected 非法小时快速失败:指向正确的禁用开关,避免用户 +// 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。 +func TestScheduleInvalidHourRejected(t *testing.T) { + cases := []struct{ body, wantSwitch string }{ + {`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"}, + {`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"}, + {`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"}, + } + for _, tc := range cases { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(tc.body), 0o600) + _, err := Load(fp) + if err == nil { + t.Fatalf("want error for %s", tc.body) + } + if !strings.Contains(err.Error(), tc.wantSwitch) { + t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err) + } + } +} + +func TestBadSessionTTL(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad session_sticky.ttl") + } +} + +func TestWriteDefault(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建 + key, err := WriteDefault(fp) + if err != nil { + t.Fatal(err) + } + // key 形如 sk-<24字符随机串>,两次生成不重复 + if !strings.HasPrefix(key, "sk-") || len(key) < 20 { + t.Errorf("key=%q want sk-", key) + } + if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key { + t.Errorf("two generated keys identical: %q", key) + } + // 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效 + c, err := Load(fp) + if err != nil { + t.Fatalf("load generated config: %v", err) + } + if c.APIKey != key { + t.Errorf("api_key=%q want %q", c.APIKey, key) + } + if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" { + t.Errorf("generated defaults off: %+v", c) + } + if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled { + t.Errorf("generated schedule off: %+v", c.Schedule) + } + // 已存在的文件不覆盖:二次写入同一路径必须报错 + if _, err := WriteDefault(fp); err == nil { + t.Error("WriteDefault must refuse to overwrite existing file") + } +} + +func TestBalanceRefreshDefaults(t *testing.T) { + // 缺省:启用 + 30 分钟 + c := Default() + if err := c.normalize(); err != nil { + t.Fatal(err) + } + if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute { + t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval) + } + // 显式配置 10 分钟 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600) + c2, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c2.BalanceRefreshInterval != 10*time.Minute { + t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval) + } + // 显式关闭:interval 归零(不启动) + os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600) + c3, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c3.BalanceRefreshInterval != 0 { + t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval) + } + // 启用但 minutes<=0 → 回落默认 30 + os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600) + c4, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c4.BalanceRefreshInterval != 5*time.Minute { + t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval) + } +} + +// TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端 +// 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。 +func TestPromptDefaultPassthrough(t *testing.T) { + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode) + } + // passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。 +} + +// TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。 +func TestPromptExplicitPassthrough(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("mode=%q want passthrough", c.Prompt.Mode) + } + if c.PromptText != "" { + t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText)) + } +} + +// TestPromptInvalidMode 非法 mode 启动报错。 +func TestPromptInvalidMode(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for invalid prompt.mode") + } +} + +// TestPromptFileMissing 文件路径非空但不存在 → 启动报错(fail fast)。 +func TestPromptFileMissing(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for missing prompt file") + } +} + +// TestPromptFileOverride 自定义 file 覆盖内置默认。 +func TestPromptFileOverride(t *testing.T) { + dir := t.TempDir() + pf := filepath.Join(dir, "my.md") + want := "我的自定义人格入口" + os.WriteFile(pf, []byte(want), 0o600) + cf := filepath.Join(dir, "c.json") + // 用 json.Marshal 拼路径:Windows 反斜杠必须转义,手工字符串拼接会产出非法 JSON。 + cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}}) + if err != nil { + t.Fatal(err) + } + os.WriteFile(cf, cfgJSON, 0o600) + c, err := Load(cf) + if err != nil { + t.Fatal(err) + } + if c.PromptText != want { + t.Errorf("PromptText=%q want %q", c.PromptText, want) + } +} + +// TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。 +func TestPromptEnvOverride(t *testing.T) { + t.Setenv("WB2A_PROMPT_MODE", "passthrough") + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("mode=%q want passthrough", c.Prompt.Mode) + } +} + +// TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。 +func TestPromptLegacyConfigNoImpact(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode) + } + if c.Listen != ":9999" { + t.Errorf("listen=%q", c.Listen) + } +} + +// TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效, +// 缺省空串保持现状(headers 层回落到 clientUA)。 +func TestUpstreamUserAgentConfig(t *testing.T) { + // JSON 配置 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstream.UserAgent != "WorkBuddy/1.2.3" { + t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent) + } + // 缺省为空 + if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" { + t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err) + } + // env 覆盖 + t.Setenv("WB2A_USER_AGENT", "EnvAgent/9") + c3, err := Load("") + if err != nil { + t.Fatal(err) + } + if c3.Upstream.UserAgent != "EnvAgent/9" { + t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent) + } +} + +// TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。 +// 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。 +// 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。 +func TestLoadConfigPathIsDirectory(t *testing.T) { + dir := t.TempDir() + asDir := filepath.Join(dir, "config.json") + if err := os.Mkdir(asDir, 0o755); err != nil { + t.Fatal(err) + } + _, err := Load(asDir) + if err == nil { + t.Fatal("want error when config path is a directory") + } + msg := err.Error() + if !strings.Contains(msg, "是目录") { + t.Errorf("error should explain it is a directory: %v", err) + } + if !strings.Contains(msg, "config.example.json") { + t.Errorf("error should suggest the fix (cp config.example.json): %v", err) + } +} diff --git a/cmd/server/credit_floor_config_test.go b/cmd/server/credit_floor_config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b6ad53d64e66b9a42d91a326ffaf2d96c72e80fd --- /dev/null +++ b/cmd/server/credit_floor_config_test.go @@ -0,0 +1,48 @@ +// credit_floor_config_test.go pool.credit_floor 配置测试: +// 默认 0(关闭,零回归)/ 文件覆盖 / 负值钳 0 / 大值合法。 +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCreditFloorDefault 键缺席 → 默认 0(保底关闭,行为与引入前一致)。 +func TestCreditFloorDefault(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Pool.CreditFloor != 0 { + t.Errorf("credit_floor=%d want 0 (default off)", c.Pool.CreditFloor) + } +} + +// TestCreditFloorParsedFromFile 显式配置覆盖默认。 +func TestCreditFloorParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"credit_floor":100}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Pool.CreditFloor != 100 { + t.Errorf("credit_floor=%d want 100", c.Pool.CreditFloor) + } +} + +// TestCreditFloorNegativeClamped 负值钳 0(非法即关闭,不报错:老配置误写不炸启动)。 +func TestCreditFloorNegativeClamped(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"credit_floor":-5}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Pool.CreditFloor != 0 { + t.Errorf("credit_floor=%d want 0 (negative clamped)", c.Pool.CreditFloor) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000000000000000000000000000000000000..724775443ad822d3dd98653a4754f1ae42b49e58 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,550 @@ +// main.go workbuddy2api 入口:加载配置、构建 pool、起调度器与 HTTP 服务。 +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" + "github.com/linguo2625469/workbuddy2api-panel/internal/panel" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/redisstore" + "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" + "github.com/linguo2625469/workbuddy2api-panel/internal/server" + "github.com/linguo2625469/workbuddy2api-panel/internal/session" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" + "github.com/linguo2625469/workbuddy2api-panel/internal/usage" +) + +// appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。 +const appVersion = "1.11.11-panel" + +// usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。 +// 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。 +func usagePathFor(stateFile string) string { return stateSibling(stateFile, "usage.json") } + +// stateSibling 返回与 state 文件同目录的指定文件名路径(相对路径场景回落当前目录)。 +// usage.json(用量记录)与 output_probes.json(模型上限探测)共用本规则。 +func stateSibling(stateFile, name string) string { + dir := filepath.Dir(stateFile) + if dir == "" || dir == "." { + return name + } + return filepath.Join(dir, name) +} + +func main() { + cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)") + flag.Parse() + + cfg, err := Load(*cfgPath) + if err != nil { + // errors.Is 才能看穿 Load 里 fmt.Errorf("%w") 的包装;os.IsNotExist 不行。 + if errors.Is(err, fs.ErrNotExist) { + // 首次运行:目录下没有配置 → 自动落一份推荐配置(含随机 api_key)再加载。 + // 双击 exe / 裸跑 docker 即开,无需先手工复制样例。 + if key, werr := WriteDefault(*cfgPath); werr == nil { + log.Printf("config %s 不存在,已生成推荐配置(api_key=%s,记录在该文件里,可自行修改)", *cfgPath, key) + cfg, err = Load(*cfgPath) + } + if err != nil { + // 生成失败(目录只读等):退回纯默认 + env(旧行为兜底),不阻塞启动。 + log.Printf("config %s not found (auto-generate failed), using defaults+env: %v", *cfgPath, err) + cfg, err = Load("") + } + } + if err != nil { + log.Fatalf("load config: %v", err) + } + } + + auths, err := auth.LoadDir(cfg.AuthDir) + if err != nil { + log.Fatalf("load auths: %v", err) + } + log.Printf("loaded %d account(s) from %s", len(auths), cfg.AuthDir) + + // redisstore:未配置/连接失败 → Noop(纯内存模式,一切功能照常)。 + store := redisstore.New(cfg.Upstash.URL, cfg.Upstash.Token) + + p := pool.New(cfg.StateFile) + // 停机序:先 pool.Close()(最后一次 Flush → SaveState 已提交到 store), + // 再 store.Close() 排空在途异步写(最后一笔 Redis 镜像必须写完才关连接)。 + defer func() { + p.Close() + _ = store.Close() + }() + p.SetStore(store) + p.RestoreFromSnapshot() // 择新恢复:Redis 快照比本地新才采用,否则本地优先 + p.SyncToDir(auths) // 与 auths 目录对齐:新账号加入、已删除文件账号剔除(状态保留) + + // 熔断器 + 在途上限(含 global 分档)+ 连败降权 + 闲置补偿调优(从 config 注入, + // 非正值回退默认)。 + p.SetBreaker(cfg.Pool.BreakerThreshold, cfg.BreakerCooldownDur, cfg.BreakerCooldownMaxD) + p.SetMaxInFlight(cfg.Pool.MaxInFlight) + p.SetMaxInFlightGlobal(cfg.Pool.MaxInFlightGlobal) // global 域 WAF 风控分档(P1-1) + p.SetDegrade(cfg.Pool.DegradeThreshold, cfg.DegradeCooldownDur, cfg.DegradeCooldownMaxD) + p.SetSoftRateMax(cfg.SoftRateMaxDur) // 软冷却指数退避封顶(soft_rate_max,默认 2h) + p.SetCostExploreInterval(cfg.CostExploreIntervalDur) // costTier 探索窗口(issue #136,默认 30m;0 关停) + p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭) + p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax) + p.SetPreferExpiring(cfg.Pool.PreferExpiring) + + // 会话粘性路由(可配关闭)。 + var sessRouter *session.Router + redisMode := "noop" + if _, ok := store.(redisstore.Noop); !ok { + redisMode = "upstash" + } + if cfg.SessionSticky.Enabled { + sessRouter = session.New(session.Config{ + TTL: cfg.SessionTTL, + GCInterval: cfg.SessionGCInterval, + Store: store, + Available: p.AvailableUIDs, + // realm 感知闭包:带前缀模型名按 realm 过滤可用账号(跨 realm 不泄漏); + // 裸名走 cn(现状零回归)。闭包内部 resolveModel 剥前缀,再按 realm 过滤。 + AvailableForModel: realmAwareAvailableForModel(p), + }) + sessRouter.LoadFromStore() // 启动时从 Redis 恢复粘性(读操作仅此处) + sessRouter.StartGC() + defer sessRouter.StopGC() + } + sessCount := func() int { + if sessRouter != nil { + return sessRouter.Count() + } + return 0 + } + + up := upstream.New() + + // 积分保底的「收费」兜底判据:接上游模型目录的积分倍率表。本地实测台账无观测 + // 时用它判收费——否则「没学过」恒等于「放行」,高价新模型会把触底号一笔打穿 + // (kimi-k3-1 实案:全池无观测 → 保底全放行 → 两笔打穿并硬冷却到次日 04:00)。 + // 位于 up 装配之后:倍率表由探测下发,闭包每次调用读实时快照。 + p.SetModelRateOf(func(realm, model string) string { return up.ModelRate(realm, model) }) + + // 短 RPC 总时长上限(refresh/checkin/balance/FetchModels),语义不变。 + up.HTTP.Timeout = time.Duration(cfg.Upstream.TimeoutSeconds) * time.Second + // 聊天 SSE 首字节前(响应头)上限:cfg 已 normalize(缺省回落 timeout_seconds)。 + up.HeaderTimeout = time.Duration(cfg.Upstream.HeaderTimeoutSeconds) * time.Second + if tr, ok := up.ChatHTTP.Transport.(*http.Transport); ok { + tr.ResponseHeaderTimeout = up.HeaderTimeout + } + // 聊天 SSE 流中空闲上限(S3 空闲监控读取)。 + up.IdleTimeout = time.Duration(cfg.Upstream.IdleTimeoutSeconds) * time.Second + up.SanitizeFingerprints.Store(cfg.Features.SanitizeBlacklistFingerprints) + // 出站 UA 与归属头(issue #42 + 上游同步): + // UserAgent 非空则完全覆盖;ClientVersion/CliVersion 缺省对齐官方形态; + // ClientName 非空时 chat 路径注入 X-IDE-* 四头(用量归因对齐官方桌面端)。 + up.UserAgent = cfg.Upstream.UserAgent + up.ClientVersion = cfg.Upstream.ClientVersion + up.CliVersion = cfg.Upstream.CliVersion + up.ClientName = cfg.Upstream.ClientName + up.DeviceToken = cfg.Upstream.DeviceToken + up.DeviceTokenFile = cfg.Upstream.DeviceTokenFile + up.PassthroughIP = cfg.Upstream.PassthroughIP + // global realm 路由(config global 段):上游侧开关(第一道闸)+ base 覆盖; + // auth 侧开关(auth.SetGlobalEnabled)是第二道闸,两者同 config global.enabled。 + up.GlobalEnabled = cfg.Global.Enabled + up.ChatBaseGlobal = cfg.Global.ChatBase + up.BillingBaseGlobal = cfg.Global.BillingBase + auth.SetGlobalEnabled(cfg.Global.Enabled) + // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): + // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 + // 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。 + upstream.SetModelCatalogPath(stateSibling(cfg.StateFile, "model.json")) + + sch := scheduler.New(scheduler.Config{ + Pool: p, + Upstream: up, + CheckinHours: cfg.Schedule.CheckinHours, + TravelHours: cfg.Schedule.TravelHours, + ActivityHours: cfg.Schedule.ActivityHours, + KeepaliveHours: cfg.Schedule.KeepaliveHours, + BlackcatHours: cfg.Schedule.BlackcatHours, + GrowthHours: cfg.Schedule.GrowthHours, + // 快过期积分优先消耗:签到/余额刷新按此窗口分桶(issue:积分过期)。 + ExpiringSoonWindow: cfg.ExpiringSoonDur, + CheckinDisabled: !cfg.Schedule.CheckinEnabled, + TravelDisabled: !cfg.Schedule.TravelEnabled, + ActivityDisabled: !cfg.Schedule.ActivityEnabled, + KeepaliveDisabled: !cfg.Schedule.KeepaliveEnabled, + BlackcatDisabled: !cfg.Schedule.BlackcatEnabled, + GrowthDisabled: !cfg.Schedule.GrowthEnabled, + }) + switch { + case !cfg.Schedule.CheckinEnabled: + log.Printf("签到已禁用(schedule.checkin_enabled=false)") + default: + log.Printf("签到已启用:%v 点(签到 + 余额查询解冻)", cfg.Schedule.CheckinHours) + } + switch { + case !cfg.Schedule.TravelEnabled: + log.Printf("猫猫旅行已禁用(schedule.travel_enabled=false)") + default: + log.Printf("猫猫旅行已启用:%v 点(独立排程:领养 / 派出 / 领奖)", cfg.Schedule.TravelHours) + } + switch { + case !cfg.Schedule.ActivityEnabled: + log.Printf("活跃上报已禁用(schedule.activity_enabled=false)") + default: + log.Printf("活跃上报已启用:%v 点(每日 1 次,点亮连登 + 解锁 first_buddy)", cfg.Schedule.ActivityHours) + } + if !cfg.Schedule.KeepaliveEnabled { + log.Printf("token 保活已禁用(schedule.keepalive_enabled=false)") + } else { + log.Printf("token 保活已启用:%v 点", cfg.Schedule.KeepaliveHours) + } + switch { + case !cfg.Schedule.BlackcatEnabled: + log.Printf("夜猫子已禁用(schedule.blackcat_enabled=false)") + default: + log.Printf("夜猫子已启用:%v 点(23:00–08:00 窗口 glm-5.2 对话补足)", cfg.Schedule.BlackcatHours) + } + switch { + case !cfg.Schedule.BalanceRefreshEnabled: + log.Printf("余额后台刷新已禁用(schedule.balance_refresh_enabled=false)") + case cfg.BalanceRefreshInterval > 0: + log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval) + } + + // 管理面板日志镜像:标准 log(stderr)与 chat 表格日志(stdout)双路复制进 + // 面板环形缓冲,供 /panel/api/logs 读取;控制台输出行为完全不变。 + // live 承载可热改字段(api_key/soft_rate/脱敏开关),面板保存配置时在线替换。 + live := livecfg.New(livecfg.Snapshot{ + APIKey: cfg.APIKey, + SoftCooldown: cfg.SoftRateDur, + SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints, + RecordClientInfo: cfg.Logging.RequestClientInfo, + }) + // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。 + // datapath 由 state 文件路径推出,避免再加一个配置项。 + usagePath := usagePathFor(cfg.StateFile) + rec := usage.New(usagePath) + rec.Start() + defer rec.Stop() + log.Printf("[usage] 逐请求用量记录已启用: %s (%s)", usagePath, rec.Describe()) + + // 请求指标始终启用;JSONL 归档只写脱敏元数据,写盘失败不影响聊天请求。 + requestLog := reqlog.New(reqlog.Config{ + Dir: stateSibling(cfg.StateFile, "request-logs"), + Enabled: cfg.Logging.RequestArchiveEnabled, + RetentionDays: cfg.Logging.RequestRetentionDays, + MaxBytes: int64(cfg.Logging.RequestArchiveMaxMB) << 20, + }) + defer requestLog.Close() + rs := requestLog.Snapshot().Archive + if rs.Enabled { + log.Printf("[reqlog] 请求指标已启用;JSONL 归档 %s(保留 %d 天,上限 %d MiB)", + rs.Dir, cfg.Logging.RequestRetentionDays, cfg.Logging.RequestArchiveMaxMB) + } else { + log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭") + } + + pn := panel.New(panel.Config{ + Pool: p, + Usage: rec, + RequestLog: requestLog, + Upstream: up, + Scheduler: sch, + AuthDir: cfg.AuthDir, + APIKey: cfg.APIKey, + RedisMode: redisMode, + StickyCount: sessCount, + Version: appVersion, + Live: live, + // 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入): + // 与 state 文件同目录,缺省 data/output_probes.json。 + ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"), + ConfigPath: *cfgPath, + LoadConfig: func() (any, error) { + return Load(*cfgPath) + }, + SaveConfig: func(raw []byte) ([]string, error) { + return saveConfig(raw, *cfgPath, live, p, up, sch) + }, + }) + // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后 + // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。 + sch.SetGrowthHook(pn.RunGrowthQueueOnce) + log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs())) + server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs())) + + h := server.NewHandler(server.Config{ + Pool: p, + Upstream: up, + APIKey: cfg.APIKey, + Session: sessRouter, + StickyCount: sessCount, + RedisMode: redisMode, + SoftCooldown: cfg.SoftRateDur, + Panel: pn, + Live: live, + Usage: rec, + RequestLog: requestLog, + PromptMode: cfg.Prompt.Mode, + PromptText: cfg.PromptText, + // 来源记录开关经 livecfg 热生效;此处同时填静态字段,供 Live 为 nil 的 + // 裸用/测试路径拿到同一缺省值。 + RecordClientInfo: cfg.Logging.RequestClientInfo, + // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。 + GlobalEnabled: cfg.Global.Enabled, + }) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go sch.Run(ctx) + sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval) + + // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时 + // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前, + // ModelRate 恒返回空串——积分保底的目录兜底在这段空窗期内形同虚设,触底号 + // 会被当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费 + // 模型归零;倍率表当时尚未建立)。 + // 异步执行:不阻塞监听启动;失败仅记日志(下一轮懒触发或本轮重试仍可补上)。 + go warmModelRates(ctx, up, p) + + srv := &http.Server{ + Addr: cfg.Listen, + Handler: h, + ReadHeaderTimeout: 30 * time.Second, + // ReadTimeout 覆盖整个请求读取(含 body):防慢速 body 拖死连接。 + // 请求体已无网关侧上限(max_body_mb 移除),60s 按常规带宽的数十 MB + // 上传余量取值;超大 body 慢速上传若超时,由客户端重试。 + ReadTimeout: 60 * time.Second, + // IdleTimeout keep-alive 空闲连接回收:配合 chat 出站 ctx 传播防连接泄漏堆积。 + // 注意:SSE 流式响应期间连接非空闲,不受此项掐断;不设全局 WriteTimeout + // (长流式生成合法时长可达数分钟,全局 WriteTimeout 会误杀在途 SSE)。 + IdleTimeout: 120 * time.Second, + } + go func() { + <-ctx.Done() + p.Flush() // 信号触发:先落盘再做优雅停机 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + log.Printf("workbuddy2api listening on %s (api_key=%v),管理面板 http://127.0.0.1%s/panel/", cfg.Listen, cfg.APIKey != "", panelListenPath(cfg.Listen)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http: %v", err) + } + log.Printf("bye") +} + +// warmModelRates 启动预热各域模型积分倍率表(供积分保底的目录兜底判定)。 +// +// 为什么需要:倍率表只在 FetchModels(CN)/ FetchGlobalModelInfos(global)成功时 +// 填充,两者都是懒触发(被 /v1/models 或面板模型页访问才跑)。重启后到首次触发 +// 之间的空窗期里 ModelRate 恒返回空串,保底的目录兜底判不出收费,触底号会被 +// 当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费模型归零)。 +// +// 失败处理:单域失败只记 WARN(不阻塞、不致命——后续懒触发仍会补上);global 域 +// 仅在其路由开关开启时预热(逃生门关锁时按 CN 处理,无需探测)。 +func warmModelRates(ctx context.Context, up *upstream.Client, p *pool.Pool) { + // 预热不得拖住进程退出:ctx 取消(SIGINT/SIGTERM)时立刻放弃剩余域。 + if ctx.Err() != nil { + return + } + // CN:有可用 CN 账号才拉(与面板 models 同口径,避免无谓上游调用)。 + if uids := p.AvailableUIDsForRealm("cn"); len(uids) > 0 { + if a := p.AuthByUID(uids[0]); a != nil { + if _, err := up.FetchModels(a); err != nil { + log.Printf("WARN: [upstream] warm model rates (cn): %v", err) + } else { + log.Printf("[upstream] warm model rates: cn ok") + } + } + } + // global:独立目录端点(workbuddy.ai),倍率按 "global" 域键存储。 + if up.GlobalEnabled && ctx.Err() == nil { + if uids := p.AvailableUIDsForRealm("global"); len(uids) > 0 { + if a := p.AuthByUID(uids[0]); a != nil { + // FetchGlobalModelInfos 无错误返回(内部负缓存自行节流), + // 仅按结果条数判断是否拿到目录。 + if infos := up.FetchGlobalModelInfos(a); len(infos) == 0 { + log.Printf("WARN: [upstream] warm model rates (global): empty model list") + } else { + log.Printf("[upstream] warm model rates: global ok (%d models)", len(infos)) + } + } + } + } +} + +// panelListenPath 从 listen 地址提取 ":port" 形式,用于启动日志拼面板 URL +// (":7863" 或 "0.0.0.0:7863" → ":7863";异常输入原样返回)。 +func panelListenPath(listen string) string { + for i := len(listen) - 1; i >= 0; i-- { + if listen[i] == ':' { + return listen[i:] + } + } + return listen +} + +// saveConfig 面板保存配置:校验 → 落盘 → 热应用 → 返回需重启的字段列表。 +// +// 热生效范围(设计取舍): +// - api_key / cooldown.soft_rate / features.sanitize_blacklist_fingerprints → livecfg 快照 +// - pool.* → pool.SetBreaker/SetMaxInFlight/SetSoftRateMax/SetWeights/SetCostExploreInterval/SetPreferExpiring/SetCreditFloor +// - schedule.* → scheduler.Reconfigure/SetBalanceInterval/SetExpiringSoonWindow +// +// 需重启(涉及监听地址、HTTP client 超时、auth_dir 等装配期依赖): +// - listen / auth_dir / state_file / upstream.* / upstash.* / session_sticky.*(TTL 类) +// +// 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改 +// 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体 +// 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。 +func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) { + // 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。 + oldRaw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read current config: %w", err) + } + var cur, incoming map[string]any + if err := json.Unmarshal(oldRaw, &cur); err != nil { + cur = map[string]any{} + } + if err := json.Unmarshal(raw, &incoming); err != nil { + return nil, fmt.Errorf("parse submitted config: %w", err) + } + merged := mergeConfigMaps(cur, incoming) + + // 2) 校验(与启动同一套 Default+normalize),失败直接返回、不落盘。 + newCfg, err := ParseConfig(mergedJSON(merged)) + if err != nil { + return nil, err + } + + // 3) 落盘(原子替换)。 + out, err := json.MarshalIndent(merged, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, out, 0o600); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + // A single-file Docker bind mount cannot be renamed over its mount + // target (Linux returns EBUSY / "device or resource busy"). Keep the + // atomic path for regular files, but update the mounted file in place + // for this specific deployment shape. + if !errors.Is(err, syscall.EBUSY) { + return nil, fmt.Errorf("replace config: %w", err) + } + f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) + if openErr != nil { + _ = os.Remove(tmp) + return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr) + } + _, writeErr := f.Write(out) + if writeErr == nil { + writeErr = f.Sync() + } + closeErr := f.Close() + // 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容, + // 可手工恢复);写成功才清理。 + if writeErr != nil { + return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr) + } + _ = os.Remove(tmp) + if closeErr != nil { + return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr) + } + } + + // 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。 + live.Store(livecfg.Snapshot{ + APIKey: newCfg.APIKey, + SoftCooldown: newCfg.SoftRateDur, + SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints, + RecordClientInfo: newCfg.Logging.RequestClientInfo, + }) + up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints) + p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD) + p.SetMaxInFlight(newCfg.Pool.MaxInFlight) + p.SetMaxInFlightGlobal(newCfg.Pool.MaxInFlightGlobal) + p.SetDegrade(newCfg.Pool.DegradeThreshold, newCfg.DegradeCooldownDur, newCfg.DegradeCooldownMaxD) + p.SetSoftRateMax(newCfg.SoftRateMaxDur) + p.SetCostExploreInterval(newCfg.CostExploreIntervalDur) // costTier 探索窗口热生效(0 关停) + p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭) + p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax) + p.SetPreferExpiring(newCfg.Pool.PreferExpiring) + sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) + sch.Reconfigure( + newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours, + newCfg.Schedule.ActivityHours, newCfg.Schedule.KeepaliveHours, newCfg.Schedule.BlackcatHours, + newCfg.Schedule.GrowthHours, + !newCfg.Schedule.CheckinEnabled, !newCfg.Schedule.TravelEnabled, + !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled, + !newCfg.Schedule.GrowthEnabled) + sch.SetBalanceInterval(newCfg.BalanceRefreshInterval) + + return restartRequiredFields(newCfg), nil +} + +// restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。 +// 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。 +func restartRequiredFields(c *Config) []string { + var out []string + // 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。 + if c.Listen != "" { + out = append(out, "listen") + } + if c.AuthDir != "" { + out = append(out, "auth_dir") + } + if c.StateFile != "" { + out = append(out, "state_file") + } + out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds") + if c.Upstash.URL != "" || c.Upstash.Token != "" { + out = append(out, "upstash") + } + out = append(out, "session_sticky.ttl", "session_sticky.gc_interval") + out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb") + return out +} + +// mergeConfigMaps 把 incoming 深合并进 cur(原地),返回 cur。 +// 对嵌套对象逐键覆盖而不是整体替换:面板表单只提交它管理的键, +// 未提交的兄弟键(含用户手写的未知键)保持原样。 +func mergeConfigMaps(cur, incoming map[string]any) map[string]any { + for k, v := range incoming { + if inMap, ok := v.(map[string]any); ok { + if curMap, ok := cur[k].(map[string]any); ok { + cur[k] = mergeConfigMaps(curMap, inMap) + continue + } + } + cur[k] = v + } + return cur +} + +// mergedJSON 把合并后的 map 序列化回 JSON(供 ParseConfig 校验)。 +func mergedJSON(m map[string]any) []byte { + b, err := json.Marshal(m) + if err != nil { + return []byte("{}") + } + return b +} diff --git a/cmd/server/wiring.go b/cmd/server/wiring.go new file mode 100644 index 0000000000000000000000000000000000000000..a10476870ab365f9b905947231354c344a100a8f --- /dev/null +++ b/cmd/server/wiring.go @@ -0,0 +1,24 @@ +package main + +import ( + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/server" +) + +// realmAwareAvailableForModel 构造会话粘性路由按模型可用口径的 realm 感知闭包。 +// +// 粘性分配的模型名可能带 realm 前缀("global:gpt-5.4" / "cn:glm-5.2"):必须按前缀剥出 +// realm + bareModel,再交给分池选号域过滤——否则裸名取池子全集,global 号会被粘性分配给 +// CN 前缀请求(跨 realm 泄漏)。裸名/显式 cn → cn 集合;global: → global 集合。 +// +// realm 为空串时 pool.WeightedAvailableUIDsForModelRealm 退化为现状 +// (AvailableUIDsForModel),老调用(无前缀模型名)语义零改动。 +// +// 返回列表可能对快过期账号重复同一 UID,作为虚拟实例权重;会话哈希分配无需感知 +// 权重细节,已有绑定的快路径仍直接返回原账号,不做迁移。 +func realmAwareAvailableForModel(p *pool.Pool) func(model string) []string { + return func(model string) []string { + realm, bare := server.ResolveModel(model) + return p.WeightedAvailableUIDsForModelRealm(bare, realm) + } +} diff --git a/cmd/signin/main.go b/cmd/signin/main.go new file mode 100644 index 0000000000000000000000000000000000000000..71e3731dc01a1dd18fccf0d9f2f26ffa1115cd9e --- /dev/null +++ b/cmd/signin/main.go @@ -0,0 +1,140 @@ +// signin 一次性批量签到工具:遍历 ./auths/workbuddy-*.json 全部账号, +// 自动 RefreshToken(过期时),逐个调 daily-checkin,顺手查余额。 +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +type row struct { + file string + uid string + nick string + status string // OK | ALREADY | FAIL | AUTH_INVALID | LOAD_ERR + detail string + remain int64 + hasQuota bool +} + +func main() { + dir := "auths" + if len(os.Args) > 1 { + dir = os.Args[1] + } + files, err := filepath.Glob(filepath.Join(dir, "workbuddy-*.json")) + if err != nil || len(files) == 0 { + fmt.Fprintf(os.Stderr, "no auth files in %s\n", dir) + os.Exit(1) + } + sort.Strings(files) + up := upstream.New() + + var rows []row + okN, alreadyN, failN := 0, 0, 0 + for _, f := range files { + r := row{file: filepath.Base(f)} + raw, err := os.ReadFile(f) + if err != nil { + r.status, r.detail = "LOAD_ERR", err.Error() + rows = append(rows, r) + failN++ + continue + } + a, err := auth.Parse(raw) + if err != nil { + r.status, r.detail = "LOAD_ERR", err.Error() + rows = append(rows, r) + failN++ + continue + } + a.FilePath = f + r.uid, r.nick = a.UID, a.Nickname + + // refresh 过期 token + if a.NeedsRefresh(2 * 3600) { + if err := up.RefreshToken(a); err != nil { + if ue, ok := err.(*upstream.Error); ok && ue.Kind == upstream.ErrSessionDead { + r.status = "AUTH_INVALID" + } else { + r.status = "FAIL" + } + r.detail = "refresh: " + short(err.Error()) + rows = append(rows, r) + failN++ + continue + } + // refresh 后写回文件(权限问题已修复);落盘失败必须暴露,否则重启回旧 token + if err := a.SaveAtomic(); err != nil { + log.Printf("signin %s save: %v", a.UID, err) + } + } + + err = up.DailyCheckin(a) + switch { + case err == nil: + r.status = "OK" + okN++ + default: + // DailyCheckin 已签到返回 code!=0 错误 + if isAlready(err.Error()) { + r.status = "ALREADY" + r.detail = short(err.Error()) + alreadyN++ + } else { + r.status = "FAIL" + r.detail = short(err.Error()) + failN++ + } + } + // 顺手查余额 + if remain, _, qerr := up.UserResource(a); qerr == nil { + r.remain, r.hasQuota = remain, true + } + rows = append(rows, r) + } + + // 报告 + fmt.Printf("uid | nick | status | remain | detail\n") + fmt.Printf("-------------------------------------+-------------+--------------+--------+------------------------------\n") + for _, r := range rows { + remain := "-" + if r.hasQuota { + remain = fmt.Sprintf("%d", r.remain) + } + fmt.Printf("%-36s | %-11s | %-12s | %-6s | %s\n", + trunc(r.uid, 36), trunc(r.nick, 11), r.status, remain, r.detail) + } + fmt.Printf("\ntotal=%d ok=%d already=%d fail=%d\n", len(rows), okN, alreadyN, failN) +} + +// 已签判定:code 非 0 且含 "已签到"/"already"/"checkin" 等字样 +func isAlready(msg string) bool { + s := strings.ToLower(msg) + return strings.Contains(s, "已签到") || + strings.Contains(s, "already") || + strings.Contains(s, "checkin") || + strings.Contains(s, "code=400") +} + +func trunc(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +func short(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > 60 { + return s[:60] + } + return s +} diff --git a/cmd/trial/main.go b/cmd/trial/main.go new file mode 100644 index 0000000000000000000000000000000000000000..564673b549d63ba08a196c64840e3db4fa7bb8ef --- /dev/null +++ b/cmd/trial/main.go @@ -0,0 +1,133 @@ +// trial 一次性批量领取 global trial 加油包:遍历 auths 下全部账号, +// 仅对 global 账号执行 /billing/ide/trial(CN 无此端点,明确提示不适用)。 +// +// 用法: +// +// # 本地:在项目根目录(需 config.json + auths/ + data/)直接 run +// go run ./cmd/trial +// +// # 容器内:先 cp 进去再 exec +// docker cp trial workbuddy2api:/tmp/trial +// docker exec -w /app workbuddy2api /tmp/trial +// +// 结果逐账号输出到 stdout: +// +// uid | nick | status | detail +// ----+------+--------+------- +// ... | GLOBAL | OK | trial granted +// ... | GLOBAL | ALREADY | 已领取过(幂等,不算失败) +// ... | CN | N/A | not applicable +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// classifyTrial 归一化 ClaimTrial 结果(纯函数,供 main 循环与测试直接断言): +// err → FAIL;claimed → OK;否则(幂等码已领)→ ALREADY。 +func classifyTrial(claimed bool, err error) (trialStatus, string) { + switch { + case err != nil: + return trialFailed, err.Error() + case claimed: + return trialOK, "trial granted" + default: + return trialAlready, "already claimed (idempotent)" + } +} + +// trialStatus 单账号 trial 领取结果状态。 +type trialStatus string + +const ( + trialOK trialStatus = "OK" + trialAlready trialStatus = "ALREADY" + trialNotApp trialStatus = "N/A" // CN 账号不适用 + trialFailed trialStatus = "FAIL" +) + +type trialRow struct { + uid string + nick string + status trialStatus + detail string +} + +func main() { + authDir := "auths" + if len(os.Args) > 1 { + authDir = os.Args[1] + } + files, err := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json")) + if err != nil || len(files) == 0 { + fmt.Fprintf(os.Stderr, "no auth files in %s\n", authDir) + os.Exit(1) + } + sort.Strings(files) + + up := upstream.New() + // trial 是 global 专属端点:必须开启 global realm 路由,否则 upstream.New() 的 + // GlobalEnabled 零值 false 会把请求路由到 CN base(codebuddy.cn)而必然失败。 + up.GlobalEnabled = true + var rows []trialRow + for _, f := range files { + r := trialRow{uid: filepath.Base(f)} + raw, err := os.ReadFile(f) + if err != nil { + r.status, r.detail = trialFailed, "load: "+err.Error() + rows = append(rows, r) + continue + } + a, err := auth.Parse(raw) + if err != nil { + r.status, r.detail = trialFailed, "parse: "+err.Error() + rows = append(rows, r) + continue + } + a.FilePath = f + r.uid, r.nick = a.UID, a.Nickname + + // 仅 global 账号适用:CN 明确提示不适用,不发任何请求。 + if !a.IsGlobal() { + r.status, r.detail = trialNotApp, "CN account not applicable" + rows = append(rows, r) + continue + } + + r.status, r.detail = classifyTrial(up.ClaimTrial(a)) + rows = append(rows, r) + } + + var okN, alreadyN, notAppN, failN int + fmt.Printf("uid | nick | status | detail\n") + fmt.Printf("-------------------------------------+-------------+---------+------------------------------\n") + for _, r := range rows { + fmt.Printf("%-36s | %-11s | %-7s | %s\n", + trunc(r.uid, 36), trunc(r.nick, 11), r.status, r.detail) + switch r.status { + case trialOK: + okN++ + case trialAlready: + alreadyN++ + case trialNotApp: + notAppN++ + default: + failN++ + } + } + fmt.Printf("\ntotal=%d ok=%d already=%d na=%d fail=%d\n", + len(rows), okN, alreadyN, notAppN, failN) +} + +func trunc(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/config.default.json b/config.default.json new file mode 100644 index 0000000000000000000000000000000000000000..87207fc754eecbc59f0cf94657585c3e6baa205e --- /dev/null +++ b/config.default.json @@ -0,0 +1,98 @@ +{ + "listen": ":7861", + "api_key": "", + "auth_dir": "/app/storage/auths", + "state_file": "/app/storage/data/state.json", + "panel": { + "package_detail_limit": 5 + }, + "logging": { + "request_archive_enabled": true, + "request_retention_days": 7, + "request_archive_max_mb": 100, + "request_client_info": true + }, + "cooldown": { + "soft_rate": "600s", + "soft_rate_max": "2h" + }, + "schedule": { + "checkin_hours": [ + 9, + 21 + ], + "travel_hours": [ + 9, + 21 + ], + "activity_hours": [ + 10 + ], + "keepalive_hours": [ + 22 + ], + "blackcat_hours": [ + 23 + ], + "growth_hours": [ + 1 + ], + "checkin_enabled": true, + "travel_enabled": true, + "activity_enabled": true, + "keepalive_enabled": true, + "blackcat_enabled": true, + "growth_enabled": true, + "balance_refresh_enabled": true, + "balance_refresh_minutes": 5 + }, + "global": { + "enabled": true, + "chat_base": "", + "billing_base": "" + }, + "upstream": { + "timeout_seconds": 120, + "header_timeout_seconds": 120, + "idle_timeout_seconds": 300, + "user_agent": "", + "client_version": "", + "cli_version": "", + "client_name": "", + "device_token": "", + "device_token_file": "", + "passthrough_ip": false + }, + "features": { + "sanitize_blacklist_fingerprints": true + }, + "prompt": { + "mode": "passthrough", + "file": "" + }, + "upstash": { + "url": "", + "token": "" + }, + "pool": { + "max_in_flight": 3, + "max_in_flight_global": 2, + "breaker_threshold": 3, + "breaker_cooldown": "30m", + "breaker_cooldown_max": "6h", + "degrade_threshold": 5, + "degrade_cooldown": "10m", + "degrade_cooldown_max": "2h", + "idle_weight_per_hour": 0.5, + "idle_weight_max": 5, + "prefer_expiring": true, + "expiring_soon": "168h", + "cost_explore_interval": "30m", + "credit_floor": 0 + }, + "session_sticky": { + "enabled": true, + "ttl": "30m", + "gc_interval": "5m" + } +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000000000000000000000000000000000000..bbfc4b7138c48380a959ffb7c23b814ba0d76979 --- /dev/null +++ b/config.example.json @@ -0,0 +1,84 @@ +{ + "listen": ":7863", + "api_key": "test_key", + "auth_dir": "./auths", + "state_file": "./data/state.json", + "panel": { + "package_detail_limit": 5 + }, + "logging": { + "request_archive_enabled": true, + "request_retention_days": 7, + "request_archive_max_mb": 100, + "request_client_info": true + }, + "cooldown": { + "soft_rate": "600s", + "soft_rate_max": "2h" + }, + "schedule": { + "checkin_hours": [9, 21], + "growth_hours": [1], + "travel_hours": [9, 21], + "activity_hours": [10], + "keepalive_hours": [22], + "blackcat_hours": [23], + "checkin_enabled": true, + "growth_enabled": true, + "travel_enabled": true, + "activity_enabled": true, + "keepalive_enabled": true, + "blackcat_enabled": true, + "balance_refresh_enabled": true, + "balance_refresh_minutes": 5 + }, + "global": { + "enabled": true, + "chat_base": "", + "billing_base": "" + }, + "upstream": { + "timeout_seconds": 120, + "header_timeout_seconds": 120, + "idle_timeout_seconds": 300, + "user_agent": "", + "client_version": "", + "cli_version": "", + "client_name": "", + "device_token": "", + "device_token_file": "", + "passthrough_ip": false + }, + "features": { + "sanitize_blacklist_fingerprints": true + }, + "prompt": { + "mode": "passthrough", + "file": "" + }, + "upstash": { + "url": "", + "token": "" + }, + "pool": { + "max_in_flight": 3, + "max_in_flight_global": 2, + "breaker_threshold": 3, + "breaker_cooldown": "30m", + "breaker_cooldown_max": "6h", + "degrade_threshold": 5, + "degrade_cooldown": "10m", + "degrade_cooldown_max": "2h", + "idle_weight_per_hour": 0.5, + "idle_weight_max": 5.0, + "prefer_expiring": true, + "expiring_soon": "168h", + "cost_explore_interval": "30m", + "credit_floor": 100 + }, + "session_sticky": { + "enabled": true, + "ttl": "30m", + "gc_interval": "5m" + } +} diff --git a/config.py b/config.py deleted file mode 100644 index 31a089d67c1ec5ab6dc6c2c64ce0e0e229fab44a..0000000000000000000000000000000000000000 --- a/config.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Configuration constants for the Geminicli2api proxy server. -Centralizes all configuration to avoid duplication across modules. - -- 启动时加载一次配置到内存 -- 修改配置时调用 reload_config() 重新从数据库加载 -""" - -import os -from typing import Any, Optional - -# 全局配置缓存 -_config_cache: dict[str, Any] = {} -_config_initialized = False - -# Client Configuration - -# 需要自动封禁的错误码 (默认值,可通过环境变量或配置覆盖) -AUTO_BAN_ERROR_CODES = [403] - -# ====================== 环境变量映射表 ====================== -# 统一维护环境变量名和配置键名的映射关系 -# 格式: "环境变量名": "配置键名" -ENV_MAPPINGS = { - "CODE_ASSIST_ENDPOINT": "code_assist_endpoint", - "CREDENTIALS_DIR": "credentials_dir", - "PROXY": "proxy", - "OAUTH_PROXY_URL": "oauth_proxy_url", - "GOOGLEAPIS_PROXY_URL": "googleapis_proxy_url", - "RESOURCE_MANAGER_API_URL": "resource_manager_api_url", - "SERVICE_USAGE_API_URL": "service_usage_api_url", - "ANTIGRAVITY_API_URL": "antigravity_api_url", - "AUTO_BAN": "auto_ban_enabled", - "AUTO_BAN_ERROR_CODES": "auto_ban_error_codes", - "RETRY_429_MAX_RETRIES": "retry_429_max_retries", - "RETRY_429_ENABLED": "retry_429_enabled", - "RETRY_429_INTERVAL": "retry_429_interval", - "ANTI_TRUNCATION_MAX_ATTEMPTS": "anti_truncation_max_attempts", - "COMPATIBILITY_MODE": "compatibility_mode_enabled", - "RETURN_THOUGHTS_TO_FRONTEND": "return_thoughts_to_frontend", - "ANTIGRAVITY_STREAM2NOSTREAM": "antigravity_stream2nostream", - "ANTIGRAVITY_SWITCH_CREDENTIAL": "antigravity_switch_credential_enabled", - "HOST": "host", - "PORT": "port", - "API_PASSWORD": "api_password", - "PANEL_PASSWORD": "panel_password", - "PASSWORD": "password", - "KEEPALIVE_URL": "keepalive_url", - "KEEPALIVE_INTERVAL": "keepalive_interval", -} - - -# ====================== 配置系统 ====================== - -async def init_config(): - """初始化配置缓存(启动时调用一次)""" - global _config_cache, _config_initialized - - if _config_initialized: - return - - try: - from src.storage_adapter import get_storage_adapter - storage_adapter = await get_storage_adapter() - _config_cache = await storage_adapter.get_all_config() - _config_initialized = True - except Exception: - # 初始化失败时使用空缓存 - _config_cache = {} - _config_initialized = True - - -async def reload_config(): - """重新加载配置(修改配置后调用)""" - global _config_cache, _config_initialized - - try: - from src.storage_adapter import get_storage_adapter - storage_adapter = await get_storage_adapter() - - # 如果后端支持 reload_config_cache,调用它 - if hasattr(storage_adapter._backend, 'reload_config_cache'): - await storage_adapter._backend.reload_config_cache() - - # 重新加载配置缓存 - _config_cache = await storage_adapter.get_all_config() - _config_initialized = True - except Exception: - pass - - -def _get_cached_config(key: str, default: Any = None) -> Any: - """从内存缓存获取配置(同步)""" - return _config_cache.get(key, default) - - -async def get_config_value(key: str, default: Any = None, env_var: Optional[str] = None) -> Any: - """Get configuration value with priority: ENV > Storage > default.""" - # 确保配置已初始化 - if not _config_initialized: - await init_config() - - # Priority 1: Environment variable - if env_var and os.getenv(env_var): - return os.getenv(env_var) - - # Priority 2: Memory cache - value = _get_cached_config(key) - if value is not None: - return value - - return default - - -# Configuration getters - all async -async def get_proxy_config(): - """Get proxy configuration.""" - proxy_url = await get_config_value("proxy", env_var="PROXY") - return proxy_url if proxy_url else None - - -async def get_auto_ban_enabled() -> bool: - """Get auto ban enabled setting.""" - env_value = os.getenv("AUTO_BAN") - if env_value: - return env_value.lower() in ("true", "1", "yes", "on") - - return bool(await get_config_value("auto_ban_enabled", False)) - - -async def get_auto_ban_error_codes() -> list: - """ - Get auto ban error codes. - - Environment variable: AUTO_BAN_ERROR_CODES (comma-separated, e.g., "400,403") - Database config key: auto_ban_error_codes - Default: [400, 403] - """ - env_value = os.getenv("AUTO_BAN_ERROR_CODES") - if env_value: - try: - return [int(code.strip()) for code in env_value.split(",") if code.strip()] - except ValueError: - pass - - codes = await get_config_value("auto_ban_error_codes") - if codes and isinstance(codes, list): - return codes - return AUTO_BAN_ERROR_CODES - - -async def get_retry_429_max_retries() -> int: - """Get max retries for 429 errors.""" - env_value = os.getenv("RETRY_429_MAX_RETRIES") - if env_value: - try: - return int(env_value) - except ValueError: - pass - - return int(await get_config_value("retry_429_max_retries", 5)) - - -async def get_retry_429_enabled() -> bool: - """Get 429 retry enabled setting.""" - env_value = os.getenv("RETRY_429_ENABLED") - if env_value: - return env_value.lower() in ("true", "1", "yes", "on") - - return bool(await get_config_value("retry_429_enabled", True)) - - -async def get_retry_429_interval() -> float: - """Get 429 retry interval in seconds.""" - env_value = os.getenv("RETRY_429_INTERVAL") - if env_value: - try: - return float(env_value) - except ValueError: - pass - - return float(await get_config_value("retry_429_interval", 1)) - - -async def get_anti_truncation_max_attempts() -> int: - """ - Get maximum attempts for anti-truncation continuation. - - Environment variable: ANTI_TRUNCATION_MAX_ATTEMPTS - Database config key: anti_truncation_max_attempts - Default: 3 - """ - env_value = os.getenv("ANTI_TRUNCATION_MAX_ATTEMPTS") - if env_value: - try: - return int(env_value) - except ValueError: - pass - - return int(await get_config_value("anti_truncation_max_attempts", 3)) - - -# Server Configuration -async def get_server_host() -> str: - """ - Get server host setting. - - Environment variable: HOST - Database config key: host - Default: 0.0.0.0 - """ - return str(await get_config_value("host", "0.0.0.0", "HOST")) - - -async def get_server_port() -> int: - """ - Get server port setting. - - Environment variable: PORT - Database config key: port - Default: 7861 - """ - env_value = os.getenv("PORT") - if env_value: - try: - return int(env_value) - except ValueError: - pass - - return int(await get_config_value("port", 7861)) - - -async def get_api_password() -> str: - """ - Get API password setting for chat endpoints. - - Environment variable: API_PASSWORD - Database config key: api_password - Default: Uses PASSWORD env var for compatibility, otherwise 'pwd' - """ - # 优先使用 API_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性 - api_password = await get_config_value("api_password", None, "API_PASSWORD") - if api_password is not None: - return str(api_password) - - # 兼容性:使用通用密码 - return str(await get_config_value("password", "pwd", "PASSWORD")) - - -async def get_panel_password() -> str: - """ - Get panel password setting for web interface. - - Environment variable: PANEL_PASSWORD - Database config key: panel_password - Default: Uses PASSWORD env var for compatibility, otherwise 'pwd' - """ - # 优先使用 PANEL_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性 - panel_password = await get_config_value("panel_password", None, "PANEL_PASSWORD") - if panel_password is not None: - return str(panel_password) - - # 兼容性:使用通用密码 - return str(await get_config_value("password", "pwd", "PASSWORD")) - - -async def get_server_password() -> str: - """ - Get server password setting (deprecated, use get_api_password or get_panel_password). - - Environment variable: PASSWORD - Database config key: password - Default: pwd - """ - return str(await get_config_value("password", "pwd", "PASSWORD")) - - -async def get_credentials_dir() -> str: - """ - Get credentials directory setting. - - Environment variable: CREDENTIALS_DIR - Database config key: credentials_dir - Default: ./creds - """ - return str(await get_config_value("credentials_dir", "./creds", "CREDENTIALS_DIR")) - - -async def get_code_assist_endpoint() -> str: - """ - Get Code Assist endpoint setting. - - Environment variable: CODE_ASSIST_ENDPOINT - Database config key: code_assist_endpoint - Default: https://cloudcode-pa.googleapis.com - """ - return str( - await get_config_value( - "code_assist_endpoint", "https://cloudcode-pa.googleapis.com", "CODE_ASSIST_ENDPOINT" - ) - ) - - -async def get_compatibility_mode_enabled() -> bool: - """ - Get compatibility mode setting. - - 兼容性模式:启用后所有system消息全部转换成user,停用system_instructions。 - 该选项可能会降低模型理解能力,但是能避免流式空回的情况。 - - Environment variable: COMPATIBILITY_MODE - Database config key: compatibility_mode_enabled - Default: False - """ - env_value = os.getenv("COMPATIBILITY_MODE") - if env_value: - return env_value.lower() in ("true", "1", "yes", "on") - - return bool(await get_config_value("compatibility_mode_enabled", False)) - - -async def get_return_thoughts_to_frontend() -> bool: - """ - Get return thoughts to frontend setting. - - 控制是否将思维链返回到前端。 - 启用后,思维链会在响应中返回;禁用后,思维链会在响应中被过滤掉。 - - Environment variable: RETURN_THOUGHTS_TO_FRONTEND - Database config key: return_thoughts_to_frontend - Default: True - """ - env_value = os.getenv("RETURN_THOUGHTS_TO_FRONTEND") - if env_value: - return env_value.lower() in ("true", "1", "yes", "on") - - return bool(await get_config_value("return_thoughts_to_frontend", True)) - - -async def get_antigravity_stream2nostream() -> bool: - """ - Get use stream for non-stream setting. - - 控制antigravity非流式请求是否使用流式API并收集为完整响应。 - 启用后,非流式请求将在后端使用流式API,然后收集所有块后再返回完整响应。 - - Environment variable: ANTIGRAVITY_STREAM2NOSTREAM - Database config key: antigravity_stream2nostream - Default: True - """ - env_value = os.getenv("ANTIGRAVITY_STREAM2NOSTREAM") - if env_value: - return env_value.lower() in ("true", "1", "yes", "on") - - return bool(await get_config_value("antigravity_stream2nostream", True)) - - -async def get_antigravity_switch_credential_enabled() -> bool: - """ - Get antigravity switch credential setting. - - 控制antigravity在重试时是否切换凭证。 - 禁用时会持续使用当前凭证,直到该凭证对当前模型进入CD或被禁用。 - - Environment variable: ANTIGRAVITY_SWITCH_CREDENTIAL - Database config key: antigravity_switch_credential_enabled - Default: False - """ - env_value = os.getenv("ANTIGRAVITY_SWITCH_CREDENTIAL") - if env_value: - return env_value.lower() in ("true", "1", "yes", "on") - - return bool(await get_config_value("antigravity_switch_credential_enabled", False)) - - -async def get_oauth_proxy_url() -> str: - """ - Get OAuth proxy URL setting. - - 用于Google OAuth2认证的代理URL。 - - Environment variable: OAUTH_PROXY_URL - Database config key: oauth_proxy_url - Default: https://oauth2.googleapis.com - """ - return str( - await get_config_value( - "oauth_proxy_url", "https://oauth2.googleapis.com", "OAUTH_PROXY_URL" - ) - ) - - -async def get_googleapis_proxy_url() -> str: - """ - Get Google APIs proxy URL setting. - - 用于Google APIs调用的代理URL。 - - Environment variable: GOOGLEAPIS_PROXY_URL - Database config key: googleapis_proxy_url - Default: https://www.googleapis.com - """ - return str( - await get_config_value( - "googleapis_proxy_url", "https://www.googleapis.com", "GOOGLEAPIS_PROXY_URL" - ) - ) - - -async def get_resource_manager_api_url() -> str: - """ - Get Google Cloud Resource Manager API URL setting. - - 用于Google Cloud Resource Manager API的URL。 - - Environment variable: RESOURCE_MANAGER_API_URL - Database config key: resource_manager_api_url - Default: https://cloudresourcemanager.googleapis.com - """ - return str( - await get_config_value( - "resource_manager_api_url", - "https://cloudresourcemanager.googleapis.com", - "RESOURCE_MANAGER_API_URL", - ) - ) - - -async def get_service_usage_api_url() -> str: - """ - Get Google Cloud Service Usage API URL setting. - - 用于Google Cloud Service Usage API的URL。 - - Environment variable: SERVICE_USAGE_API_URL - Database config key: service_usage_api_url - Default: https://serviceusage.googleapis.com - """ - return str( - await get_config_value( - "service_usage_api_url", "https://serviceusage.googleapis.com", "SERVICE_USAGE_API_URL" - ) - ) - - -async def get_antigravity_api_url() -> str: - """ - Get Antigravity API URL setting. - - 用于Google Antigravity API的URL。 - - Environment variable: ANTIGRAVITY_API_URL - Database config key: antigravity_api_url - Default: https://daily-cloudcode-pa.googleapis.com - """ - return str( - await get_config_value( - "antigravity_api_url", - "https://daily-cloudcode-pa.googleapis.com", - "ANTIGRAVITY_API_URL", - ) - ) - - -async def get_keepalive_url() -> str: - """ - Get keep-alive URL setting. - - 配置后保活服务会定期向该URL发送GET请求。 - 留空表示禁用保活服务。 - - Environment variable: KEEPALIVE_URL - Database config key: keepalive_url - Default: "" (disabled) - """ - return str(await get_config_value("keepalive_url", "", "KEEPALIVE_URL")) - - -async def get_keepalive_interval() -> int: - """ - Get keep-alive interval in seconds. - - 保活请求发送间隔(秒)。 - - Environment variable: KEEPALIVE_INTERVAL - Database config key: keepalive_interval - Default: 60 - """ - env_value = os.getenv("KEEPALIVE_INTERVAL") - if env_value: - try: - return int(env_value) - except ValueError: - pass - - return int(await get_config_value("keepalive_interval", 60)) diff --git a/credit.sh b/credit.sh new file mode 100644 index 0000000000000000000000000000000000000000..ae0e085ceab21398e110fb317c3e6dbbd59be704 --- /dev/null +++ b/credit.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# credit.sh — WorkBuddy 积分日报(默认美化输出) +# +# 用法: +# ./credit.sh # 人类可读日报 +# ./credit.sh -json # 原始 JSON +# +# 二进制升级: go build -o credit ./cmd/credit +set -euo pipefail +cd "$(dirname "$0")" +if [[ "${1:-}" == "-json" ]]; then + exec ./credit +fi +exec ./credit -pretty diff --git a/darwin-install.sh b/darwin-install.sh deleted file mode 100644 index 91681f26e2f240ad6495802aa0364b8a6768289f..0000000000000000000000000000000000000000 --- a/darwin-install.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -# macOS 安装脚本 (支持 Intel 和 Apple Silicon) - -# 确保 Homebrew 已安装 -if ! command -v brew &> /dev/null; then - echo "未检测到 Homebrew,开始安装..." - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - - # 检测 Homebrew 安装路径并设置环境变量 - if [[ -f "/opt/homebrew/bin/brew" ]]; then - # Apple Silicon Mac - eval "$(/opt/homebrew/bin/brew shellenv)" - elif [[ -f "/usr/local/bin/brew" ]]; then - # Intel Mac - eval "$(/usr/local/bin/brew shellenv)" - fi -fi - -# 更新 brew 并安装 git -brew update -brew install git - -# 安装 uv (Python 环境管理工具) -curl -Ls https://astral.sh/uv/install.sh | sh - -# 确保 uv 在 PATH 中 -export PATH="$HOME/.local/bin:$PATH" - -# 克隆或进入项目目录 -if [ -f "./web.py" ]; then - # 已经在目标目录 - : -elif [ -f "./gcli2api/web.py" ]; then - cd ./gcli2api -else - git clone https://github.com/su-kaka/gcli2api.git - cd ./gcli2api -fi - -# 拉取最新代码 -git pull - -# 创建并同步虚拟环境 -uv sync - -# 激活虚拟环境 -if [ -f ".venv/bin/activate" ]; then - source .venv/bin/activate -else - echo "❌ 未找到虚拟环境,请检查 uv 是否安装成功" - exit 1 -fi - -# 启动项目 -python3 web.py diff --git a/docker-compose.yml b/docker-compose.yml index d68b8eef43e5ab6e87f3629790785c3099bdf96f..d60ba24a77c723a9738a01add4f83d48248d8490 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,79 +1,15 @@ -version: '3.8' - +# 本地 / 自建服务器跑法(不用 HF 时)。HF Spaces 上不需要这个文件。 services: - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api + wb2api: + build: . + container_name: workbuddy2api restart: unless-stopped - network_mode: host environment: - # Password configuration (choose one) - # Option 1: Use common password - - PASSWORD=${PASSWORD:-pwd} - # Option 2: Use separate passwords (uncomment if needed) - # - API_PASSWORD=${API_PASSWORD:-your_api_password} - # - PANEL_PASSWORD=${PANEL_PASSWORD:-your_panel_password} - - # Server configuration - - PORT=${PORT:-7861} - - HOST=${HOST:-0.0.0.0} - - # Optional: Google credentials from environment - # - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} - - # Optional: Logging configuration - # - LOG_LEVEL=${LOG_LEVEL:-info} - - # Optional: Redis configuration (for distributed storage) - # - REDIS_URI=${REDIS_URI} - # - REDIS_DATABASE=${REDIS_DATABASE:-0} - - # Optional: MongoDB configuration (for distributed storage) - # - MONGODB_URI=${MONGODB_URI} - # - MONGODB_DATABASE=${MONGODB_DATABASE:-gcli2api} - - # Optional: PostgreSQL configuration (for distributed storage) - # - POSTGRES_DSN=${POSTGRES_DSN} - - # Optional: Proxy configuration - # - PROXY=${PROXY} + - TZ=Asia/Shanghai + ports: + - "7861:7861" volumes: - - ./data/creds:/app/creds - -# Example with Redis for distributed storage -# redis: -# image: redis:7-alpine -# container_name: gcli2api-redis -# restart: unless-stopped -# ports: -# - "6379:6379" -# volumes: -# - redis_data:/data -# command: redis-server --appendonly yes -# healthcheck: -# test: ["CMD", "redis-cli", "ping"] -# interval: 10s -# timeout: 3s -# retries: 3 - -# Example with MongoDB for distributed storage -# mongodb: -# image: mongo:7 -# container_name: gcli2api-mongodb -# restart: unless-stopped -# environment: -# MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin} -# MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password} -# ports: -# - "27017:27017" -# volumes: -# - mongodb_data:/data/db -# healthcheck: -# test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] -# interval: 10s -# timeout: 5s -# retries: 3 - -#volumes: -# redis_data: -# mongodb_data: + # 一个目录装下全部持久化状态,和 HF 上挂桶的布局保持一致: + # storage/config.json、storage/auths/、storage/data/ + - ./storage:/app/storage + - ./storage/config.json:/app/config.json diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..ce68a3b236ec1ddb45efee33bd31a597671d338d --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# WorkBuddy2API Panel — Hugging Face Spaces 入口脚本 +# +# 持久化模型: +# HF Storage Bucket(私有) -> 挂载到容器 /app/storage +# /app/storage/config.json 运行配置(含 api_key,私有卷上,不进公开仓库) +# /app/storage/auths/*.json 账号凭证 +# /app/storage/data/*.json 水位/用量/模型探测/请求归档 +# +# 容器根文件系统是 ephemeral 的:重启、休眠唤醒、重新构建都会重置。 +# 所以配置和账号必须待在卷上,否则每次重启都要重新登录账号。 +set -eu + +PERSIST="${WB2A_PERSIST_DIR:-/app/storage}" +TEMPLATE=/app/config.json +CFG="$TEMPLATE" + +echo "[entrypoint] boot at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + +if [ -d "$PERSIST" ]; then + echo "[entrypoint] persistent volume detected at $PERSIST" + mkdir -p "$PERSIST/auths" "$PERSIST/data" + + # 落盘自检:程序全部走「写 .tmp 再 rename」,rename 不可用的话持久化是假的 + if echo probe > "$PERSIST/.wb2a-write-probe" 2>/dev/null \ + && mv "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null \ + && rm -f "$PERSIST/.wb2a-rename-probe"; then + echo "[entrypoint] storage self-test: write+rename OK" + else + echo "[entrypoint] storage self-test: FAILED — write or rename unsupported on $PERSIST" >&2 + rm -f "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null || true + fi + + if [ ! -f "$PERSIST/config.json" ]; then + if [ -f "$TEMPLATE" ]; then + echo "[entrypoint] no config.json on the volume -> seeding from image template" + cp "$TEMPLATE" "$PERSIST/config.json" + else + echo "[entrypoint] no config.json on the volume and no image template" >&2 + fi + fi + [ -f "$PERSIST/config.json" ] && CFG="$PERSIST/config.json" +else + echo "[entrypoint] WARNING: no persistent volume at $PERSIST — everything written will be LOST on restart" >&2 + mkdir -p /app/auths /app/data +fi + +# 安全闸门:api_key 为空 == 完全不鉴权(httpauth.VerifyBearer 语义)。 +# 空间是公网可达的,绝不允许以裸奔状态启动。 +if [ -n "${WB2A_API_KEY:-}" ]; then + echo "[entrypoint] api_key supplied via WB2A_API_KEY env" +else + KEY=$(python3 -c 'import json,sys +try: + print(json.load(open(sys.argv[1])).get("api_key") or "") +except Exception: + print("")' "$CFG" 2>/dev/null || true) + if [ -z "$KEY" ]; then + echo "[entrypoint] FATAL: api_key is empty in $CFG and WB2A_API_KEY is not set." >&2 + echo "[entrypoint] Refusing to start: an empty api_key disables authentication on a public Space." >&2 + echo "[entrypoint] Fix: write a config.json containing an api_key onto the bucket, or set the WB2A_API_KEY secret." >&2 + exit 1 + fi + echo "[entrypoint] api_key loaded from config file" +fi + +echo "[entrypoint] config: $CFG" +echo "[entrypoint] auth_dir: $(dirname "$CFG")/auths (see config)" +exec /app/wb2api -config "$CFG" diff --git a/docs/README_EN.md b/docs/README_EN.md deleted file mode 100644 index cadb0dc0deeb0e89807e39015e07876ec89dab49..0000000000000000000000000000000000000000 --- a/docs/README_EN.md +++ /dev/null @@ -1,883 +0,0 @@ -# GeminiCLI to API - -**Convert GeminiCLI and Antigravity to OpenAI, GEMINI, and Claude API Compatible Interfaces** - -[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) -[![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](../LICENSE) -[![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api) - -[中文](../README.md) | English | [日本語](./README_JA.md) - -## 🚀 Quick Deploy - -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka) -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api) ---- - -## Installation Guide - -### Termux Environment - -**Initial Installation** -```bash -curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh -``` - -**Restart Service** -```bash -cd gcli2api -bash termux-start.sh -``` - -### Windows Environment - -**Initial Installation** -```powershell -iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content -``` - -**Restart Service** -Double-click to execute `start.bat` - -### Linux Environment - -**Initial Installation** -```bash -curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh -``` - -**Restart Service** -```bash -cd gcli2api -bash start.sh -``` - -### macOS Environment - -**Initial Installation** -```bash -curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh -``` - -**Restart Service** -```bash -cd gcli2api -bash start.sh -``` - -### Docker Environment - -**Docker Run Command** -```bash -# Using universal password -docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest - -# Using separate passwords -docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Mac** -```bash -# Using universal password -docker run -d \ - --name gcli2api \ - -p 7861:7861 \ - -p 8080:8080 \ - -e PASSWORD=pwd \ - -e PORT=7861 \ - -v "$(pwd)/data/creds":/app/creds \ - ghcr.io/su-kaka/gcli2api:latest -``` - -```bash -# Using separate passwords -docker run -d \ ---name gcli2api \ --p 7861:7861 \ --p 8080:8080 \ --e API_PASSWORD=api_pwd \ --e PANEL_PASSWORD=panel_pwd \ --e PORT=7861 \ --v $(pwd)/data/creds:/app/creds \ -ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Compose Run Command** -1. Save the following content as `docker-compose.yml` file: - ```yaml - version: '3.8' - - services: - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api - restart: unless-stopped - network_mode: host - environment: - # Using universal password (recommended for simple deployment) - - PASSWORD=pwd - - PORT=7861 - # Or use separate passwords (recommended for production) - # - API_PASSWORD=your_api_password - # - PANEL_PASSWORD=your_panel_password - volumes: - - ./data/creds:/app/creds - healthcheck: - test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - ``` -2. Start the service: - ```bash - docker-compose up -d - ``` - -## Core Features - -### 🔄 API Endpoints and Format Support - -**Multi-endpoint Multi-format Support** -- **OpenAI Compatible Endpoints**: `/v1/chat/completions` and `/v1/models` - - Supports standard OpenAI format (messages structure) - - Supports Gemini native format (contents structure) - - Automatic format detection and conversion, no manual switching required - - Supports multimodal input (text + images) -- **Gemini Native Endpoints**: `/v1/models/{model}:generateContent` and `streamGenerateContent` - - Supports complete Gemini native API specifications - - Multiple authentication methods: Bearer Token, x-goog-api-key header, URL parameter key -- **Claude Format Compatibility**: Full support for Claude API format - - Endpoint: `/v1/messages` (follows Claude API specification) - - Supports Claude standard messages format - - Supports system parameter and Claude-specific features - - Automatically converts to backend-supported format -- **Antigravity API Support**: Supports OpenAI, Gemini, and Claude formats - - OpenAI format endpoint: `/antigravity/v1/chat/completions` - - Gemini format endpoint: `/antigravity/v1/models/{model}:generateContent` and `streamGenerateContent` - - Claude format endpoint: `/antigravity/v1/messages` - - Supports all Antigravity models (Claude, Gemini, etc.) - - Automatic model name mapping and thinking mode detection - -### 🔐 Authentication and Security Management - -**Flexible Password Management** -- **Separate Password Support**: API password (chat endpoints) and control panel password can be set independently -- **Multiple Authentication Methods**: Supports Authorization Bearer, x-goog-api-key header, URL parameters, etc. -- **JWT Token Authentication**: Control panel supports JWT token authentication -- **User Email Retrieval**: Automatically retrieves and displays Google account email addresses - -### 📊 Intelligent Credential Management System - -**Advanced Credential Management** -- Multiple Google OAuth credential automatic rotation -- Enhanced stability through redundant authentication -- Load balancing and concurrent request support -- Automatic failure detection and credential disabling -- Credential usage statistics and quota management -- Support for manual enable/disable credential files -- Batch credential file operations (enable, disable, delete) - -**Credential Status Monitoring** -- Real-time credential health checks -- Error code tracking (429, 403, 500, etc.) -- Automatic banning mechanism (configurable) - -### 🌊 Streaming and Response Processing - -**Multiple Streaming Support** -- True real-time streaming responses -- Fake streaming mode (for compatibility) -- Streaming anti-truncation feature (prevents answer truncation) -- Asynchronous task management and timeout handling - -**Response Optimization** -- Thinking chain content separation -- Reasoning process (reasoning_content) handling -- Multi-turn conversation context management -- Compatibility mode (converts system messages to user messages) - -### 🎛️ Web Management Console - -**Full-featured Web Interface** -- OAuth authentication flow management (supports GCLI and Antigravity dual modes) -- Credential file upload, download, and management -- Real-time log viewing (WebSocket) -- System configuration management -- Usage statistics and monitoring dashboard -- Mobile-friendly interface - -**Batch Operation Support** -- ZIP file batch credential upload (GCLI and Antigravity) -- Batch enable/disable/delete credentials -- Batch user email retrieval -- Batch configuration management -- Unified batch upload interface for all credential types - -### 📈 Usage Monitoring - -**Real-time Monitoring** -- WebSocket real-time log streams -- System status monitoring -- Credential health status - -### 🔧 Advanced Configuration and Customization - -**Network and Proxy Configuration** -- HTTP/HTTPS proxy support -- Proxy endpoint configuration (OAuth, Google APIs, metadata service) -- Timeout and retry configuration -- Network error handling and recovery - -**Performance and Stability Configuration** -- 429 error automatic retry (configurable interval and attempts) -- Anti-truncation maximum retry attempts - -**Logging and Debugging** -- Multi-level logging system (DEBUG, INFO, WARNING, ERROR) -- Log file management -- Real-time log streams -- Log download and clearing - -### 🔄 Environment Variables and Configuration Management - -**Flexible Configuration Methods** -- Environment variable configuration -- Hot configuration updates (partial configuration items) -- Configuration locking (environment variable priority) - -## Supported Models - -All models have 1M context window capacity. Each credential file provides 1000 request quota. - -### 🤖 Base Models -- `gemini-2.5-pro` -- `gemini-3-pro-preview` -- `gemini-3.1-pro-preview` - -### 🧠 Thinking Models -- `gemini-2.5-pro-high`: Thinking mode -- `gemini-2.5-pro-low`: Low thinking mode -- Supports custom thinking budget configuration -- Automatic separation of thinking content and final answers - -### 🔍 Search-Enhanced Models -- `gemini-2.5-pro-search`: Model with integrated search functionality - -### 🖼️ Image Generation Models (Antigravity) -- `gemini-3.1-flash-image`: Base image generation model -- **Resolution Suffixes**: - - `-2k`: 2K resolution - - `-4k`: 4K HD resolution -- **Aspect Ratio Suffixes**: - - `-1x1`: Square (avatar) - - `-16x9`: Landscape (desktop wallpaper) - - `-9x16`: Portrait (mobile wallpaper) - - `-21x9`: Ultra-wide (ultrawide monitor) - - `-4x3`: Traditional display - - `-3x4`: Portrait poster -- **Combination Examples**: - - `gemini-3.1-flash-image-4k-16x9`: 4K landscape - - `gemini-3.1-flash-image-2k-9x16`: 2K portrait -- When no ratio is specified, the API automatically decides the aspect ratio - -### 🌊 Special Feature Variants -- **Fake Streaming Mode**: Add `-假流式` suffix to any model name - - Example: `gemini-2.5-pro-假流式` - - For scenarios requiring streaming responses but server doesn't support true streaming -- **Streaming Anti-truncation Mode**: Add `流式抗截断/` prefix to model name - - Example: `流式抗截断/gemini-2.5-pro` - - Automatically detects response truncation and retries to ensure complete answers - -### 🔧 Automatic Model Feature Detection -- System automatically recognizes feature identifiers in model names -- Transparently handles feature mode transitions -- Supports feature combination usage - ---- - -## Configuration Instructions - -1. Visit `http://127.0.0.1:7861` (default port, modifiable via PORT environment variable) -2. Complete OAuth authentication flow (default password: `pwd`, modifiable via environment variables) - - **GCLI Mode**: For obtaining Google Cloud Gemini API credentials - - **Antigravity Mode**: For obtaining Google Antigravity API credentials -3. Configure client: - -**OpenAI Compatible Client:** - - **Endpoint Address**: `http://127.0.0.1:7861/v1` - - **API Key**: `pwd` (default value, modifiable via API_PASSWORD or PASSWORD environment variables) - -**Gemini Native Client:** - - **Endpoint Address**: `http://127.0.0.1:7861` - - **Authentication Methods**: - - `Authorization: Bearer your_api_password` - - `x-goog-api-key: your_api_password` - - URL parameter: `?key=your_api_password` - -### 🌟 Dual Authentication Mode Support - -**GCLI Authentication Mode** -- Standard Google Cloud Gemini API authentication -- Supports OAuth2.0 authentication flow -- Automatically enables required Google Cloud APIs - -**Antigravity Authentication Mode** -- Dedicated authentication for Google Antigravity API -- Independent credential management system -- Supports batch upload and management -- Completely isolated from GCLI credentials - -**Unified Management Interface** -- Manage both credential types in the "Batch Upload" tab -- Upper section: GCLI credential batch upload (blue theme) -- Lower section: Antigravity credential batch upload (green theme) -- Separate credential management tabs for each type - -## 💾 Data Storage Mode - -### 🌟 Storage Backend Support - -gcli2api supports two storage backends: **Local SQLite (Default)** and **MongoDB (Cloud Distributed Storage)** - -### 📁 Local SQLite Storage (Default) - -**Default Storage Method** -- No configuration required, works out of the box -- Data is stored in a local SQLite database -- Suitable for single-machine deployment and personal use -- Automatically creates and manages database files - -### 🍃 MongoDB Cloud Storage Mode - -**Cloud Distributed Storage Solution** - -When multi-instance deployment or cloud storage is needed, MongoDB storage mode can be enabled. - -### ⚙️ Enable MongoDB Mode - -**Step 1: Configure MongoDB Connection** -```bash -# Local MongoDB -export MONGODB_URI="mongodb://localhost:27017" - -# MongoDB Atlas cloud service -export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net" - -# MongoDB with authentication -export MONGODB_URI="mongodb://admin:password@localhost:27017/admin" - -# Optional: Custom database name (default: gcli2api) -export MONGODB_DATABASE="my_gcli_db" -``` - -**Step 2: Start Application** -```bash -# Application will automatically detect MongoDB configuration and use MongoDB storage -python web.py -``` - -**Docker Environment using MongoDB** -```bash -# Single MongoDB deployment -docker run -d --name gcli2api \ - -e MONGODB_URI="mongodb://mongodb:27017" \ - -e API_PASSWORD=your_password \ - --network your_network \ - ghcr.io/su-kaka/gcli2api:latest - -# Using MongoDB Atlas -docker run -d --name gcli2api \ - -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \ - -e API_PASSWORD=your_password \ - -p 7861:7861 \ - ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Compose Example** -```yaml -version: '3.8' - -services: - mongodb: - image: mongo:7 - container_name: gcli2api-mongodb - restart: unless-stopped - environment: - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: password123 - volumes: - - mongodb_data:/data/db - ports: - - "27017:27017" - - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api - restart: unless-stopped - depends_on: - - mongodb - environment: - - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin - - MONGODB_DATABASE=gcli2api - - API_PASSWORD=your_api_password - - PORT=7861 - ports: - - "7861:7861" - -volumes: - mongodb_data: -``` - - -### 🔧 Advanced Configuration - -**MongoDB Connection Optimization** -```bash -# Connection pool and timeout configuration -export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000" - -# Replica set configuration -export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet" - -# Read-write separation configuration -export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred" -``` - -### Environment Variable Configuration - -**Basic Configuration** -- `PORT`: Service port (default: 7861) -- `HOST`: Server listen address (default: 0.0.0.0) - -**Password Configuration** -- `API_PASSWORD`: Chat API access password (default: inherits PASSWORD or pwd) -- `PANEL_PASSWORD`: Control panel access password (default: inherits PASSWORD or pwd) -- `PASSWORD`: Universal password, overrides the above two when set (default: pwd) - -**Performance and Stability Configuration** -- `RETRY_429_ENABLED`: Enable 429 error automatic retry (default: true) -- `RETRY_429_MAX_RETRIES`: Maximum retry attempts for 429 errors (default: 3) -- `RETRY_429_INTERVAL`: Retry interval for 429 errors, in seconds (default: 1.0) -- `ANTI_TRUNCATION_MAX_ATTEMPTS`: Maximum retry attempts for anti-truncation (default: 3) - -**Network and Proxy Configuration** -- `PROXY`: HTTP/HTTPS proxy address (format: `http://host:port`) -- `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint -- `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint -- `METADATA_SERVICE_URL`: Metadata service proxy endpoint - -**Automation Configuration** -- `AUTO_BAN`: Enable automatic credential banning (default: true) -- `AUTO_LOAD_ENV_CREDS`: Automatically load environment variable credentials at startup (default: false) - -**Compatibility Configuration** -- `COMPATIBILITY_MODE`: Enable compatibility mode, converts system messages to user messages (default: false) - -**Logging Configuration** -- `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO) -- `LOG_FILE`: Log file path (default: log.txt) - -**Storage Configuration** - -**SQLite Configuration (Default)** -- No configuration required, automatically uses local SQLite database -- Database files are automatically created in the project directory - -**MongoDB Configuration (Optional Cloud Storage)** -- `MONGODB_URI`: MongoDB connection string (enables MongoDB mode when set) -- `MONGODB_DATABASE`: MongoDB database name (default: gcli2api) - -**Docker Usage Example** -```bash -# Using universal password -docker run -d --name gcli2api \ - -e PASSWORD=mypassword \ - -e PORT=7861 \ - ghcr.io/su-kaka/gcli2api:latest - -# Using separate passwords -docker run -d --name gcli2api \ - -e API_PASSWORD=my_api_password \ - -e PANEL_PASSWORD=my_panel_password \ - -e PORT=7861 \ - ghcr.io/su-kaka/gcli2api:latest -``` - -Note: When credential environment variables are set, the system will prioritize using credentials from environment variables and ignore files in the `creds` directory. - -### API Usage Methods - -This service supports multiple complete sets of API endpoints: - -#### 1. OpenAI Compatible Endpoints (GCLI) - -**Endpoint:** `/v1/chat/completions` -**Authentication:** `Authorization: Bearer your_api_password` - -Supports two request formats with automatic detection and processing: - -**OpenAI Format:** -```json -{ - "model": "gemini-2.5-pro", - "messages": [ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "Hello"} - ], - "temperature": 0.7, - "stream": true -} -``` - -**Gemini Native Format:** -```json -{ - "model": "gemini-2.5-pro", - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ], - "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]}, - "generationConfig": { - "temperature": 0.7 - } -} -``` - -#### 2. Gemini Native Endpoints (GCLI) - -**Non-streaming Endpoint:** `/v1/models/{model}:generateContent` -**Streaming Endpoint:** `/v1/models/{model}:streamGenerateContent` -**Model List:** `/v1/models` - -**Authentication Methods (choose one):** -- `Authorization: Bearer your_api_password` -- `x-goog-api-key: your_api_password` -- URL parameter: `?key=your_api_password` - -**Request Examples:** -```bash -# Using x-goog-api-key header -curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \ - -H "x-goog-api-key: your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' - -# Using URL parameter -curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' -``` - -#### 3. Claude API Format Endpoints - -**Endpoint:** `/v1/messages` -**Authentication:** `x-api-key: your_api_password` or `Authorization: Bearer your_api_password` - -**Request Example:** -```bash -curl -X POST "http://127.0.0.1:7861/v1/messages" \ - -H "x-api-key: your_api_password" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gemini-2.5-pro", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, Claude!"} - ] - }' -``` - -**Support for system parameter:** -```json -{ - "model": "gemini-2.5-pro", - "max_tokens": 1024, - "system": "You are a helpful assistant", - "messages": [ - {"role": "user", "content": "Hello"} - ] -} -``` - -**Notes:** -- Fully compatible with Claude API format specification -- Automatically converts to Gemini format for backend calls -- Supports all Claude standard parameters -- Response format follows Claude API specification - -#### 4. Antigravity API Endpoints - -**Supports three formats: OpenAI, Gemini, and Claude** - -##### Antigravity OpenAI Format Endpoints - -**Endpoint:** `/antigravity/v1/chat/completions` -**Authentication:** `Authorization: Bearer your_api_password` - -**Request Example:** -```bash -curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \ - -H "Authorization: Bearer your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5", - "messages": [ - {"role": "user", "content": "Hello"} - ], - "stream": true - }' -``` - -##### Antigravity Gemini Format Endpoints - -**Non-streaming Endpoint:** `/antigravity/v1/models/{model}:generateContent` -**Streaming Endpoint:** `/antigravity/v1/models/{model}:streamGenerateContent` - -**Authentication Methods (choose one):** -- `Authorization: Bearer your_api_password` -- `x-goog-api-key: your_api_password` -- URL parameter: `?key=your_api_password` - -**Request Examples:** -```bash -# Gemini format non-streaming request -curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \ - -H "x-goog-api-key: your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ], - "generationConfig": { - "temperature": 0.7 - } - }' - -# Gemini format streaming request -curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' -``` - -##### Antigravity Claude Format Endpoints - -**Endpoint:** `/antigravity/v1/messages` -**Authentication:** `x-api-key: your_api_password` - -**Request Example:** -```bash -curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \ - -H "x-api-key: your_api_password" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello"} - ] - }' -``` - -**Supported Antigravity Models:** -- Claude series: `claude-sonnet-4-5`, `claude-opus-4-5`, etc. -- Gemini series: `gemini-2.5-flash`, `gemini-2.5-pro`, etc. -- Automatically supports thinking models - -**Gemini Native Example:** -```python -from io import BytesIO -from PIL import Image -from google.genai import Client -from google.genai.types import HttpOptions -from google.genai import types -# The client gets the API key from the environment variable `GEMINI_API_KEY`. - -client = Client( - api_key="pwd", - http_options=HttpOptions(base_url="http://127.0.0.1:7861"), - ) - -prompt = ( - """ - Draw a cat - """ -) - -response = client.models.generate_content( - model="gemini-3.1-flash-image", - contents=[prompt], - config=types.GenerateContentConfig( - image_config=types.ImageConfig( - aspect_ratio="16:9", - ) - ) -) -for part in response.candidates[0].content.parts: - if part.text is not None: - print(part.text) - elif part.inline_data is not None: - image = Image.open(BytesIO(part.inline_data.data)) - image.save("generated_image.png") - -``` - -**Notes:** -- OpenAI endpoints return OpenAI-compatible format -- Gemini endpoints return Gemini native format -- Both endpoints use the same API password - -## 📋 Complete API Reference - -### Web Console API - -**Authentication Endpoints** -- `POST /auth/login` - User login -- `POST /auth/start` - Start OAuth authentication (supports GCLI and Antigravity modes) -- `POST /auth/callback` - Handle OAuth callback -- `POST /auth/callback-url` - Complete authentication directly from callback URL -- `GET /auth/status/{project_id}` - Check authentication status - -**Credential Management Endpoints** (supports `mode=geminicli` or `mode=antigravity` parameter) -- `POST /creds/upload` - Batch upload credential files (supports JSON and ZIP) -- `GET /creds/status` - Get credential status list (supports pagination and filtering) -- `GET /creds/detail/{filename}` - Get single credential details -- `POST /creds/action` - Single credential operation (enable/disable/delete) -- `POST /creds/batch-action` - Batch credential operations -- `GET /creds/download/{filename}` - Download single credential file -- `GET /creds/download-all` - Package download all credentials -- `POST /creds/fetch-email/{filename}` - Get user email -- `POST /creds/refresh-all-emails` - Batch refresh user emails -- `POST /creds/deduplicate-by-email` - Deduplicate credentials by email -- `POST /creds/verify-project/{filename}` - Verify credential Project ID -- `GET /creds/quota/{filename}` - Get credential quota information (Antigravity only) - -**Configuration Management Endpoints** -- `GET /config/get` - Get current configuration -- `POST /config/save` - Save configuration - -**Log Management Endpoints** -- `POST /logs/clear` - Clear logs -- `GET /logs/download` - Download log file -- `WebSocket /logs/stream` - Real-time log stream - -**Version Information Endpoints** -- `GET /version/info` - Get version information (optional `check_update=true` parameter to check for updates) - -### Chat API Features - -**Multimodal Support** -```json -{ - "model": "gemini-2.5-pro", - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Describe this image"}, - { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..." - } - } - ] - } - ] -} -``` - -**Thinking Mode Support** -```json -{ - "model": "gemini-2.5-pro-high", - "messages": [ - {"role": "user", "content": "Complex math problem"} - ] -} -``` - -Response will include separated thinking content: -```json -{ - "choices": [{ - "message": { - "role": "assistant", - "content": "Final answer", - "reasoning_content": "Detailed thought process..." - } - }] -} -``` - -**Streaming Anti-truncation Usage** -```json -{ - "model": "流式抗截断/gemini-2.5-pro", - "messages": [ - {"role": "user", "content": "Write a long article"} - ], - "stream": true -} -``` - -**Compatibility Mode** -```bash -# Enable compatibility mode -export COMPATIBILITY_MODE=true -``` -In this mode, all `system` messages are converted to `user` messages, improving compatibility with certain clients. - ---- - -## 💬 Community - -Welcome to join the QQ group for discussion! - -**QQ Group: 1083250744** - -QQ Group QR Code - ---- - -## License and Disclaimer - -This project is for learning and research purposes only. Using this project indicates that you agree to: -- Not use this project for any commercial purposes -- Bear all risks and responsibilities of using this project -- Comply with relevant terms of service and legal regulations - -The project authors are not responsible for any direct or indirect losses arising from the use of this project. - -## ⚠️ License Declaration - -**This project is licensed under the Cooperative Non-Commercial License (CNC-1.0)** - -This is a strict anti-commercial open source license. Please refer to the [LICENSE](../LICENSE) file for details. - -### ✅ Permitted Uses: -- Personal learning, research, and educational purposes -- Non-profit organization use -- Open source project integration (must comply with the same license) -- Academic research and publication - -### ❌ Prohibited Uses: -- Any form of commercial use -- Enterprise use with annual revenue exceeding $1 million -- Venture capital-backed or publicly traded companies -- Providing paid services or products -- Commercial competitive use diff --git a/docs/README_JA.md b/docs/README_JA.md deleted file mode 100644 index ec80bb7fe9686cfe75b3e2f632ceb6bf5de0dfb6..0000000000000000000000000000000000000000 --- a/docs/README_JA.md +++ /dev/null @@ -1,883 +0,0 @@ -# GeminiCLI to API - -**GeminiCLIおよびAntigravityをOpenAI、GEMINI、Claude API互換インターフェースに変換** - -[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) -[![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](../LICENSE) -[![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api) - -[中文](../README.md) | [English](README_EN.md) | 日本語 - -## 🚀 クイックデプロイ - -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka) -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api) ---- - -## インストールガイド - -### Termux環境 - -**初期インストール** -```bash -curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh -``` - -**サービス再起動** -```bash -cd gcli2api -bash termux-start.sh -``` - -### Windows環境 - -**初期インストール** -```powershell -iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content -``` - -**サービス再起動** -`start.bat` をダブルクリックして実行 - -### Linux環境 - -**初期インストール** -```bash -curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh -``` - -**サービス再起動** -```bash -cd gcli2api -bash start.sh -``` - -### macOS環境 - -**初期インストール** -```bash -curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh -``` - -**サービス再起動** -```bash -cd gcli2api -bash start.sh -``` - -### Docker環境 - -**Docker Runコマンド** -```bash -# 共通パスワードを使用 -docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest - -# 個別パスワードを使用 -docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Mac** -```bash -# 共通パスワードを使用 -docker run -d \ - --name gcli2api \ - -p 7861:7861 \ - -p 8080:8080 \ - -e PASSWORD=pwd \ - -e PORT=7861 \ - -v "$(pwd)/data/creds":/app/creds \ - ghcr.io/su-kaka/gcli2api:latest -``` - -```bash -# 個別パスワードを使用 -docker run -d \ ---name gcli2api \ --p 7861:7861 \ --p 8080:8080 \ --e API_PASSWORD=api_pwd \ --e PANEL_PASSWORD=panel_pwd \ --e PORT=7861 \ --v $(pwd)/data/creds:/app/creds \ -ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Compose Runコマンド** -1. 以下の内容を `docker-compose.yml` ファイルとして保存: - ```yaml - version: '3.8' - - services: - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api - restart: unless-stopped - network_mode: host - environment: - # 共通パスワードを使用(シンプルなデプロイに推奨) - - PASSWORD=pwd - - PORT=7861 - # または個別パスワードを使用(本番環境に推奨) - # - API_PASSWORD=your_api_password - # - PANEL_PASSWORD=your_panel_password - volumes: - - ./data/creds:/app/creds - healthcheck: - test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - ``` -2. サービスを起動: - ```bash - docker-compose up -d - ``` - -## コア機能 - -### 🔄 APIエンドポイントとフォーマット対応 - -**マルチエンドポイント・マルチフォーマット対応** -- **OpenAI互換エンドポイント**: `/v1/chat/completions` および `/v1/models` - - 標準OpenAIフォーマット(messages構造)に対応 - - Geminiネイティブフォーマット(contents構造)に対応 - - フォーマットの自動検出・変換、手動切替不要 - - マルチモーダル入力に対応(テキスト+画像) -- **Geminiネイティブエンドポイント**: `/v1/models/{model}:generateContent` および `streamGenerateContent` - - Geminiネイティブ API仕様に完全対応 - - 複数の認証方式: Bearer Token、x-goog-api-keyヘッダー、URLパラメータkey -- **Claudeフォーマット互換**: Claude APIフォーマットに完全対応 - - エンドポイント: `/v1/messages`(Claude API仕様に準拠) - - Claude標準messagesフォーマットに対応 - - systemパラメータおよびClaude固有機能に対応 - - バックエンド対応フォーマットへの自動変換 -- **Antigravity API対応**: OpenAI、Gemini、Claudeフォーマットに対応 - - OpenAIフォーマットエンドポイント: `/antigravity/v1/chat/completions` - - Geminiフォーマットエンドポイント: `/antigravity/v1/models/{model}:generateContent` および `streamGenerateContent` - - Claudeフォーマットエンドポイント: `/antigravity/v1/messages` - - 全Antigravityモデルに対応(Claude、Geminiなど) - - モデル名の自動マッピングおよびThinkingモード検出 - -### 🔐 認証とセキュリティ管理 - -**柔軟なパスワード管理** -- **個別パスワード対応**: APIパスワード(チャットエンドポイント)とコントロールパネルパスワードを個別に設定可能 -- **複数の認証方式**: Authorization Bearer、x-goog-api-keyヘッダー、URLパラメータなどに対応 -- **JWTトークン認証**: コントロールパネルはJWTトークン認証に対応 -- **ユーザーメール取得**: Googleアカウントのメールアドレスを自動取得・表示 - -### 📊 インテリジェントなクレデンシャル管理システム - -**高度なクレデンシャル管理** -- 複数のGoogle OAuthクレデンシャルの自動ローテーション -- 冗長認証による安定性の向上 -- ロードバランシングと同時リクエスト対応 -- 自動障害検出とクレデンシャル無効化 -- クレデンシャル使用統計とクォータ管理 -- クレデンシャルファイルの手動有効化/無効化に対応 -- クレデンシャルファイルの一括操作(有効化、無効化、削除) - -**クレデンシャルステータス監視** -- リアルタイムのクレデンシャルヘルスチェック -- エラーコードの追跡(429、403、500など) -- 自動BAN機能(設定可能) - -### 🌊 ストリーミングとレスポンス処理 - -**複数のストリーミング対応** -- リアルタイムストリーミングレスポンス -- 疑似ストリーミングモード(互換性向上用) -- ストリーミング途切れ防止機能(回答の途切れを防止) -- 非同期タスク管理とタイムアウト処理 - -**レスポンス最適化** -- 思考チェーン内容の分離 -- 推論プロセス(reasoning_content)の処理 -- マルチターン会話のコンテキスト管理 -- 互換モード(systemメッセージをuserメッセージに変換) - -### 🎛️ Web管理コンソール - -**フル機能のWebインターフェース** -- OAuth認証フロー管理(GCLIおよびAntigravityデュアルモード対応) -- クレデンシャルファイルのアップロード、ダウンロード、管理 -- リアルタイムログ表示(WebSocket) -- システム設定管理 -- 使用統計と監視ダッシュボード -- モバイル対応インターフェース - -**一括操作対応** -- ZIPファイルによるクレデンシャル一括アップロード(GCLIおよびAntigravity) -- クレデンシャルの一括有効化/無効化/削除 -- ユーザーメールの一括取得 -- 設定の一括管理 -- 全クレデンシャルタイプ統合一括アップロードインターフェース - -### 📈 使用状況モニタリング - -**リアルタイム監視** -- WebSocketリアルタイムログストリーム -- システムステータス監視 -- クレデンシャルヘルスステータス - -### 🔧 高度な設定とカスタマイズ - -**ネットワークとプロキシ設定** -- HTTP/HTTPSプロキシ対応 -- プロキシエンドポイント設定(OAuth、Google APIs、メタデータサービス) -- タイムアウトとリトライ設定 -- ネットワークエラー処理とリカバリ - -**パフォーマンスと安定性の設定** -- 429エラーの自動リトライ(間隔と回数を設定可能) -- 途切れ防止の最大リトライ回数 - -**ログとデバッグ** -- マルチレベルログシステム(DEBUG、INFO、WARNING、ERROR) -- ログファイル管理 -- リアルタイムログストリーム -- ログのダウンロードとクリア - -### 🔄 環境変数と設定管理 - -**柔軟な設定方法** -- 環境変数による設定 -- ホット設定更新(一部設定項目) -- 設定ロック(環境変数優先) - -## 対応モデル - -全モデルが100万トークンのコンテキストウィンドウに対応。各クレデンシャルファイルで1000リクエストのクォータを提供。 - -### 🤖 基本モデル -- `gemini-2.5-pro` -- `gemini-3-pro-preview` -- `gemini-3.1-pro-preview` - -### 🧠 Thinkingモデル -- `gemini-2.5-pro-high`: Thinkingモード -- `gemini-2.5-pro-low`: 低Thinkingモード -- カスタムThinkingバジェット設定に対応 -- 思考内容と最終回答の自動分離 - -### 🔍 検索拡張モデル -- `gemini-2.5-pro-search`: 検索機能統合モデル - -### 🖼️ 画像生成モデル(Antigravity) -- `gemini-3.1-flash-image`: 基本画像生成モデル -- **解像度サフィックス**: - - `-2k`: 2K解像度 - - `-4k`: 4K HD解像度 -- **アスペクト比サフィックス**: - - `-1x1`: 正方形(アバター) - - `-16x9`: 横長(デスクトップ壁紙) - - `-9x16`: 縦長(モバイル壁紙) - - `-21x9`: ウルトラワイド(ウルトラワイドモニター) - - `-4x3`: 従来のディスプレイ - - `-3x4`: 縦型ポスター -- **組み合わせ例**: - - `gemini-3.1-flash-image-4k-16x9`: 4K横長 - - `gemini-3.1-flash-image-2k-9x16`: 2K縦長 -- 比率未指定時はAPIが自動的にアスペクト比を決定 - -### 🌊 特殊機能バリアント -- **疑似ストリーミングモード**: 任意のモデル名に `-假流式` サフィックスを追加 - - 例: `gemini-2.5-pro-假流式` - - ストリーミングレスポンスが必要だがサーバーが真のストリーミングに対応していない場合に使用 -- **ストリーミング途切れ防止モード**: モデル名に `流式抗截断/` プレフィックスを追加 - - 例: `流式抗截断/gemini-2.5-pro` - - レスポンスの途切れを自動検出しリトライして完全な回答を保証 - -### 🔧 モデル機能の自動検出 -- システムがモデル名内の機能識別子を自動認識 -- 機能モード切替を透過的に処理 -- 機能の組み合わせ使用に対応 - ---- - -## 設定手順 - -1. `http://127.0.0.1:7861` にアクセス(デフォルトポート、PORT環境変数で変更可能) -2. OAuth認証フローを完了(デフォルトパスワード: `pwd`、環境変数で変更可能) - - **GCLIモード**: Google Cloud Gemini APIクレデンシャルの取得用 - - **Antigravityモード**: Google Antigravity APIクレデンシャルの取得用 -3. クライアントを設定: - -**OpenAI互換クライアント:** - - **エンドポイントアドレス**: `http://127.0.0.1:7861/v1` - - **APIキー**: `pwd`(デフォルト値、API_PASSWORDまたはPASSWORD環境変数で変更可能) - -**Geminiネイティブクライアント:** - - **エンドポイントアドレス**: `http://127.0.0.1:7861` - - **認証方式**: - - `Authorization: Bearer your_api_password` - - `x-goog-api-key: your_api_password` - - URLパラメータ: `?key=your_api_password` - -### 🌟 デュアル認証モード対応 - -**GCLI認証モード** -- 標準Google Cloud Gemini API認証 -- OAuth2.0認証フローに対応 -- 必要なGoogle Cloud APIを自動的に有効化 - -**Antigravity認証モード** -- Google Antigravity API専用認証 -- 独立したクレデンシャル管理システム -- 一括アップロードと管理に対応 -- GCLIクレデンシャルとは完全に分離 - -**統合管理インターフェース** -- 「一括アップロード」タブで両方のクレデンシャルタイプを管理 -- 上部セクション: GCLIクレデンシャル一括アップロード(青テーマ) -- 下部セクション: Antigravityクレデンシャル一括アップロード(緑テーマ) -- 各タイプ別のクレデンシャル管理タブ - -## 💾 データストレージモード - -### 🌟 ストレージバックエンド対応 - -gcli2apiは2つのストレージバックエンドに対応: **ローカルSQLite(デフォルト)** と **MongoDB(クラウド分散ストレージ)** - -### 📁 ローカルSQLiteストレージ(デフォルト) - -**デフォルトストレージ方式** -- 設定不要、すぐに利用可能 -- データはローカルSQLiteデータベースに保存 -- 単一マシンデプロイおよび個人利用に最適 -- データベースファイルの自動作成・管理 - -### 🍃 MongoDBクラウドストレージモード - -**クラウド分散ストレージソリューション** - -マルチインスタンスデプロイやクラウドストレージが必要な場合、MongoDBストレージモードを有効にできます。 - -### ⚙️ MongoDBモードの有効化 - -**ステップ1: MongoDB接続の設定** -```bash -# ローカルMongoDB -export MONGODB_URI="mongodb://localhost:27017" - -# MongoDB Atlasクラウドサービス -export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net" - -# 認証付きMongoDB -export MONGODB_URI="mongodb://admin:password@localhost:27017/admin" - -# オプション: カスタムデータベース名(デフォルト: gcli2api) -export MONGODB_DATABASE="my_gcli_db" -``` - -**ステップ2: アプリケーションの起動** -```bash -# アプリケーションがMongoDB設定を自動検出し、MongoDBストレージを使用します -python web.py -``` - -**Docker環境でのMongoDB使用** -```bash -# 単一MongoDBデプロイ -docker run -d --name gcli2api \ - -e MONGODB_URI="mongodb://mongodb:27017" \ - -e API_PASSWORD=your_password \ - --network your_network \ - ghcr.io/su-kaka/gcli2api:latest - -# MongoDB Atlasの使用 -docker run -d --name gcli2api \ - -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \ - -e API_PASSWORD=your_password \ - -p 7861:7861 \ - ghcr.io/su-kaka/gcli2api:latest -``` - -**Docker Composeの例** -```yaml -version: '3.8' - -services: - mongodb: - image: mongo:7 - container_name: gcli2api-mongodb - restart: unless-stopped - environment: - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: password123 - volumes: - - mongodb_data:/data/db - ports: - - "27017:27017" - - gcli2api: - image: ghcr.io/su-kaka/gcli2api:latest - container_name: gcli2api - restart: unless-stopped - depends_on: - - mongodb - environment: - - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin - - MONGODB_DATABASE=gcli2api - - API_PASSWORD=your_api_password - - PORT=7861 - ports: - - "7861:7861" - -volumes: - mongodb_data: -``` - - -### 🔧 高度な設定 - -**MongoDB接続の最適化** -```bash -# コネクションプールとタイムアウト設定 -export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000" - -# レプリカセット設定 -export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet" - -# リード・ライト分離設定 -export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred" -``` - -### 環境変数設定 - -**基本設定** -- `PORT`: サービスポート(デフォルト: 7861) -- `HOST`: サーバーリッスンアドレス(デフォルト: 0.0.0.0) - -**パスワード設定** -- `API_PASSWORD`: チャットAPIアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承) -- `PANEL_PASSWORD`: コントロールパネルアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承) -- `PASSWORD`: 共通パスワード、設定時に上記2つを上書き(デフォルト: pwd) - -**パフォーマンスと安定性の設定** -- `RETRY_429_ENABLED`: 429エラー自動リトライの有効化(デフォルト: true) -- `RETRY_429_MAX_RETRIES`: 429エラーの最大リトライ回数(デフォルト: 3) -- `RETRY_429_INTERVAL`: 429エラーのリトライ間隔、秒単位(デフォルト: 1.0) -- `ANTI_TRUNCATION_MAX_ATTEMPTS`: 途切れ防止の最大リトライ回数(デフォルト: 3) - -**ネットワークとプロキシ設定** -- `PROXY`: HTTP/HTTPSプロキシアドレス(形式: `http://host:port`) -- `OAUTH_PROXY_URL`: OAuth認証プロキシエンドポイント -- `GOOGLEAPIS_PROXY_URL`: Google APIsプロキシエンドポイント -- `METADATA_SERVICE_URL`: メタデータサービスプロキシエンドポイント - -**自動化設定** -- `AUTO_BAN`: クレデンシャル自動BANの有効化(デフォルト: true) -- `AUTO_LOAD_ENV_CREDS`: 起動時に環境変数クレデンシャルを自動ロード(デフォルト: false) - -**互換性設定** -- `COMPATIBILITY_MODE`: 互換モードの有効化、systemメッセージをuserメッセージに変換(デフォルト: false) - -**ログ設定** -- `LOG_LEVEL`: ログレベル(DEBUG/INFO/WARNING/ERROR、デフォルト: INFO) -- `LOG_FILE`: ログファイルパス(デフォルト: log.txt) - -**ストレージ設定** - -**SQLite設定(デフォルト)** -- 設定不要、自動的にローカルSQLiteデータベースを使用 -- データベースファイルはプロジェクトディレクトリに自動作成 - -**MongoDB設定(オプションのクラウドストレージ)** -- `MONGODB_URI`: MongoDB接続文字列(設定時にMongoDBモードを有効化) -- `MONGODB_DATABASE`: MongoDBデータベース名(デフォルト: gcli2api) - -**Docker使用例** -```bash -# 共通パスワードを使用 -docker run -d --name gcli2api \ - -e PASSWORD=mypassword \ - -e PORT=7861 \ - ghcr.io/su-kaka/gcli2api:latest - -# 個別パスワードを使用 -docker run -d --name gcli2api \ - -e API_PASSWORD=my_api_password \ - -e PANEL_PASSWORD=my_panel_password \ - -e PORT=7861 \ - ghcr.io/su-kaka/gcli2api:latest -``` - -注意: クレデンシャル環境変数が設定されている場合、システムは環境変数のクレデンシャルを優先的に使用し、`creds` ディレクトリ内のファイルを無視します。 - -### API使用方法 - -本サービスは複数の完全なAPIエンドポイントセットに対応しています: - -#### 1. OpenAI互換エンドポイント(GCLI) - -**エンドポイント:** `/v1/chat/completions` -**認証:** `Authorization: Bearer your_api_password` - -2つのリクエストフォーマットに対応し、自動検出・処理を行います: - -**OpenAIフォーマット:** -```json -{ - "model": "gemini-2.5-pro", - "messages": [ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "Hello"} - ], - "temperature": 0.7, - "stream": true -} -``` - -**Geminiネイティブフォーマット:** -```json -{ - "model": "gemini-2.5-pro", - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ], - "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]}, - "generationConfig": { - "temperature": 0.7 - } -} -``` - -#### 2. Geminiネイティブエンドポイント(GCLI) - -**非ストリーミングエンドポイント:** `/v1/models/{model}:generateContent` -**ストリーミングエンドポイント:** `/v1/models/{model}:streamGenerateContent` -**モデル一覧:** `/v1/models` - -**認証方式(いずれか1つを選択):** -- `Authorization: Bearer your_api_password` -- `x-goog-api-key: your_api_password` -- URLパラメータ: `?key=your_api_password` - -**リクエスト例:** -```bash -# x-goog-api-keyヘッダーを使用 -curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \ - -H "x-goog-api-key: your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' - -# URLパラメータを使用 -curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' -``` - -#### 3. Claude APIフォーマットエンドポイント - -**エンドポイント:** `/v1/messages` -**認証:** `x-api-key: your_api_password` または `Authorization: Bearer your_api_password` - -**リクエスト例:** -```bash -curl -X POST "http://127.0.0.1:7861/v1/messages" \ - -H "x-api-key: your_api_password" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gemini-2.5-pro", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, Claude!"} - ] - }' -``` - -**systemパラメータの対応:** -```json -{ - "model": "gemini-2.5-pro", - "max_tokens": 1024, - "system": "You are a helpful assistant", - "messages": [ - {"role": "user", "content": "Hello"} - ] -} -``` - -**注意事項:** -- Claude APIフォーマット仕様に完全互換 -- バックエンド呼び出し時にGeminiフォーマットへ自動変換 -- すべてのClaude標準パラメータに対応 -- レスポンスフォーマットはClaude API仕様に準拠 - -#### 4. Antigravity APIエンドポイント - -**OpenAI、Gemini、Claudeの3フォーマットに対応** - -##### Antigravity OpenAIフォーマットエンドポイント - -**エンドポイント:** `/antigravity/v1/chat/completions` -**認証:** `Authorization: Bearer your_api_password` - -**リクエスト例:** -```bash -curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \ - -H "Authorization: Bearer your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5", - "messages": [ - {"role": "user", "content": "Hello"} - ], - "stream": true - }' -``` - -##### Antigravity Geminiフォーマットエンドポイント - -**非ストリーミングエンドポイント:** `/antigravity/v1/models/{model}:generateContent` -**ストリーミングエンドポイント:** `/antigravity/v1/models/{model}:streamGenerateContent` - -**認証方式(いずれか1つを選択):** -- `Authorization: Bearer your_api_password` -- `x-goog-api-key: your_api_password` -- URLパラメータ: `?key=your_api_password` - -**リクエスト例:** -```bash -# Geminiフォーマット非ストリーミングリクエスト -curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \ - -H "x-goog-api-key: your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ], - "generationConfig": { - "temperature": 0.7 - } - }' - -# Geminiフォーマットストリーミングリクエスト -curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] - }' -``` - -##### Antigravity Claudeフォーマットエンドポイント - -**エンドポイント:** `/antigravity/v1/messages` -**認証:** `x-api-key: your_api_password` - -**リクエスト例:** -```bash -curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \ - -H "x-api-key: your_api_password" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello"} - ] - }' -``` - -**対応Antigravityモデル:** -- Claudeシリーズ: `claude-sonnet-4-5`、`claude-opus-4-5` など -- Geminiシリーズ: `gemini-2.5-flash`、`gemini-2.5-pro` など -- Thinkingモデルに自動対応 - -**Geminiネイティブの例:** -```python -from io import BytesIO -from PIL import Image -from google.genai import Client -from google.genai.types import HttpOptions -from google.genai import types -# クライアントは環境変数 `GEMINI_API_KEY` からAPIキーを取得します。 - -client = Client( - api_key="pwd", - http_options=HttpOptions(base_url="http://127.0.0.1:7861"), - ) - -prompt = ( - """ - 猫を描いて - """ -) - -response = client.models.generate_content( - model="gemini-3.1-flash-image", - contents=[prompt], - config=types.GenerateContentConfig( - image_config=types.ImageConfig( - aspect_ratio="16:9", - ) - ) -) -for part in response.candidates[0].content.parts: - if part.text is not None: - print(part.text) - elif part.inline_data is not None: - image = Image.open(BytesIO(part.inline_data.data)) - image.save("generated_image.png") - -``` - -**注意事項:** -- OpenAIエンドポイントはOpenAI互換フォーマットを返します -- GeminiエンドポイントはGeminiネイティブフォーマットを返します -- 両エンドポイントとも同一のAPIパスワードを使用 - -## 📋 完全なAPIリファレンス - -### Webコンソール API - -**認証エンドポイント** -- `POST /auth/login` - ユーザーログイン -- `POST /auth/start` - OAuth認証の開始(GCLIおよびAntigravityモード対応) -- `POST /auth/callback` - OAuthコールバックの処理 -- `POST /auth/callback-url` - コールバックURLから直接認証を完了 -- `GET /auth/status/{project_id}` - 認証ステータスの確認 - -**クレデンシャル管理エンドポイント**(`mode=geminicli` または `mode=antigravity` パラメータ対応) -- `POST /creds/upload` - クレデンシャルファイルの一括アップロード(JSONおよびZIP対応) -- `GET /creds/status` - クレデンシャルステータス一覧の取得(ページネーションとフィルタリング対応) -- `GET /creds/detail/{filename}` - 単一クレデンシャルの詳細取得 -- `POST /creds/action` - 単一クレデンシャル操作(有効化/無効化/削除) -- `POST /creds/batch-action` - クレデンシャルの一括操作 -- `GET /creds/download/{filename}` - 単一クレデンシャルファイルのダウンロード -- `GET /creds/download-all` - 全クレデンシャルの一括ダウンロード -- `POST /creds/fetch-email/{filename}` - ユーザーメールの取得 -- `POST /creds/refresh-all-emails` - ユーザーメールの一括更新 -- `POST /creds/deduplicate-by-email` - メールによるクレデンシャルの重複排除 -- `POST /creds/verify-project/{filename}` - クレデンシャルのProject ID検証 -- `GET /creds/quota/{filename}` - クレデンシャルのクォータ情報取得(Antigravityのみ) - -**設定管理エンドポイント** -- `GET /config/get` - 現在の設定の取得 -- `POST /config/save` - 設定の保存 - -**ログ管理エンドポイント** -- `POST /logs/clear` - ログのクリア -- `GET /logs/download` - ログファイルのダウンロード -- `WebSocket /logs/stream` - リアルタイムログストリーム - -**バージョン情報エンドポイント** -- `GET /version/info` - バージョン情報の取得(オプション `check_update=true` パラメータで更新確認) - -### チャットAPI機能 - -**マルチモーダル対応** -```json -{ - "model": "gemini-2.5-pro", - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "この画像を説明してください"}, - { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..." - } - } - ] - } - ] -} -``` - -**Thinkingモード対応** -```json -{ - "model": "gemini-2.5-pro-high", - "messages": [ - {"role": "user", "content": "複雑な数学の問題"} - ] -} -``` - -レスポンスには分離された思考内容が含まれます: -```json -{ - "choices": [{ - "message": { - "role": "assistant", - "content": "最終回答", - "reasoning_content": "詳細な思考プロセス..." - } - }] -} -``` - -**ストリーミング途切れ防止の使用方法** -```json -{ - "model": "流式抗截断/gemini-2.5-pro", - "messages": [ - {"role": "user", "content": "長い記事を書いてください"} - ], - "stream": true -} -``` - -**互換モード** -```bash -# 互換モードを有効化 -export COMPATIBILITY_MODE=true -``` -このモードでは、すべての `system` メッセージが `user` メッセージに変換され、特定のクライアントとの互換性が向上します。 - ---- - -## 💬 コミュニティ - -QQグループへの参加をお待ちしています! - -**QQグループ: 1083250744** - -QQグループQRコード - ---- - -## ライセンスと免責事項 - -本プロジェクトは学習および研究目的のみに使用できます。本プロジェクトの使用は、以下に同意したことを意味します: -- 本プロジェクトをいかなる商用目的にも使用しないこと -- 本プロジェクトの使用に伴うすべてのリスクと責任を負うこと -- 関連するサービス利用規約および法的規制を遵守すること - -プロジェクトの作者は、本プロジェクトの使用から生じるいかなる直接的または間接的な損害についても責任を負いません。 - -## ⚠️ ライセンスについて - -**本プロジェクトはCooperative Non-Commercial License (CNC-1.0) の下でライセンスされています** - -これは厳格な非商用オープンソースライセンスです。詳細は [LICENSE](../LICENSE) ファイルをご参照ください。 - -### ✅ 許可される用途: -- 個人の学習、研究、教育目的 -- 非営利団体での利用 -- オープンソースプロジェクトへの統合(同一ライセンスの遵守が必要) -- 学術研究および論文発表 - -### ❌ 禁止される用途: -- あらゆる形態の商用利用 -- 年間売上が100万ドルを超える企業での利用 -- ベンチャーキャピタルの出資を受けた企業または上場企業 -- 有料サービスまたは製品の提供 -- 商業的な競合利用 diff --git a/front/common.js b/front/common.js deleted file mode 100644 index 439c0bb63a9f4867df3a8757add0343aa2fa6e67..0000000000000000000000000000000000000000 --- a/front/common.js +++ /dev/null @@ -1,3225 +0,0 @@ -// ===================================================================== -// GCLI2API 控制面板公共JavaScript模块 -// ===================================================================== - -// ===================================================================== -// 全局状态管理 -// ===================================================================== -const AppState = { - // 认证相关 - authToken: '', - authInProgress: false, - currentProjectId: '', - - // Antigravity认证 - antigravityAuthState: null, - antigravityAuthInProgress: false, - - // 凭证管理 - creds: createCredsManager('normal'), - antigravityCreds: createCredsManager('antigravity'), - - // 文件上传 - uploadFiles: createUploadManager('normal'), - antigravityUploadFiles: createUploadManager('antigravity'), - - // 配置管理 - currentConfig: {}, - envLockedFields: new Set(), - - // 日志管理 - logWebSocket: null, - allLogs: [], - filteredLogs: [], - currentLogFilter: 'all', - - // 使用统计 - usageStatsData: {}, - - // 冷却倒计时 - cooldownTimerInterval: null -}; - -// ===================================================================== -// 凭证管理器工厂 -// ===================================================================== -function createCredsManager(type) { - const modeParam = type === 'antigravity' ? 'mode=antigravity' : 'mode=geminicli'; - - return { - type: type, - data: {}, - filteredData: {}, - currentPage: 1, - pageSize: 20, - selectedFiles: new Set(), - totalCount: 0, - currentStatusFilter: 'all', - currentErrorCodeFilter: 'all', - currentCooldownFilter: 'all', - currentPreviewFilter: 'all', - currentTierFilter: 'all', - statsData: { total: 0, normal: 0, disabled: 0 }, - - // API端点 - getEndpoint: (action) => { - const endpoints = { - status: `./creds/status`, - action: `./creds/action`, - batchAction: `./creds/batch-action`, - download: `./creds/download`, - downloadAll: `./creds/download-all`, - detail: `./creds/detail`, - fetchEmail: `./creds/fetch-email`, - refreshAllEmails: `./creds/refresh-all-emails`, - deduplicate: `./creds/deduplicate-by-email`, - verifyProject: `./creds/verify-project`, - quota: `./creds/quota` - }; - return endpoints[action] || ''; - }, - - // 获取mode参数 - getModeParam: () => modeParam, - - // DOM元素ID前缀 - getElementId: (suffix) => { - // 普通凭证的ID首字母小写,如 credsLoading - // Antigravity的ID是 antigravity + 首字母大写,如 antigravityCredsLoading - if (type === 'antigravity') { - return 'antigravity' + suffix.charAt(0).toUpperCase() + suffix.slice(1); - } - return suffix.charAt(0).toLowerCase() + suffix.slice(1); - }, - - // 刷新凭证列表 - async refresh() { - const loading = document.getElementById(this.getElementId('CredsLoading')); - const list = document.getElementById(this.getElementId('CredsList')); - - try { - loading.style.display = 'block'; - list.innerHTML = ''; - - const offset = (this.currentPage - 1) * this.pageSize; - const errorCodeFilter = this.currentErrorCodeFilter || 'all'; - const cooldownFilter = this.currentCooldownFilter || 'all'; - const previewFilter = this.currentPreviewFilter || 'all'; - const tierFilter = this.currentTierFilter || 'all'; - const response = await fetch( - `${this.getEndpoint('status')}?offset=${offset}&limit=${this.pageSize}&status_filter=${this.currentStatusFilter}&error_code_filter=${errorCodeFilter}&cooldown_filter=${cooldownFilter}&preview_filter=${previewFilter}&tier_filter=${tierFilter}&${this.getModeParam()}`, - { headers: getAuthHeaders() } - ); - - const data = await response.json(); - - if (response.ok) { - this.data = {}; - data.items.forEach(item => { - this.data[item.filename] = { - filename: item.filename, - status: { - disabled: item.disabled, - error_codes: item.error_codes || [], - last_success: item.last_success, - }, - user_email: item.user_email, - model_cooldowns: item.model_cooldowns || {}, - preview: item.preview, - tier: item.tier || 'pro', - enable_credit: !!item.enable_credit - }; - }); - - this.totalCount = data.total; - // 使用后端返回的全局统计数据 - if (data.stats) { - this.statsData = data.stats; - } else { - // 兼容旧版本后端 - this.calculateStats(); - } - this.updateStatsDisplay(); - this.filteredData = this.data; - this.renderList(); - this.updatePagination(); - - let msg = `已加载 ${data.total} 个${type === 'antigravity' ? 'Antigravity' : ''}凭证文件`; - if (this.currentStatusFilter !== 'all') { - msg += ` (筛选: ${this.currentStatusFilter === 'enabled' ? '仅启用' : '仅禁用'})`; - } - showStatus(msg, 'success'); - } else { - showStatus(`加载失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - loading.style.display = 'none'; - } - }, - - // 计算统计数据(仅用于兼容旧版本后端) - calculateStats() { - this.statsData = { total: this.totalCount, normal: 0, disabled: 0 }; - Object.values(this.data).forEach(credInfo => { - if (credInfo.status.disabled) { - this.statsData.disabled++; - } else { - this.statsData.normal++; - } - }); - }, - - // 更新统计显示 - updateStatsDisplay() { - document.getElementById(this.getElementId('StatTotal')).textContent = this.statsData.total; - document.getElementById(this.getElementId('StatNormal')).textContent = this.statsData.normal; - document.getElementById(this.getElementId('StatDisabled')).textContent = this.statsData.disabled; - }, - - // 渲染凭证列表 - renderList() { - const list = document.getElementById(this.getElementId('CredsList')); - list.innerHTML = ''; - - const entries = Object.entries(this.filteredData); - - if (entries.length === 0) { - const msg = this.totalCount === 0 ? '暂无凭证文件' : '当前筛选条件下暂无数据'; - list.innerHTML = `

${msg}

`; - document.getElementById(this.getElementId('PaginationContainer')).style.display = 'none'; - return; - } - - entries.forEach(([, credInfo]) => { - list.appendChild(createCredCard(credInfo, this)); - }); - - document.getElementById(this.getElementId('PaginationContainer')).style.display = - this.getTotalPages() > 1 ? 'flex' : 'none'; - this.updateBatchControls(); - }, - - // 获取总页数 - getTotalPages() { - return Math.ceil(this.totalCount / this.pageSize); - }, - - // 更新分页信息 - updatePagination() { - const totalPages = this.getTotalPages(); - const startItem = (this.currentPage - 1) * this.pageSize + 1; - const endItem = Math.min(this.currentPage * this.pageSize, this.totalCount); - - document.getElementById(this.getElementId('PaginationInfo')).textContent = - `第 ${this.currentPage} 页,共 ${totalPages} 页 (显示 ${startItem}-${endItem},共 ${this.totalCount} 项)`; - - document.getElementById(this.getElementId('PrevPageBtn')).disabled = this.currentPage <= 1; - document.getElementById(this.getElementId('NextPageBtn')).disabled = this.currentPage >= totalPages; - }, - - // 切换页面 - changePage(direction) { - const newPage = this.currentPage + direction; - if (newPage >= 1 && newPage <= this.getTotalPages()) { - this.currentPage = newPage; - this.refresh(); - } - }, - - // 改变每页大小 - changePageSize() { - this.pageSize = parseInt(document.getElementById(this.getElementId('PageSizeSelect')).value); - this.currentPage = 1; - this.refresh(); - }, - - // 应用状态筛选 - applyStatusFilter() { - this.currentStatusFilter = document.getElementById(this.getElementId('StatusFilter')).value; - const errorCodeFilterEl = document.getElementById(this.getElementId('ErrorCodeFilter')); - const cooldownFilterEl = document.getElementById(this.getElementId('CooldownFilter')); - const previewFilterEl = document.getElementById(this.getElementId('PreviewFilter')); - const tierFilterEl = document.getElementById(this.getElementId('TierFilter')); - this.currentErrorCodeFilter = errorCodeFilterEl ? errorCodeFilterEl.value : 'all'; - this.currentCooldownFilter = cooldownFilterEl ? cooldownFilterEl.value : 'all'; - this.currentPreviewFilter = previewFilterEl ? previewFilterEl.value : 'all'; - this.currentTierFilter = tierFilterEl ? tierFilterEl.value : 'all'; - this.currentPage = 1; - this.refresh(); - }, - - // 更新批量控件 - updateBatchControls() { - const selectedCount = this.selectedFiles.size; - document.getElementById(this.getElementId('SelectedCount')).textContent = `已选择 ${selectedCount} 项`; - - const batchBtnNames = ['Enable', 'Disable', 'Delete', 'Verify', 'Preview']; - if (this.type === 'antigravity') { - batchBtnNames.push('EnableCredit'); - batchBtnNames.push('DisableCredit'); - } - const batchBtns = batchBtnNames.map(action => - document.getElementById(this.getElementId(`Batch${action}Btn`)) - ); - batchBtns.forEach(btn => btn && (btn.disabled = selectedCount === 0)); - - const selectAllCheckbox = document.getElementById(this.getElementId('SelectAllCheckbox')); - if (!selectAllCheckbox) return; - - const checkboxes = document.querySelectorAll(`.${this.getElementId('file-checkbox')}`); - const currentPageSelectedCount = Array.from(checkboxes) - .filter(cb => this.selectedFiles.has(cb.getAttribute('data-filename'))).length; - - if (currentPageSelectedCount === 0) { - selectAllCheckbox.indeterminate = false; - selectAllCheckbox.checked = false; - } else if (currentPageSelectedCount === checkboxes.length) { - selectAllCheckbox.indeterminate = false; - selectAllCheckbox.checked = true; - } else { - selectAllCheckbox.indeterminate = true; - } - - checkboxes.forEach(cb => { - cb.checked = this.selectedFiles.has(cb.getAttribute('data-filename')); - }); - }, - - // 凭证操作 - async action(filename, action) { - try { - const response = await fetch(`${this.getEndpoint('action')}?${this.getModeParam()}`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ filename, action }) - }); - - const data = await response.json(); - - if (response.ok) { - showStatus(data.message || `操作成功: ${action}`, 'success'); - await this.refresh(); - } else { - showStatus(`操作失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } - }, - - // 批量操作 - async batchAction(action) { - const selectedFiles = Array.from(this.selectedFiles); - - if (selectedFiles.length === 0) { - showStatus('请先选择要操作的文件', 'error'); - return; - } - - const actionNames = { - enable: '启用', - disable: '禁用', - delete: '删除', - enable_credit: '开启积分', - disable_credit: '关闭积分' - }; - const actionLabel = actionNames[action] || action; - const confirmMsg = action === 'delete' - ? `确定要删除选中的 ${selectedFiles.length} 个文件吗?\n注意:此操作不可恢复!` - : `确定要${actionLabel}选中的 ${selectedFiles.length} 个文件吗?`; - - if (!confirm(confirmMsg)) return; - - try { - showStatus(`正在执行批量${actionLabel}操作...`, 'info'); - - const response = await fetch(`${this.getEndpoint('batchAction')}?${this.getModeParam()}`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ action, filenames: selectedFiles }) - }); - - const data = await response.json(); - - if (response.ok) { - const successCount = data.success_count || data.succeeded; - showStatus(`批量操作完成:成功处理 ${successCount}/${selectedFiles.length} 个文件`, 'success'); - this.selectedFiles.clear(); - this.updateBatchControls(); - await this.refresh(); - } else { - showStatus(`批量操作失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`批量操作网络错误: ${error.message}`, 'error'); - } - } - }; -} - -// ===================================================================== -// 文件上传管理器工厂 -// ===================================================================== -function createUploadManager(type) { - const modeParam = type === 'antigravity' ? 'mode=antigravity' : 'mode=geminicli'; - const endpoint = `./creds/upload?${modeParam}`; - - return { - type: type, - selectedFiles: [], - - getElementId: (suffix) => { - // 普通上传的ID首字母小写,如 fileList - // Antigravity的ID是 antigravity + 首字母大写,如 antigravityFileList - if (type === 'antigravity') { - return 'antigravity' + suffix.charAt(0).toUpperCase() + suffix.slice(1); - } - return suffix.charAt(0).toLowerCase() + suffix.slice(1); - }, - - handleFileSelect(event) { - this.addFiles(Array.from(event.target.files)); - }, - - addFiles(files) { - files.forEach(file => { - const isValid = file.type === 'application/json' || file.name.endsWith('.json') || - file.type === 'application/zip' || file.name.endsWith('.zip'); - - if (isValid) { - if (!this.selectedFiles.find(f => f.name === file.name && f.size === file.size)) { - this.selectedFiles.push(file); - } - } else { - showStatus(`文件 ${file.name} 格式不支持,只支持JSON和ZIP文件`, 'error'); - } - }); - this.updateFileList(); - }, - - updateFileList() { - const list = document.getElementById(this.getElementId('FileList')); - const section = document.getElementById(this.getElementId('FileListSection')); - - if (!list || !section) { - console.warn('File list elements not found:', this.getElementId('FileList')); - return; - } - - if (this.selectedFiles.length === 0) { - section.classList.add('hidden'); - return; - } - - section.classList.remove('hidden'); - list.innerHTML = ''; - - this.selectedFiles.forEach((file, index) => { - const isZip = file.name.endsWith('.zip'); - const fileIcon = isZip ? '📦' : '📄'; - const fileType = isZip ? ' (ZIP压缩包)' : ' (JSON文件)'; - - const fileItem = document.createElement('div'); - fileItem.className = 'file-item'; - fileItem.innerHTML = ` -
- ${fileIcon} ${file.name} - (${formatFileSize(file.size)}${fileType}) -
- - `; - list.appendChild(fileItem); - }); - }, - - removeFile(index) { - this.selectedFiles.splice(index, 1); - this.updateFileList(); - }, - - clearFiles() { - this.selectedFiles = []; - this.updateFileList(); - }, - - async upload() { - if (this.selectedFiles.length === 0) { - showStatus('请选择要上传的文件', 'error'); - return; - } - - const progressSection = document.getElementById(this.getElementId('UploadProgressSection')); - const progressFill = document.getElementById(this.getElementId('ProgressFill')); - const progressText = document.getElementById(this.getElementId('ProgressText')); - - progressSection.classList.remove('hidden'); - - const formData = new FormData(); - this.selectedFiles.forEach(file => formData.append('files', file)); - - if (this.selectedFiles.some(f => f.name.endsWith('.zip'))) { - showStatus('正在上传并解压ZIP文件...', 'info'); - } - - try { - const xhr = new XMLHttpRequest(); - xhr.timeout = 300000; // 5分钟 - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - progressFill.style.width = percent + '%'; - progressText.textContent = Math.round(percent) + '%'; - } - }; - - xhr.onload = () => { - if (xhr.status === 200) { - try { - const data = JSON.parse(xhr.responseText); - showStatus(`成功上传 ${data.uploaded_count} 个${type === 'antigravity' ? 'Antigravity' : ''}文件`, 'success'); - this.clearFiles(); - progressSection.classList.add('hidden'); - } catch (e) { - showStatus('上传失败: 服务器响应格式错误', 'error'); - } - } else { - try { - const error = JSON.parse(xhr.responseText); - showStatus(`上传失败: ${error.detail || error.error || '未知错误'}`, 'error'); - } catch (e) { - showStatus(`上传失败: HTTP ${xhr.status}`, 'error'); - } - } - }; - - xhr.onerror = () => { - showStatus(`上传失败:连接中断 - 可能原因:文件过多(${this.selectedFiles.length}个)或网络不稳定。建议分批上传。`, 'error'); - progressSection.classList.add('hidden'); - }; - - xhr.ontimeout = () => { - showStatus('上传失败:请求超时 - 文件处理时间过长,请减少文件数量或检查网络连接', 'error'); - progressSection.classList.add('hidden'); - }; - - xhr.open('POST', endpoint); - xhr.setRequestHeader('Authorization', `Bearer ${AppState.authToken}`); - xhr.send(formData); - } catch (error) { - showStatus(`上传失败: ${error.message}`, 'error'); - } - } - }; -} - -// ===================================================================== -// 工具函数 -// ===================================================================== -function showStatus(message, type = 'info') { - const statusSection = document.getElementById('statusSection'); - if (statusSection) { - // 清除之前的定时器 - if (window._statusTimeout) { - clearTimeout(window._statusTimeout); - } - - // 创建新的 toast - statusSection.innerHTML = `
${message}
`; - const statusDiv = statusSection.querySelector('.status'); - - // 强制重绘以触发动画 - statusDiv.offsetHeight; - statusDiv.classList.add('show'); - - // 3秒后淡出并移除 - window._statusTimeout = setTimeout(() => { - statusDiv.classList.add('fade-out'); - setTimeout(() => { - statusSection.innerHTML = ''; - }, 300); // 等待淡出动画完成 - }, 3000); - } else { - showMessageModal('提示', message, 'info'); - } -} - -// 将文本中的链接转换为可点击的HTML链接 -function linkifyText(text) { - if (!text) return text; - - // 匹配 http://, https:// 和 www. 开头的链接,排除常见的标点符号 - const urlPattern = /(https?:\/\/[^\s"'<>()[\]{}]+)|(www\.[^\s"'<>()[\]{}]+)/gi; - - return text.replace(urlPattern, function(url) { - let href = url; - // 如果是 www. 开头,添加 https:// - if (url.startsWith('www.')) { - href = 'https://' + url; - } - - return `${url}`; - }); -} - -// 显示增强的消息模态框(支持链接高亮) -function showMessageModal(title, message, type = 'info') { - // 创建模态框 - const modal = document.createElement('div'); - modal.className = 'message-modal-overlay'; - modal.innerHTML = ` -
-
-

${title}

- -
-
- ${linkifyText(message).replace(/\n/g, '
')} -
- -
- `; - - // 添加到页面 - document.body.appendChild(modal); - - // 点击遮罩层关闭 - modal.addEventListener('click', function(e) { - if (e.target === modal) { - modal.remove(); - } - }); - - // ESC 键关闭 - const escHandler = function(e) { - if (e.key === 'Escape') { - modal.remove(); - document.removeEventListener('keydown', escHandler); - } - }; - document.addEventListener('keydown', escHandler); -} - -function getAuthHeaders() { - return { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${AppState.authToken}` - }; -} - -function formatFileSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB'; - return Math.round(bytes / (1024 * 1024)) + ' MB'; -} - -function formatCooldownTime(remainingSeconds) { - const hours = Math.floor(remainingSeconds / 3600); - const minutes = Math.floor((remainingSeconds % 3600) / 60); - const seconds = remainingSeconds % 60; - - if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; - if (minutes > 0) return `${minutes}m ${seconds}s`; - return `${seconds}s`; -} - -// ===================================================================== -// 凭证卡片创建(通用) -// ===================================================================== -function createCredCard(credInfo, manager) { - const div = document.createElement('div'); - const { status, filename } = credInfo; - const managerType = manager.type; - - // 卡片样式 - div.className = status.disabled ? 'cred-card disabled' : 'cred-card'; - - // 状态徽章 - let statusBadges = ''; - statusBadges += status.disabled - ? '已禁用' - : '已启用'; - - if (status.error_codes && status.error_codes.length > 0) { - statusBadges += `错误码: ${status.error_codes.join(', ')}`; - const autoBan = status.error_codes.filter(c => c === 400 || c === 403); - if (autoBan.length > 0 && status.disabled) { - statusBadges += 'AUTO_BAN'; - } - } else { - statusBadges += '无错误'; - } - - // Preview状态显示 (仅对geminicli模式显示) - if (managerType !== 'antigravity' && credInfo.preview !== undefined) { - if (credInfo.preview) { - statusBadges += 'Preview: ON'; - } else { - statusBadges += 'Preview: OFF'; - } - } - - // tier 状态显示 (geminicli 和 antigravity 都显示) - const tier = (credInfo.tier || 'pro').toString().toLowerCase(); - const tierLabel = tier.toUpperCase(); - const tierColor = tier === 'ultra' ? '#ff9800' : (tier === 'free' ? '#607d8b' : '#2e7d32'); - statusBadges += `Tier: ${tierLabel}`; - - // Credit 状态显示(仅 antigravity) - if (managerType === 'antigravity') { - if (credInfo.enable_credit) { - statusBadges += 'Credit: ON'; - } else { - statusBadges += 'Credit: OFF'; - } - } - - // 模型级冷却状态 - if (credInfo.model_cooldowns && Object.keys(credInfo.model_cooldowns).length > 0) { - const currentTime = Date.now() / 1000; - const activeCooldowns = Object.entries(credInfo.model_cooldowns) - .filter(([, until]) => until > currentTime) - .map(([model, until]) => { - const remaining = Math.max(0, Math.floor(until - currentTime)); - const shortModel = model.replace('gemini-', '').replace('-exp', '') - .replace('2.0-', '2-').replace('1.5-', '1.5-'); - return { - model: shortModel, - time: formatCooldownTime(remaining).replace(/s$/, '').replace(/ /g, ''), - fullModel: model - }; - }); - - if (activeCooldowns.length > 0) { - activeCooldowns.slice(0, 2).forEach(item => { - statusBadges += `⏰ ${item.model}: ${item.time}`; - }); - if (activeCooldowns.length > 2) { - const remaining = activeCooldowns.length - 2; - const remainingModels = activeCooldowns.slice(2).map(i => `${i.fullModel}: ${i.time}`).join('\n'); - statusBadges += `+${remaining}`; - } - } - } - - // 路径ID - const pathId = (managerType === 'antigravity' ? 'ag_' : '') + btoa(encodeURIComponent(filename)).replace(/[+/=]/g, '_'); - - // 操作按钮 - const actionButtons = ` - ${status.disabled - ? `` - : `` - } - - - - ${managerType === 'antigravity' ? `` : ''} - ${managerType === 'antigravity' ? (credInfo.enable_credit - ? `` - : `` - ) : ''} - ${managerType !== 'antigravity' ? `` : ''} - - - - - `; - - // 邮箱信息 - const emailInfo = credInfo.user_email - ? `
${credInfo.user_email}
` - : '
未获取邮箱
'; - - const checkboxClass = manager.getElementId('file-checkbox'); - - div.innerHTML = ` -
-
- -
-
${filename}
- ${emailInfo} -
-
-
${statusBadges}
-
-
${actionButtons}
-
-
点击"查看内容"按钮加载文件详情...
-
-
-
点击"查看报错"按钮加载报错信息...
-
- ${managerType === 'antigravity' ? ` - - ` : ''} - `; - - // 添加事件监听 - div.querySelectorAll('[data-filename][data-action]').forEach(button => { - button.addEventListener('click', function () { - const fn = this.getAttribute('data-filename'); - const action = this.getAttribute('data-action'); - if (action === 'delete') { - if (confirm(`确定要删除${managerType === 'antigravity' ? ' Antigravity ' : ''}凭证文件吗?\n${fn}`)) { - manager.action(fn, action); - } - } else { - manager.action(fn, action); - } - }); - }); - - return div; -} - -// ===================================================================== -// 凭证详情切换 -// ===================================================================== -async function toggleCredDetails(pathId) { - await toggleCredDetailsCommon(pathId, AppState.creds); -} - -async function toggleAntigravityCredDetails(pathId) { - await toggleCredDetailsCommon(pathId, AppState.antigravityCreds); -} - -async function toggleCredDetailsCommon(pathId, manager) { - const details = document.getElementById('details-' + pathId); - if (!details) return; - - const isShowing = details.classList.toggle('show'); - - if (isShowing) { - const contentDiv = details.querySelector('.cred-content'); - const filename = contentDiv.getAttribute('data-filename'); - const loaded = contentDiv.getAttribute('data-loaded'); - - if (loaded === 'false' && filename) { - contentDiv.textContent = '正在加载文件内容...'; - - try { - const modeParam = manager.type === 'antigravity' ? 'mode=antigravity' : 'mode=geminicli'; - const endpoint = `./creds/detail/${encodeURIComponent(filename)}?${modeParam}`; - - const response = await fetch(endpoint, { headers: getAuthHeaders() }); - - const data = await response.json(); - if (response.ok && data.content) { - contentDiv.textContent = JSON.stringify(data.content, null, 2); - contentDiv.setAttribute('data-loaded', 'true'); - } else { - contentDiv.textContent = '无法加载文件内容: ' + (data.error || data.detail || '未知错误'); - } - } catch (error) { - contentDiv.textContent = '加载文件内容失败: ' + error.message; - } - } - } -} - -// ===================================================================== -// 登录相关函数 -// ===================================================================== -async function login() { - const password = document.getElementById('loginPassword').value; - - if (!password) { - showStatus('请输入密码', 'error'); - return; - } - - try { - const response = await fetch('./auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }) - }); - - const data = await response.json(); - - if (response.ok) { - AppState.authToken = data.token; - localStorage.setItem('gcli2api_auth_token', AppState.authToken); - document.getElementById('loginSection').classList.add('hidden'); - document.getElementById('mainSection').classList.remove('hidden'); - showStatus('登录成功', 'success'); - // 显示面板后初始化滑块 - requestAnimationFrame(() => initTabSlider()); - } else { - showStatus(`登录失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -async function autoLogin() { - const savedToken = localStorage.getItem('gcli2api_auth_token'); - if (!savedToken) return false; - - AppState.authToken = savedToken; - - try { - const response = await fetch('./config/get', { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${AppState.authToken}` - } - }); - - if (response.ok) { - document.getElementById('loginSection').classList.add('hidden'); - document.getElementById('mainSection').classList.remove('hidden'); - showStatus('自动登录成功', 'success'); - // 显示面板后初始化滑块 - requestAnimationFrame(() => initTabSlider()); - return true; - } else if (response.status === 401) { - localStorage.removeItem('gcli2api_auth_token'); - AppState.authToken = ''; - return false; - } - return false; - } catch (error) { - return false; - } -} - -function logout() { - localStorage.removeItem('gcli2api_auth_token'); - AppState.authToken = ''; - document.getElementById('loginSection').classList.remove('hidden'); - document.getElementById('mainSection').classList.add('hidden'); - showStatus('已退出登录', 'info'); - const passwordInput = document.getElementById('loginPassword'); - if (passwordInput) passwordInput.value = ''; -} - -function handlePasswordEnter(event) { - if (event.key === 'Enter') login(); -} - -// ===================================================================== -// 标签页切换 -// ===================================================================== - -// 更新滑块位置 -function updateTabSlider(targetTab, animate = true) { - const slider = document.querySelector('.tab-slider'); - const tabs = document.querySelector('.tabs'); - if (!slider || !tabs || !targetTab) return; - - // 获取按钮位置和容器宽度 - const tabLeft = targetTab.offsetLeft; - const tabWidth = targetTab.offsetWidth; - const tabsWidth = tabs.scrollWidth; - - // 使用 left 和 right 同时控制,确保动画同步 - const rightValue = tabsWidth - tabLeft - tabWidth; - - if (animate) { - slider.style.left = `${tabLeft}px`; - slider.style.right = `${rightValue}px`; - } else { - // 首次加载时不使用动画 - slider.style.transition = 'none'; - slider.style.left = `${tabLeft}px`; - slider.style.right = `${rightValue}px`; - // 强制重绘后恢复过渡 - slider.offsetHeight; - slider.style.transition = ''; - } -} - -// 初始化滑块位置 -function initTabSlider() { - const activeTab = document.querySelector('.tab.active'); - if (activeTab) { - updateTabSlider(activeTab, false); - } -} - -// 页面加载和窗口大小变化时初始化滑块 -document.addEventListener('DOMContentLoaded', initTabSlider); -window.addEventListener('resize', () => { - const activeTab = document.querySelector('.tab.active'); - if (activeTab) updateTabSlider(activeTab, false); -}); - -function switchTab(tabName) { - // 获取当前活动的内容区域 - const currentContent = document.querySelector('.tab-content.active'); - const targetContent = document.getElementById(tabName + 'Tab'); - - // 如果点击的是当前标签页,不做任何操作 - if (currentContent === targetContent) return; - - // 找到目标标签按钮 - const targetTab = event && event.target ? event.target : - document.querySelector(`.tab[onclick*="'${tabName}'"]`); - - // 移除所有标签页的active状态 - document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); - - // 添加当前点击标签的active状态 - if (targetTab) { - targetTab.classList.add('active'); - // 更新滑块位置(带动画) - updateTabSlider(targetTab, true); - } - - // 淡出当前内容 - if (currentContent) { - // 设置淡出过渡 - currentContent.style.transition = 'opacity 0.18s ease-out, transform 0.18s ease-out'; - currentContent.style.opacity = '0'; - currentContent.style.transform = 'translateX(-12px)'; - - setTimeout(() => { - currentContent.classList.remove('active'); - currentContent.style.transition = ''; - currentContent.style.opacity = ''; - currentContent.style.transform = ''; - - // 淡入新内容 - if (targetContent) { - // 先设置初始状态(在添加 active 类之前) - targetContent.style.opacity = '0'; - targetContent.style.transform = 'translateX(12px)'; - targetContent.style.transition = 'none'; // 暂时禁用过渡 - - // 添加 active 类使元素可见 - targetContent.classList.add('active'); - - // 使用双重 requestAnimationFrame 确保浏览器完成重绘 - requestAnimationFrame(() => { - requestAnimationFrame(() => { - // 启用过渡并应用最终状态 - targetContent.style.transition = 'opacity 0.25s ease-out, transform 0.25s ease-out'; - targetContent.style.opacity = '1'; - targetContent.style.transform = 'translateX(0)'; - - // 清理内联样式并执行数据加载 - setTimeout(() => { - targetContent.style.transition = ''; - targetContent.style.opacity = ''; - targetContent.style.transform = ''; - - // 动画完成后触发数据加载 - triggerTabDataLoad(tabName); - }, 260); - }); - }); - } - }, 180); - } else { - // 如果没有当前内容(首次加载),直接显示目标内容 - if (targetContent) { - targetContent.classList.add('active'); - // 直接触发数据加载 - triggerTabDataLoad(tabName); - } - } -} - -// 标签页数据加载(从动画中分离出来) -function triggerTabDataLoad(tabName) { - if (tabName === 'manage') AppState.creds.refresh(); - if (tabName === 'antigravity-manage') AppState.antigravityCreds.refresh(); - if (tabName === 'config') loadConfig(); - if (tabName === 'logs') connectWebSocket(); -} - - -// ===================================================================== -// OAuth认证相关函数 -// ===================================================================== -async function startAuth() { - const projectId = document.getElementById('projectId').value.trim(); - AppState.currentProjectId = projectId || null; - - const btn = document.getElementById('getAuthBtn'); - btn.disabled = true; - btn.textContent = '正在获取认证链接...'; - - try { - const requestBody = projectId ? { project_id: projectId } : {}; - showStatus(projectId ? '使用指定的项目ID生成认证链接...' : '将尝试自动检测项目ID,正在生成认证链接...', 'info'); - - const response = await fetch('./auth/start', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(requestBody) - }); - - const data = await response.json(); - - if (response.ok) { - document.getElementById('authUrl').href = data.auth_url; - document.getElementById('authUrl').textContent = data.auth_url; - document.getElementById('authUrlSection').classList.remove('hidden'); - - const msg = data.auto_project_detection - ? '认证链接已生成(将在认证完成后自动检测项目ID),请点击链接完成授权' - : `认证链接已生成(项目ID: ${data.detected_project_id}),请点击链接完成授权`; - showStatus(msg, 'info'); - AppState.authInProgress = true; - } else { - showStatus(`错误: ${data.error || '获取认证链接失败'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - btn.disabled = false; - btn.textContent = '获取认证链接'; - } -} - -async function getCredentials() { - if (!AppState.authInProgress) { - showStatus('请先获取认证链接并完成授权', 'error'); - return; - } - - const btn = document.getElementById('getCredsBtn'); - btn.disabled = true; - btn.textContent = '等待OAuth回调中...'; - - try { - showStatus('正在等待OAuth回调,这可能需要一些时间...', 'info'); - - const requestBody = AppState.currentProjectId ? { project_id: AppState.currentProjectId } : {}; - - const response = await fetch('./auth/callback', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(requestBody) - }); - - const data = await response.json(); - - if (response.ok) { - document.getElementById('credentialsContent').textContent = JSON.stringify(data.credentials, null, 2); - - const msg = data.auto_detected_project - ? `✅ 认证成功!项目ID已自动检测为: ${data.credentials.project_id},文件已保存到: ${data.file_path}` - : `✅ 认证成功!文件已保存到: ${data.file_path}`; - showStatus(msg, 'success'); - - document.getElementById('credentialsSection').classList.remove('hidden'); - AppState.authInProgress = false; - } else if (data.requires_project_selection && data.available_projects) { - let projectOptions = "请选择一个项目:\n\n"; - data.available_projects.forEach((project, index) => { - projectOptions += `${index + 1}. ${project.name} (${project.project_id})\n`; - }); - projectOptions += `\n请输入序号 (1-${data.available_projects.length}):`; - - const selection = prompt(projectOptions); - const projectIndex = parseInt(selection) - 1; - - if (projectIndex >= 0 && projectIndex < data.available_projects.length) { - AppState.currentProjectId = data.available_projects[projectIndex].project_id; - btn.textContent = '重新尝试获取认证文件'; - showStatus(`使用选择的项目重新尝试...`, 'info'); - setTimeout(() => getCredentials(), 1000); - return; - } else { - showStatus('无效的选择,请重新开始认证', 'error'); - } - } else if (data.requires_manual_project_id) { - const userProjectId = prompt('无法自动检测项目ID,请手动输入您的Google Cloud项目ID:'); - if (userProjectId && userProjectId.trim()) { - AppState.currentProjectId = userProjectId.trim(); - btn.textContent = '重新尝试获取认证文件'; - showStatus('使用手动输入的项目ID重新尝试...', 'info'); - setTimeout(() => getCredentials(), 1000); - return; - } else { - showStatus('需要项目ID才能完成认证,请重新开始并输入正确的项目ID', 'error'); - } - } else { - showStatus(`❌ 错误: ${data.error || '获取认证文件失败'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - btn.disabled = false; - btn.textContent = '获取认证文件'; - } -} - -// ===================================================================== -// Antigravity 认证相关函数 -// ===================================================================== -async function startAntigravityAuth() { - const btn = document.getElementById('getAntigravityAuthBtn'); - btn.disabled = true; - btn.textContent = '生成认证链接中...'; - - try { - showStatus('正在生成 Antigravity 认证链接...', 'info'); - - const response = await fetch('./auth/start', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ mode: 'antigravity' }) - }); - - const data = await response.json(); - - if (response.ok) { - AppState.antigravityAuthState = data.state; - AppState.antigravityAuthInProgress = true; - - const authUrlLink = document.getElementById('antigravityAuthUrl'); - authUrlLink.href = data.auth_url; - authUrlLink.textContent = data.auth_url; - document.getElementById('antigravityAuthUrlSection').classList.remove('hidden'); - - showStatus('✅ Antigravity 认证链接已生成!请点击链接完成授权', 'success'); - } else { - showStatus(`❌ 错误: ${data.error || '生成认证链接失败'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - btn.disabled = false; - btn.textContent = '获取 Antigravity 认证链接'; - } -} - -async function getAntigravityCredentials() { - if (!AppState.antigravityAuthInProgress) { - showStatus('请先获取 Antigravity 认证链接并完成授权', 'error'); - return; - } - - const btn = document.getElementById('getAntigravityCredsBtn'); - btn.disabled = true; - btn.textContent = '等待OAuth回调中...'; - - try { - showStatus('正在等待 Antigravity OAuth回调...', 'info'); - - const response = await fetch('./auth/callback', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ mode: 'antigravity' }) - }); - - const data = await response.json(); - - if (response.ok) { - document.getElementById('antigravityCredsContent').textContent = JSON.stringify(data.credentials, null, 2); - document.getElementById('antigravityCredsSection').classList.remove('hidden'); - AppState.antigravityAuthInProgress = false; - showStatus(`✅ Antigravity 认证成功!文件已保存到: ${data.file_path}`, 'success'); - } else { - showStatus(`❌ 错误: ${data.error || '获取认证文件失败'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - btn.disabled = false; - btn.textContent = '获取 Antigravity 凭证'; - } -} - -function downloadAntigravityCredentials() { - const content = document.getElementById('antigravityCredsContent').textContent; - const blob = new Blob([content], { type: 'application/json' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `antigravity-credential-${Date.now()}.json`; - a.click(); - window.URL.revokeObjectURL(url); -} - -// ===================================================================== -// 回调URL处理 -// ===================================================================== -function toggleProjectIdSection() { - const section = document.getElementById('projectIdSection'); - const icon = document.getElementById('projectIdToggleIcon'); - - if (section.style.display === 'none') { - section.style.display = 'block'; - icon.style.transform = 'rotate(90deg)'; - icon.textContent = '▼'; - } else { - section.style.display = 'none'; - icon.style.transform = 'rotate(0deg)'; - icon.textContent = '▶'; - } -} - -function toggleCallbackUrlSection() { - const section = document.getElementById('callbackUrlSection'); - const icon = document.getElementById('callbackUrlToggleIcon'); - - if (section.style.display === 'none') { - section.style.display = 'block'; - icon.style.transform = 'rotate(180deg)'; - icon.textContent = '▲'; - } else { - section.style.display = 'none'; - icon.style.transform = 'rotate(0deg)'; - icon.textContent = '▼'; - } -} - -function toggleAntigravityCallbackUrlSection() { - const section = document.getElementById('antigravityCallbackUrlSection'); - const icon = document.getElementById('antigravityCallbackUrlToggleIcon'); - - if (section.style.display === 'none') { - section.style.display = 'block'; - icon.style.transform = 'rotate(180deg)'; - icon.textContent = '▲'; - } else { - section.style.display = 'none'; - icon.style.transform = 'rotate(0deg)'; - icon.textContent = '▼'; - } -} - -async function processCallbackUrl() { - const callbackUrl = document.getElementById('callbackUrlInput').value.trim(); - - if (!callbackUrl) { - showStatus('请输入回调URL', 'error'); - return; - } - - if (!callbackUrl.startsWith('http://') && !callbackUrl.startsWith('https://')) { - showStatus('请输入有效的URL(以http://或https://开头)', 'error'); - return; - } - - if (!callbackUrl.includes('code=') || !callbackUrl.includes('state=')) { - showStatus('❌ 这不是有效的回调URL!请确保:\n1. 已完成Google OAuth授权\n2. 复制的是浏览器地址栏的完整URL\n3. URL包含code和state参数', 'error'); - return; - } - - showStatus('正在从回调URL获取凭证...', 'info'); - - try { - const projectId = document.getElementById('projectId')?.value.trim() || null; - - const response = await fetch('./auth/callback-url', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ callback_url: callbackUrl, project_id: projectId }) - }); - - const result = await response.json(); - - if (result.credentials) { - showStatus(result.message || '从回调URL获取凭证成功!', 'success'); - document.getElementById('credentialsContent').innerHTML = '
' + JSON.stringify(result.credentials, null, 2) + '
'; - document.getElementById('credentialsSection').classList.remove('hidden'); - } else if (result.requires_manual_project_id) { - showStatus('需要手动指定项目ID,请在高级选项中填入Google Cloud项目ID后重试', 'error'); - } else if (result.requires_project_selection) { - let msg = '
可用项目:
'; - result.available_projects.forEach(p => { - msg += `• ${p.name} (ID: ${p.project_id})
`; - }); - showStatus('检测到多个项目,请在高级选项中指定项目ID:' + msg, 'error'); - } else { - showStatus(result.error || '从回调URL获取凭证失败', 'error'); - } - - document.getElementById('callbackUrlInput').value = ''; - } catch (error) { - showStatus(`从回调URL获取凭证失败: ${error.message}`, 'error'); - } -} - -async function processAntigravityCallbackUrl() { - const callbackUrl = document.getElementById('antigravityCallbackUrlInput').value.trim(); - - if (!callbackUrl) { - showStatus('请输入回调URL', 'error'); - return; - } - - if (!callbackUrl.startsWith('http://') && !callbackUrl.startsWith('https://')) { - showStatus('请输入有效的URL(以http://或https://开头)', 'error'); - return; - } - - if (!callbackUrl.includes('code=') || !callbackUrl.includes('state=')) { - showStatus('❌ 这不是有效的回调URL!请确保包含code和state参数', 'error'); - return; - } - - showStatus('正在从回调URL获取 Antigravity 凭证...', 'info'); - - try { - const response = await fetch('./auth/callback-url', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ callback_url: callbackUrl, mode: 'antigravity' }) - }); - - const result = await response.json(); - - if (result.credentials) { - showStatus(result.message || '从回调URL获取 Antigravity 凭证成功!', 'success'); - document.getElementById('antigravityCredsContent').textContent = JSON.stringify(result.credentials, null, 2); - document.getElementById('antigravityCredsSection').classList.remove('hidden'); - } else { - showStatus(result.error || '从回调URL获取 Antigravity 凭证失败', 'error'); - } - - document.getElementById('antigravityCallbackUrlInput').value = ''; - } catch (error) { - showStatus(`从回调URL获取 Antigravity 凭证失败: ${error.message}`, 'error'); - } -} - -// ===================================================================== -// 全局兼容函数(供HTML调用) -// ===================================================================== -// 普通凭证管理 -function refreshCredsStatus() { AppState.creds.refresh(); } -function applyStatusFilter() { AppState.creds.applyStatusFilter(); } -function changePage(direction) { AppState.creds.changePage(direction); } -function changePageSize() { AppState.creds.changePageSize(); } -function toggleFileSelection(filename) { - if (AppState.creds.selectedFiles.has(filename)) { - AppState.creds.selectedFiles.delete(filename); - } else { - AppState.creds.selectedFiles.add(filename); - } - AppState.creds.updateBatchControls(); -} -function toggleSelectAll() { - const checkbox = document.getElementById('selectAllCheckbox'); - const checkboxes = document.querySelectorAll('.file-checkbox'); - - if (checkbox.checked) { - checkboxes.forEach(cb => AppState.creds.selectedFiles.add(cb.getAttribute('data-filename'))); - } else { - AppState.creds.selectedFiles.clear(); - } - checkboxes.forEach(cb => cb.checked = checkbox.checked); - AppState.creds.updateBatchControls(); -} -function batchAction(action) { AppState.creds.batchAction(action); } -function downloadCred(filename) { - fetch(`./creds/download/${filename}`, { headers: { 'Authorization': `Bearer ${AppState.authToken}` } }) - .then(r => r.ok ? r.blob() : Promise.reject()) - .then(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); - showStatus(`已下载文件: ${filename}`, 'success'); - }) - .catch(() => showStatus(`下载失败: ${filename}`, 'error')); -} -async function downloadAllCreds() { - try { - const response = await fetch('./creds/download-all', { - headers: { 'Authorization': `Bearer ${AppState.authToken}` } - }); - if (response.ok) { - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'credentials.zip'; - a.click(); - window.URL.revokeObjectURL(url); - showStatus('已下载所有凭证文件', 'success'); - } - } catch (error) { - showStatus(`打包下载失败: ${error.message}`, 'error'); - } -} - -// Antigravity凭证管理 -function refreshAntigravityCredsList() { AppState.antigravityCreds.refresh(); } -function applyAntigravityStatusFilter() { AppState.antigravityCreds.applyStatusFilter(); } -function changeAntigravityPage(direction) { AppState.antigravityCreds.changePage(direction); } -function changeAntigravityPageSize() { AppState.antigravityCreds.changePageSize(); } -function toggleAntigravityFileSelection(filename) { - if (AppState.antigravityCreds.selectedFiles.has(filename)) { - AppState.antigravityCreds.selectedFiles.delete(filename); - } else { - AppState.antigravityCreds.selectedFiles.add(filename); - } - AppState.antigravityCreds.updateBatchControls(); -} -function toggleSelectAllAntigravity() { - const checkbox = document.getElementById('selectAllAntigravityCheckbox'); - const checkboxes = document.querySelectorAll('.antigravityFile-checkbox'); - - if (checkbox.checked) { - checkboxes.forEach(cb => AppState.antigravityCreds.selectedFiles.add(cb.getAttribute('data-filename'))); - } else { - AppState.antigravityCreds.selectedFiles.clear(); - } - checkboxes.forEach(cb => cb.checked = checkbox.checked); - AppState.antigravityCreds.updateBatchControls(); -} -function batchAntigravityAction(action) { AppState.antigravityCreds.batchAction(action); } -function downloadAntigravityCred(filename) { - fetch(`./creds/download/${filename}?mode=antigravity`, { headers: getAuthHeaders() }) - .then(r => r.ok ? r.blob() : Promise.reject()) - .then(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); - showStatus(`✅ 已下载: ${filename}`, 'success'); - }) - .catch(() => showStatus(`下载失败: ${filename}`, 'error')); -} -function deleteAntigravityCred(filename) { - if (confirm(`确定要删除 ${filename} 吗?`)) { - AppState.antigravityCreds.action(filename, 'delete'); - } -} -async function downloadAllAntigravityCreds() { - try { - const response = await fetch('./creds/download-all?mode=antigravity', { headers: getAuthHeaders() }); - if (response.ok) { - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `antigravity_credentials_${Date.now()}.zip`; - a.click(); - window.URL.revokeObjectURL(url); - showStatus('✅ 所有Antigravity凭证已打包下载', 'success'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -// 文件上传 -function handleFileSelect(event) { AppState.uploadFiles.handleFileSelect(event); } -function removeFile(index) { AppState.uploadFiles.removeFile(index); } -function clearFiles() { AppState.uploadFiles.clearFiles(); } -function uploadFiles() { AppState.uploadFiles.upload(); } - -function handleAntigravityFileSelect(event) { AppState.antigravityUploadFiles.handleFileSelect(event); } -function handleAntigravityFileDrop(event) { - event.preventDefault(); - event.currentTarget.style.borderColor = '#007bff'; - event.currentTarget.style.backgroundColor = '#f8f9fa'; - AppState.antigravityUploadFiles.addFiles(Array.from(event.dataTransfer.files)); -} -function removeAntigravityFile(index) { AppState.antigravityUploadFiles.removeFile(index); } -function clearAntigravityFiles() { AppState.antigravityUploadFiles.clearFiles(); } -function uploadAntigravityFiles() { AppState.antigravityUploadFiles.upload(); } - -// 邮箱相关 -// 辅助函数:根据文件名更新卡片中的邮箱显示 -function updateEmailDisplay(filename, email, managerType = 'normal') { - // 查找对应的凭证卡片 - const containerId = managerType === 'antigravity' ? 'antigravityCredsList' : 'credsList'; - const container = document.getElementById(containerId); - if (!container) return false; - - // 通过 data-filename 找到对应的复选框,再找到其父卡片 - const checkbox = container.querySelector(`input[data-filename="${filename}"]`); - if (!checkbox) return false; - - // 找到对应的 cred-card 元素 - const card = checkbox.closest('.cred-card'); - if (!card) return false; - - // 找到邮箱显示元素 - const emailDiv = card.querySelector('.cred-email'); - if (emailDiv) { - emailDiv.textContent = email; - emailDiv.style.color = '#666'; - emailDiv.style.fontStyle = 'normal'; - return true; - } - return false; -} - -async function fetchUserEmail(filename) { - try { - showStatus('正在获取用户邮箱...', 'info'); - const response = await fetch(`./creds/fetch-email/${encodeURIComponent(filename)}`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - if (response.ok && data.user_email) { - showStatus(`成功获取邮箱: ${data.user_email}`, 'success'); - // 直接更新卡片中的邮箱显示,不刷新整个列表 - updateEmailDisplay(filename, data.user_email, 'normal'); - } else { - showStatus(data.message || '无法获取用户邮箱', 'error'); - } - } catch (error) { - showStatus(`获取邮箱失败: ${error.message}`, 'error'); - } -} - -async function fetchAntigravityUserEmail(filename) { - try { - showStatus('正在获取用户邮箱...', 'info'); - const response = await fetch(`./creds/fetch-email/${encodeURIComponent(filename)}?mode=antigravity`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - if (response.ok && data.user_email) { - showStatus(`成功获取邮箱: ${data.user_email}`, 'success'); - // 直接更新卡片中的邮箱显示,不刷新整个列表 - updateEmailDisplay(filename, data.user_email, 'antigravity'); - } else { - showStatus(data.message || '无法获取用户邮箱', 'error'); - } - } catch (error) { - showStatus(`获取邮箱失败: ${error.message}`, 'error'); - } -} - -async function verifyProjectId(filename) { - try { - // 显示加载状态 - showStatus('🔍 正在检验Project ID,请稍候...', 'info'); - - const response = await fetch(`./creds/verify-project/${encodeURIComponent(filename)}`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok && data.success) { - // 成功时显示绿色成功消息和Project ID - const tierLine = data.subscription_tier ? `\nTier: ${data.subscription_tier}` : ''; - const creditLine = data.credit_amount !== undefined && data.credit_amount !== null - ? `\n积分: ${data.credit_amount}` - : ''; - const successMsg = `✅ 检验成功!\n文件: ${filename}\nProject ID: ${data.project_id}${tierLine}${creditLine}\n\n${data.message}`; - showStatus(successMsg.replace(/\n/g, '
'), 'success'); - - // 弹出成功提示 - showMessageModal('检验成功', `✅ 检验成功!\n\n文件: ${filename}\nProject ID: ${data.project_id}${tierLine}${creditLine}\n\n${data.message}`, 'success'); - - await AppState.creds.refresh(); - } else { - // 失败时显示红色错误消息 - const errorMsg = data.message || '检验失败'; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('检验失败', `❌ 检验失败\n\n${errorMsg}`, 'error'); - } - } catch (error) { - const errorMsg = `检验失败: ${error.message}`; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('检验失败', `❌ ${errorMsg}`, 'error'); - } -} - -async function verifyAntigravityProjectId(filename) { - try { - // 显示加载状态 - showStatus('🔍 正在检验Antigravity Project ID,请稍候...', 'info'); - - const response = await fetch(`./creds/verify-project/${encodeURIComponent(filename)}?mode=antigravity`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok && data.success) { - // 成功时显示绿色成功消息和Project ID - const tierLine = data.subscription_tier ? `\nTier: ${data.subscription_tier}` : ''; - const creditLine = data.credit_amount !== undefined && data.credit_amount !== null - ? `\n积分: ${data.credit_amount}` - : ''; - const successMsg = `✅ 检验成功!\n文件: ${filename}\nProject ID: ${data.project_id}${tierLine}${creditLine}\n\n${data.message}`; - showStatus(successMsg.replace(/\n/g, '
'), 'success'); - - // 弹出成功提示 - showMessageModal('检验成功', `✅ Antigravity检验成功!\n\n文件: ${filename}\nProject ID: ${data.project_id}${tierLine}${creditLine}\n\n${data.message}`, 'success'); - - await AppState.antigravityCreds.refresh(); - } else { - // 失败时显示红色错误消息 - const errorMsg = data.message || '检验失败'; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('检验失败', `❌ 检验失败\n\n${errorMsg}`, 'error'); - } - } catch (error) { - const errorMsg = `检验失败: ${error.message}`; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('检验失败', `❌ ${errorMsg}`, 'error'); - } -} - -async function testCredential(filename) { - try { - // 显示加载状态 - showStatus('🧪 正在测试凭证,请稍候...', 'info'); - - const response = await fetch(`./creds/test/${encodeURIComponent(filename)}`, { - method: 'POST', - headers: getAuthHeaders() - }); - - // 解析JSON响应 - const data = await response.json(); - - if (response.status === 200) { - // 凭证可用 - const successMsg = `✅ 测试成功!\n文件: ${filename}\n状态: ${data.message || '凭证可用'} (${data.status_code || 200})`; - showStatus('✅ 测试成功!', 'success'); - showMessageModal('测试成功', successMsg, 'success'); - await AppState.creds.refresh(); - } - else { - // 其他错误 - 显示完整错误信息 - let errorDetails = `❌ 测试失败\n文件: ${filename}\n`; - - // 如果有完整的错误响应,添加到详情中 - if (data.error) { - try { - // 尝试格式化JSON错误 - const errorObj = JSON.parse(data.error); - errorDetails += `\n错误详情:\n${JSON.stringify(errorObj, null, 2)}`; - } catch { - // 如果不是JSON,直接显示文本 - errorDetails += `\n错误详情:\n${data.error}`; - } - } else { - errorDetails += `错误码: ${data.status_code || response.status}`; - } - - showStatus(`❌ 测试失败 - ${data.message || '错误码: ' + (data.status_code || response.status)}`, 'error'); - showMessageModal('测试失败', errorDetails, 'error'); - } - } catch (error) { - const errorMsg = `测试失败: ${error.message}`; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('测试失败', `❌ ${errorMsg}`, 'error'); - } -} - -async function testAntigravityCredential(filename) { - try { - // 显示加载状态 - showStatus('🧪 正在测试Antigravity凭证,请稍候...', 'info'); - - const response = await fetch(`./creds/test/${encodeURIComponent(filename)}?mode=antigravity`, { - method: 'POST', - headers: getAuthHeaders() - }); - - // 解析JSON响应 - const data = await response.json(); - - if (response.status === 200) { - // 凭证可用 - const successMsg = `✅ 测试成功!\n文件: ${filename}\n状态: ${data.message || 'Antigravity凭证可用'} (${data.status_code || 200})`; - showStatus('✅ 测试成功!', 'success'); - showMessageModal('测试成功', successMsg, 'success'); - await AppState.antigravityCreds.refresh(); - } - else { - // 其他错误 - 显示完整错误信息 - let errorDetails = `❌ 测试失败\n文件: ${filename}\n`; - - // 如果有完整的错误响应,添加到详情中 - if (data.error) { - try { - // 尝试格式化JSON错误 - const errorObj = JSON.parse(data.error); - errorDetails += `\n错误详情:\n${JSON.stringify(errorObj, null, 2)}`; - } catch { - // 如果不是JSON,直接显示文本 - errorDetails += `\n错误详情:\n${data.error}`; - } - } else { - errorDetails += `错误码: ${data.status_code || response.status}`; - } - - showStatus(`❌ 测试失败 - ${data.message || '错误码: ' + (data.status_code || response.status)}`, 'error'); - showMessageModal('测试失败', errorDetails, 'error'); - } - } catch (error) { - const errorMsg = `测试失败: ${error.message}`; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('测试失败', `❌ ${errorMsg}`, 'error'); - } -} - -async function configurePreviewChannel(filename) { - try { - // 显示加载状态 - showStatus('🔧 正在配置Preview通道,请稍候...', 'info'); - - const response = await fetch(`./creds/configure-preview/${encodeURIComponent(filename)}`, { - method: 'POST', - headers: getAuthHeaders() - }); - - const data = await response.json(); - - if (response.ok && data.success) { - // 配置成功 - const successMsg = `✅ 配置成功!\n文件: ${filename}\n状态: ${data.message}`; - showStatus(successMsg.replace(/\n/g, '
'), 'success'); - showMessageModal('Preview通道配置成功', `✅ Preview通道配置成功!\n\n文件: ${filename}\n\n${data.message}\n\nSetting ID: ${data.setting_id || 'N/A'}\nBinding ID: ${data.binding_id || 'N/A'}`, 'success'); - - // 刷新凭证列表 - await AppState.creds.refresh(); - } else { - // 配置失败 - const errorMsg = data.message || '配置失败'; - const errorDetail = data.error || ''; - const step = data.step || ''; - - let alertMsg = `❌ Preview通道配置失败\n\n文件: ${filename}\n\n${errorMsg}`; - if (step) { - alertMsg += `\n失败步骤: ${step}`; - } - if (errorDetail) { - alertMsg += `\n\n错误详情: ${errorDetail}`; - } - - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('Preview通道配置失败', alertMsg, 'error'); - } - } catch (error) { - const errorMsg = `配置Preview通道失败: ${error.message}`; - showStatus(`❌ ${errorMsg}`, 'error'); - showMessageModal('配置Preview通道失败', `❌ ${errorMsg}`, 'error'); - } -} - -async function toggleAntigravityQuotaDetails(pathId) { - const quotaDetails = document.getElementById('quota-' + pathId); - if (!quotaDetails) return; - - // 切换显示状态 - const isShowing = quotaDetails.style.display === 'block'; - - if (isShowing) { - // 收起 - quotaDetails.style.display = 'none'; - } else { - // 展开 - quotaDetails.style.display = 'block'; - - const contentDiv = quotaDetails.querySelector('.cred-quota-content'); - const filename = contentDiv.getAttribute('data-filename'); - - // 每次展开都重新加载数据 - if (filename) { - contentDiv.innerHTML = '
📊 正在加载额度信息...
'; - - try { - const response = await fetch(`./creds/quota/${encodeURIComponent(filename)}?mode=antigravity`, { - method: 'GET', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok && data.success) { - // 成功时渲染美化的额度信息 - const models = data.models || {}; - - if (Object.keys(models).length === 0) { - contentDiv.innerHTML = ` -
-
📊
-
暂无额度信息
-
- `; - } else { - let quotaHTML = ` -
-

- 📊 - 额度信息详情 -

-
文件: ${filename}
-
-
- `; - - for (const [modelName, quotaData] of Object.entries(models)) { - // 后端返回的是剩余比例 (0-1),不是绝对数量 - const remainingFraction = quotaData.remaining || 0; - const resetTime = quotaData.resetTime || 'N/A'; - - // 计算已使用百分比(1 - 剩余比例) - const usedPercentage = Math.round((1 - remainingFraction) * 100); - const remainingPercentage = Math.round(remainingFraction * 100); - - // 根据使用情况选择颜色 - let percentageColor = '#28a745'; // 绿色:使用少 - if (usedPercentage >= 90) percentageColor = '#dc3545'; // 红色:使用多 - else if (usedPercentage >= 70) percentageColor = '#ffc107'; // 黄色:使用较多 - else if (usedPercentage >= 50) percentageColor = '#17a2b8'; // 蓝色:使用中等 - - quotaHTML += ` -
-
-
- ${modelName} -
-
- ${remainingPercentage}% -
-
-
-
-
-
- ${resetTime !== 'N/A' ? '🔄 ' + resetTime : ''} -
-
- `; - } - - quotaHTML += '
'; - contentDiv.innerHTML = quotaHTML; - } - - showStatus('✅ 成功加载额度信息', 'success'); - } else { - // 失败时显示错误 - const errorMsg = data.error || '获取额度信息失败'; - contentDiv.innerHTML = ` -
-
❌
-
获取额度信息失败
-
${errorMsg}
-
- `; - showStatus(`❌ ${errorMsg}`, 'error'); - } - } catch (error) { - contentDiv.innerHTML = ` -
-
❌
-
网络错误
-
${error.message}
-
- `; - showStatus(`❌ 获取额度信息失败: ${error.message}`, 'error'); - } - } - } -} - -// ===================================================================== -// 查看报错详情 -// ===================================================================== -async function toggleErrorDetails(pathId) { - await toggleErrorDetailsCommon(pathId, AppState.creds); -} - -async function toggleAntigravityErrorDetails(pathId) { - await toggleErrorDetailsCommon(pathId, AppState.antigravityCreds); -} - -async function toggleErrorDetailsCommon(pathId, manager) { - const errorDetails = document.getElementById('errors-' + pathId); - if (!errorDetails) return; - - // 切换显示状态 - const isShowing = errorDetails.classList.toggle('show'); - - if (isShowing) { - const contentDiv = errorDetails.querySelector('.cred-content'); - const filename = contentDiv.getAttribute('data-filename'); - - // 每次展开都重新加载数据 - if (filename) { - contentDiv.innerHTML = '
⏳ 正在加载报错信息...
'; - - try { - const modeParam = manager.type === 'antigravity' ? 'mode=antigravity' : 'mode=geminicli'; - const response = await fetch(`./creds/errors/${encodeURIComponent(filename)}?${modeParam}`, { - method: 'GET', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok) { - const errorCodes = data.error_codes || []; - const errorMessages = data.error_messages || {}; - - if (errorCodes.length === 0) { - contentDiv.innerHTML = ` -
-
✅
-
无报错记录
-
该凭证运行正常
-
- `; - } else { - let errorHTML = ''; - - // 遍历所有错误码,从 errorMessages 对象中获取对应消息 - errorCodes.forEach((errorCode) => { - const messageStr = errorMessages[errorCode] || '无详细信息'; - - // 提取核心错误消息和详细信息 - let displayMsg = messageStr; - let detailsHtml = ''; - - try { - // 尝试解析 JSON 格式的 message - const parsedMsg = JSON.parse(messageStr); - if (parsedMsg.error) { - // 显示核心错误信息 - if (parsedMsg.error.message) { - displayMsg = parsedMsg.error.message; - } - - // 如果有 details 字段,也显示出来 - if (parsedMsg.error.details && Array.isArray(parsedMsg.error.details)) { - detailsHtml = '
'; - detailsHtml += '
详细信息:
'; - - parsedMsg.error.details.forEach((detail, idx) => { - detailsHtml += '
'; - - // 显示 @type - if (detail['@type']) { - const highlightedType = highlightHttpLinks(escapeHtml(detail['@type'])); - detailsHtml += `
类型: ${highlightedType}
`; - } - - // 显示 reason - if (detail.reason) { - detailsHtml += `
原因: ${escapeHtml(detail.reason)}
`; - } - - // 显示 metadata(如 quotaResetTimeStamp) - if (detail.metadata) { - detailsHtml += '
'; - for (const [key, value] of Object.entries(detail.metadata)) { - const highlightedValue = highlightHttpLinks(escapeHtml(String(value))); - detailsHtml += `
${escapeHtml(key)}: ${highlightedValue}
`; - } - detailsHtml += '
'; - } - - detailsHtml += '
'; - }); - - detailsHtml += '
'; - } - - // 如果有 status 字段,也显示 - if (parsedMsg.error.status) { - if (!detailsHtml) { - detailsHtml = '
'; - } - detailsHtml += `
状态: ${escapeHtml(parsedMsg.error.status)}
`; - if (!parsedMsg.error.details) { - detailsHtml += '
'; - } - } - } - } catch (e) { - // 如果不是 JSON 格式,直接使用原始消息 - } - - // 对消息中的HTTP链接进行高亮处理 - const highlightedMsg = highlightHttpLinks(escapeHtml(displayMsg)); - - errorHTML += ` -
-
错误码: ${errorCode}
-
- ${highlightedMsg} -
- ${detailsHtml} -
- `; - }); - - contentDiv.innerHTML = errorHTML; - } - - showStatus('✅ 成功加载报错信息', 'success'); - } else { - // 失败时显示错误 - const errorMsg = data.detail || data.error || '获取报错信息失败'; - contentDiv.innerHTML = ` -
-
❌
-
加载失败
-
${errorMsg}
-
- `; - showStatus(`❌ 获取报错信息失败: ${errorMsg}`, 'error'); - } - } catch (error) { - contentDiv.innerHTML = ` -
-
❌
-
网络错误
-
${error.message}
-
- `; - showStatus(`❌ 获取报错信息失败: ${error.message}`, 'error'); - } - } - } -} - -// HTML转义函数 -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// 高亮HTTP链接函数 -function highlightHttpLinks(text) { - // 匹配 http:// 或 https:// 开头的URL - const urlRegex = /(https?:\/\/[^\s<>"]+)/gi; - return text.replace(urlRegex, function(url) { - return `${url}`; - }); -} - -async function batchVerifyProjectIds() { - const selectedFiles = Array.from(AppState.creds.selectedFiles); - if (selectedFiles.length === 0) { - showStatus('❌ 请先选择要检验的凭证', 'error'); - showMessageModal('提示', '请先选择要检验的凭证', 'error'); - return; - } - - if (!confirm(`确定要批量检验 ${selectedFiles.length} 个凭证的Project ID吗?\n\n将并行检验以加快速度。`)) { - return; - } - - showStatus(`🔍 正在并行检验 ${selectedFiles.length} 个凭证,请稍候...`, 'info'); - - // 并行执行所有检验请求 - const promises = selectedFiles.map(async (filename) => { - try { - const response = await fetch(`./creds/verify-project/${encodeURIComponent(filename)}`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok && data.success) { - return { - success: true, - filename, - projectId: data.project_id, - creditAmount: data.credit_amount, - message: data.message - }; - } else { - return { success: false, filename, error: data.message || '失败' }; - } - } catch (error) { - return { success: false, filename, error: error.message }; - } - }); - - // 等待所有请求完成 - const results = await Promise.all(promises); - - // 统计结果 - let successCount = 0; - let failCount = 0; - const resultMessages = []; - - results.forEach(result => { - if (result.success) { - successCount++; - const creditSuffix = result.creditAmount !== undefined && result.creditAmount !== null - ? ` (积分: ${result.creditAmount})` - : ''; - resultMessages.push(`✅ ${result.filename}: ${result.projectId}${creditSuffix}`); - } else { - failCount++; - resultMessages.push(`❌ ${result.filename}: ${result.error}`); - } - }); - - await AppState.creds.refresh(); - - const summary = `批量检验完成!\n\n成功: ${successCount} 个\n失败: ${failCount} 个\n总计: ${selectedFiles.length} 个\n\n详细结果:\n${resultMessages.join('\n')}`; - - if (failCount === 0) { - showStatus(`✅ 全部检验成功!成功检验 ${successCount}/${selectedFiles.length} 个凭证`, 'success'); - showMessageModal('批量检验完成', summary, 'success'); - } else if (successCount === 0) { - showStatus(`❌ 全部检验失败!失败 ${failCount}/${selectedFiles.length} 个凭证`, 'error'); - showMessageModal('批量检验完成', summary, 'error'); - } else { - showStatus(`⚠️ 批量检验完成:成功 ${successCount}/${selectedFiles.length} 个,失败 ${failCount} 个`, 'info'); - showMessageModal('批量检验完成', summary, 'info'); - } - - console.log(summary); -} - -async function batchVerifyAntigravityProjectIds() { - const selectedFiles = Array.from(AppState.antigravityCreds.selectedFiles); - if (selectedFiles.length === 0) { - showStatus('❌ 请先选择要检验的Antigravity凭证', 'error'); - showMessageModal('提示', '请先选择要检验的Antigravity凭证', 'error'); - return; - } - - if (!confirm(`确定要批量检验 ${selectedFiles.length} 个Antigravity凭证的Project ID吗?\n\n将并行检验以加快速度。`)) { - return; - } - - showStatus(`🔍 正在并行检验 ${selectedFiles.length} 个Antigravity凭证,请稍候...`, 'info'); - - // 并行执行所有检验请求 - const promises = selectedFiles.map(async (filename) => { - try { - const response = await fetch(`./creds/verify-project/${encodeURIComponent(filename)}?mode=antigravity`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok && data.success) { - return { - success: true, - filename, - projectId: data.project_id, - creditAmount: data.credit_amount, - message: data.message - }; - } else { - return { success: false, filename, error: data.message || '失败' }; - } - } catch (error) { - return { success: false, filename, error: error.message }; - } - }); - - // 等待所有请求完成 - const results = await Promise.all(promises); - - // 统计结果 - let successCount = 0; - let failCount = 0; - const resultMessages = []; - - results.forEach(result => { - if (result.success) { - successCount++; - const creditSuffix = result.creditAmount !== undefined && result.creditAmount !== null - ? ` (积分: ${result.creditAmount})` - : ''; - resultMessages.push(`✅ ${result.filename}: ${result.projectId}${creditSuffix}`); - } else { - failCount++; - resultMessages.push(`❌ ${result.filename}: ${result.error}`); - } - }); - - await AppState.antigravityCreds.refresh(); - - const summary = `Antigravity批量检验完成!\n\n成功: ${successCount} 个\n失败: ${failCount} 个\n总计: ${selectedFiles.length} 个\n\n详细结果:\n${resultMessages.join('\n')}`; - - if (failCount === 0) { - showStatus(`✅ 全部检验成功!成功检验 ${successCount}/${selectedFiles.length} 个Antigravity凭证`, 'success'); - showMessageModal('Antigravity批量检验完成', summary, 'success'); - } else if (successCount === 0) { - showStatus(`❌ 全部检验失败!失败 ${failCount}/${selectedFiles.length} 个Antigravity凭证`, 'error'); - showMessageModal('Antigravity批量检验完成', summary, 'error'); - } else { - showStatus(`⚠️ 批量检验完成:成功 ${successCount}/${selectedFiles.length} 个,失败 ${failCount} 个`, 'info'); - showMessageModal('Antigravity批量检验完成', summary, 'info'); - } - - console.log(summary); -} - -async function batchConfigurePreview() { - const selectedFiles = Array.from(AppState.creds.selectedFiles); - if (selectedFiles.length === 0) { - showStatus('❌ 请先选择要配置Preview的凭证', 'error'); - showMessageModal('提示', '请先选择要配置Preview的凭证', 'error'); - return; - } - - if (!confirm(`确定要为 ${selectedFiles.length} 个凭证批量设置Preview通道吗?\n\n将并行配置以加快速度。`)) { - return; - } - - showStatus(`🔧 正在为 ${selectedFiles.length} 个凭证配置Preview通道,请稍候...`, 'info'); - - // 并行执行所有配置请求 - const promises = selectedFiles.map(async (filename) => { - try { - const response = await fetch(`./creds/configure-preview/${encodeURIComponent(filename)}`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - - if (response.ok && data.success) { - return { - success: true, - filename, - message: data.message, - setting_id: data.setting_id, - binding_id: data.binding_id - }; - } else { - return { - success: false, - filename, - error: data.message || '配置失败', - step: data.step, - errorDetail: data.error - }; - } - } catch (error) { - return { success: false, filename, error: error.message }; - } - }); - - // 等待所有请求完成 - const results = await Promise.all(promises); - - // 统计结果 - let successCount = 0; - let failCount = 0; - const resultMessages = []; - - results.forEach(result => { - if (result.success) { - successCount++; - resultMessages.push(`✅ ${result.filename}: ${result.message || '配置成功'}`); - } else { - failCount++; - const errorMsg = result.step ? `${result.error} (步骤: ${result.step})` : result.error; - resultMessages.push(`❌ ${result.filename}: ${errorMsg}`); - } - }); - - await AppState.creds.refresh(); - - const summary = `批量配置Preview通道完成!\n\n成功: ${successCount} 个\n失败: ${failCount} 个\n总计: ${selectedFiles.length} 个\n\n详细结果:\n${resultMessages.join('\n')}`; - - if (failCount === 0) { - showStatus(`✅ 全部配置成功!成功配置 ${successCount}/${selectedFiles.length} 个凭证的Preview通道`, 'success'); - showMessageModal('批量配置Preview通道完成', summary, 'success'); - } else if (successCount === 0) { - showStatus(`❌ 全部配置失败!失败 ${failCount}/${selectedFiles.length} 个凭证`, 'error'); - showMessageModal('批量配置Preview通道完成', summary, 'error'); - } else { - showStatus(`⚠️ 批量配置完成:成功 ${successCount}/${selectedFiles.length} 个,失败 ${failCount} 个`, 'info'); - showMessageModal('批量配置Preview通道完成', summary, 'info'); - } - - console.log(summary); -} - - -async function refreshAllEmails() { - if (!confirm('确定要刷新所有凭证的用户邮箱吗?这可能需要一些时间。')) return; - - try { - showStatus('正在刷新所有用户邮箱...', 'info'); - const response = await fetch('./creds/refresh-all-emails', { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - if (response.ok) { - showStatus(`邮箱刷新完成:成功获取 ${data.success_count}/${data.total_count} 个邮箱地址`, 'success'); - await AppState.creds.refresh(); - } else { - showStatus(data.message || '邮箱刷新失败', 'error'); - } - } catch (error) { - showStatus(`邮箱刷新网络错误: ${error.message}`, 'error'); - } -} - -async function refreshAllAntigravityEmails() { - if (!confirm('确定要刷新所有Antigravity凭证的用户邮箱吗?这可能需要一些时间。')) return; - - try { - showStatus('正在刷新所有用户邮箱...', 'info'); - const response = await fetch('./creds/refresh-all-emails?mode=antigravity', { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - if (response.ok) { - showStatus(`邮箱刷新完成:成功获取 ${data.success_count}/${data.total_count} 个邮箱地址`, 'success'); - await AppState.antigravityCreds.refresh(); - } else { - showStatus(data.message || '邮箱刷新失败', 'error'); - } - } catch (error) { - showStatus(`邮箱刷新网络错误: ${error.message}`, 'error'); - } -} - -async function deduplicateByEmail() { - if (!confirm('确定要对凭证进行凭证一键去重吗?\n\n相同邮箱的凭证只保留一个,其他将被删除。\n此操作不可撤销!')) return; - - try { - showStatus('正在进行凭证一键去重...', 'info'); - const response = await fetch('./creds/deduplicate-by-email', { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - if (response.ok) { - const msg = `去重完成:删除 ${data.deleted_count} 个重复凭证,保留 ${data.kept_count} 个凭证(${data.unique_emails_count} 个唯一邮箱)`; - showStatus(msg, 'success'); - await AppState.creds.refresh(); - - // 显示详细信息 - if (data.duplicate_groups && data.duplicate_groups.length > 0) { - let details = '去重详情:\n\n'; - data.duplicate_groups.forEach(group => { - details += `邮箱: ${group.email}\n保留: ${group.kept_file}\n删除: ${group.deleted_files.join(', ')}\n\n`; - }); - console.log(details); - } - } else { - showStatus(data.message || '去重失败', 'error'); - } - } catch (error) { - showStatus(`去重网络错误: ${error.message}`, 'error'); - } -} - -async function deduplicateAntigravityByEmail() { - if (!confirm('确定要对Antigravity凭证进行凭证一键去重吗?\n\n相同邮箱的凭证只保留一个,其他将被删除。\n此操作不可撤销!')) return; - - try { - showStatus('正在进行凭证一键去重...', 'info'); - const response = await fetch('./creds/deduplicate-by-email?mode=antigravity', { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await response.json(); - if (response.ok) { - const msg = `去重完成:删除 ${data.deleted_count} 个重复凭证,保留 ${data.kept_count} 个凭证(${data.unique_emails_count} 个唯一邮箱)`; - showStatus(msg, 'success'); - await AppState.antigravityCreds.refresh(); - - // 显示详细信息 - if (data.duplicate_groups && data.duplicate_groups.length > 0) { - let details = '去重详情:\n\n'; - data.duplicate_groups.forEach(group => { - details += `邮箱: ${group.email}\n保留: ${group.kept_file}\n删除: ${group.deleted_files.join(', ')}\n\n`; - }); - console.log(details); - } - } else { - showStatus(data.message || '去重失败', 'error'); - } - } catch (error) { - showStatus(`去重网络错误: ${error.message}`, 'error'); - } -} - -// ===================================================================== -// WebSocket日志相关 -// ===================================================================== -function connectWebSocket() { - if (AppState.logWebSocket && AppState.logWebSocket.readyState === WebSocket.OPEN) { - showStatus('WebSocket已经连接', 'info'); - return; - } - - try { - const wsPath = new URL('./logs/stream', window.location.href).href; - const wsUrl = wsPath.replace(/^http/, 'ws'); - - // 添加 token 认证参数 - const wsUrlWithAuth = `${wsUrl}?token=${encodeURIComponent(AppState.authToken)}`; - - document.getElementById('connectionStatusText').textContent = '连接中...'; - document.getElementById('logConnectionStatus').className = 'status info'; - - AppState.logWebSocket = new WebSocket(wsUrlWithAuth); - - AppState.logWebSocket.onopen = () => { - document.getElementById('connectionStatusText').textContent = '已连接'; - document.getElementById('logConnectionStatus').className = 'status success'; - showStatus('日志流连接成功', 'success'); - clearLogsDisplay(); - }; - - AppState.logWebSocket.onmessage = (event) => { - const logLine = event.data; - if (logLine.trim()) { - AppState.allLogs.push(logLine); - if (AppState.allLogs.length > 1000) { - AppState.allLogs = AppState.allLogs.slice(-1000); - } - filterLogs(); - if (document.getElementById('autoScroll').checked) { - const logContainer = document.getElementById('logContainer'); - logContainer.scrollTop = logContainer.scrollHeight; - } - } - }; - - AppState.logWebSocket.onclose = () => { - document.getElementById('connectionStatusText').textContent = '连接断开'; - document.getElementById('logConnectionStatus').className = 'status error'; - showStatus('日志流连接断开', 'info'); - }; - - AppState.logWebSocket.onerror = (error) => { - document.getElementById('connectionStatusText').textContent = '连接错误'; - document.getElementById('logConnectionStatus').className = 'status error'; - showStatus('日志流连接错误: ' + error, 'error'); - }; - } catch (error) { - showStatus('创建WebSocket连接失败: ' + error.message, 'error'); - document.getElementById('connectionStatusText').textContent = '连接失败'; - document.getElementById('logConnectionStatus').className = 'status error'; - } -} - -function disconnectWebSocket() { - if (AppState.logWebSocket) { - AppState.logWebSocket.close(); - AppState.logWebSocket = null; - document.getElementById('connectionStatusText').textContent = '未连接'; - document.getElementById('logConnectionStatus').className = 'status info'; - showStatus('日志流连接已断开', 'info'); - } -} - -function clearLogsDisplay() { - AppState.allLogs = []; - AppState.filteredLogs = []; - document.getElementById('logContent').textContent = '日志已清空,等待新日志...'; -} - -async function downloadLogs() { - try { - const response = await fetch('./logs/download', { headers: getAuthHeaders() }); - - if (response.ok) { - const contentDisposition = response.headers.get('Content-Disposition'); - let filename = 'gcli2api_logs.txt'; - if (contentDisposition) { - const match = contentDisposition.match(/filename=(.+)/); - if (match) filename = match[1]; - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); - - showStatus(`日志文件下载成功: ${filename}`, 'success'); - } else { - const data = await response.json(); - showStatus(`下载日志失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`下载日志时网络错误: ${error.message}`, 'error'); - } -} - -async function clearLogs() { - try { - const response = await fetch('./logs/clear', { - method: 'POST', - headers: getAuthHeaders() - }); - - const data = await response.json(); - - if (response.ok) { - clearLogsDisplay(); - showStatus(data.message, 'success'); - } else { - showStatus(`清空日志失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - clearLogsDisplay(); - showStatus(`清空日志时网络错误: ${error.message}`, 'error'); - } -} - -function filterLogs() { - const filter = document.getElementById('logLevelFilter').value; - AppState.currentLogFilter = filter; - - if (filter === 'all') { - AppState.filteredLogs = [...AppState.allLogs]; - } else { - AppState.filteredLogs = AppState.allLogs.filter(log => log.toUpperCase().includes(filter)); - } - - displayLogs(); -} - -function displayLogs() { - const logContent = document.getElementById('logContent'); - if (AppState.filteredLogs.length === 0) { - logContent.textContent = AppState.currentLogFilter === 'all' ? - '暂无日志...' : `暂无${AppState.currentLogFilter}级别的日志...`; - } else { - logContent.textContent = AppState.filteredLogs.join('\n'); - } -} - -// ===================================================================== -// 环境变量凭证管理 -// ===================================================================== -async function checkEnvCredsStatus() { - const loading = document.getElementById('envStatusLoading'); - const content = document.getElementById('envStatusContent'); - - try { - loading.style.display = 'block'; - content.classList.add('hidden'); - - const response = await fetch('./auth/env-creds-status', { headers: getAuthHeaders() }); - const data = await response.json(); - - if (response.ok) { - const envVarsList = document.getElementById('envVarsList'); - envVarsList.textContent = Object.keys(data.available_env_vars).length > 0 - ? Object.keys(data.available_env_vars).join(', ') - : '未找到GCLI_CREDS_*环境变量'; - - const autoLoadStatus = document.getElementById('autoLoadStatus'); - autoLoadStatus.textContent = data.auto_load_enabled ? '✅ 已启用' : '❌ 未启用'; - autoLoadStatus.style.color = data.auto_load_enabled ? '#28a745' : '#dc3545'; - - document.getElementById('envFilesCount').textContent = `${data.existing_env_files_count} 个文件`; - - const envFilesList = document.getElementById('envFilesList'); - envFilesList.textContent = data.existing_env_files.length > 0 - ? data.existing_env_files.join(', ') - : '无'; - - content.classList.remove('hidden'); - showStatus('环境变量状态检查完成', 'success'); - } else { - showStatus(`获取环境变量状态失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - loading.style.display = 'none'; - } -} - -async function loadEnvCredentials() { - try { - showStatus('正在从环境变量导入凭证...', 'info'); - - const response = await fetch('./auth/load-env-creds', { - method: 'POST', - headers: getAuthHeaders() - }); - - const data = await response.json(); - - if (response.ok) { - if (data.loaded_count > 0) { - showStatus(`✅ 成功导入 ${data.loaded_count}/${data.total_count} 个凭证文件`, 'success'); - setTimeout(() => checkEnvCredsStatus(), 1000); - } else { - showStatus(`⚠️ ${data.message}`, 'info'); - } - } else { - showStatus(`导入失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -async function clearEnvCredentials() { - if (!confirm('确定要清除所有从环境变量导入的凭证文件吗?\n这将删除所有文件名以 "env-" 开头的认证文件。')) { - return; - } - - try { - showStatus('正在清除环境变量凭证文件...', 'info'); - - const response = await fetch('./auth/env-creds', { - method: 'DELETE', - headers: getAuthHeaders() - }); - - const data = await response.json(); - - if (response.ok) { - showStatus(`✅ 成功删除 ${data.deleted_count} 个环境变量凭证文件`, 'success'); - setTimeout(() => checkEnvCredsStatus(), 1000); - } else { - showStatus(`清除失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -// ===================================================================== -// 配置管理 -// ===================================================================== -async function loadConfig() { - const loading = document.getElementById('configLoading'); - const form = document.getElementById('configForm'); - - try { - loading.style.display = 'block'; - form.classList.add('hidden'); - - const response = await fetch('./config/get', { headers: getAuthHeaders() }); - const data = await response.json(); - - if (response.ok) { - AppState.currentConfig = data.config; - AppState.envLockedFields = new Set(data.env_locked || []); - - populateConfigForm(); - form.classList.remove('hidden'); - showStatus('配置加载成功', 'success'); - } else { - showStatus(`加载配置失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - loading.style.display = 'none'; - } -} - -function populateConfigForm() { - const c = AppState.currentConfig; - - setConfigField('host', c.host || '0.0.0.0'); - setConfigField('port', c.port || 7861); - setConfigField('configApiPassword', c.api_password || ''); - setConfigField('configPanelPassword', c.panel_password || ''); - setConfigField('configPassword', c.password || 'pwd'); - setConfigField('credentialsDir', c.credentials_dir || ''); - setConfigField('proxy', c.proxy || ''); - setConfigField('codeAssistEndpoint', c.code_assist_endpoint || ''); - setConfigField('oauthProxyUrl', c.oauth_proxy_url || ''); - setConfigField('googleapisProxyUrl', c.googleapis_proxy_url || ''); - setConfigField('resourceManagerApiUrl', c.resource_manager_api_url || ''); - setConfigField('serviceUsageApiUrl', c.service_usage_api_url || ''); - setConfigField('antigravityApiUrl', c.antigravity_api_url || ''); - - document.getElementById('autoBanEnabled').checked = Boolean(c.auto_ban_enabled); - setConfigField('autoBanErrorCodes', (c.auto_ban_error_codes || []).join(',')); - setConfigField('callsPerRotation', c.calls_per_rotation || 10); - - document.getElementById('retry429Enabled').checked = Boolean(c.retry_429_enabled); - setConfigField('retry429MaxRetries', c.retry_429_max_retries || 20); - setConfigField('retry429Interval', c.retry_429_interval || 0.1); - - document.getElementById('compatibilityModeEnabled').checked = Boolean(c.compatibility_mode_enabled); - document.getElementById('returnThoughtsToFrontend').checked = Boolean(c.return_thoughts_to_frontend !== false); - document.getElementById('antigravityStream2nostream').checked = Boolean(c.antigravity_stream2nostream !== false); - document.getElementById('antigravitySwitchCredentialEnabled').checked = Boolean(c.antigravity_switch_credential_enabled); - - setConfigField('antiTruncationMaxAttempts', c.anti_truncation_max_attempts || 3); - - setConfigField('keepaliveUrl', c.keepalive_url || ''); - setConfigField('keepaliveInterval', c.keepalive_interval || 60); -} - -function setConfigField(fieldId, value) { - const field = document.getElementById(fieldId); - if (field) { - field.value = value; - const configKey = fieldId.replace(/([A-Z])/g, '_$1').toLowerCase(); - if (AppState.envLockedFields.has(configKey)) { - field.disabled = true; - field.classList.add('env-locked'); - } else { - field.disabled = false; - field.classList.remove('env-locked'); - } - } -} - -async function saveConfig() { - try { - const getValue = (id, def = '') => document.getElementById(id)?.value.trim() || def; - const getInt = (id, def = 0) => parseInt(document.getElementById(id)?.value) || def; - const getFloat = (id, def = 0.0) => parseFloat(document.getElementById(id)?.value) || def; - const getChecked = (id, def = false) => document.getElementById(id)?.checked || def; - - const config = { - host: getValue('host', '0.0.0.0'), - port: getInt('port', 7861), - api_password: getValue('configApiPassword'), - panel_password: getValue('configPanelPassword'), - password: getValue('configPassword', 'pwd'), - code_assist_endpoint: getValue('codeAssistEndpoint'), - credentials_dir: getValue('credentialsDir'), - proxy: getValue('proxy'), - oauth_proxy_url: getValue('oauthProxyUrl'), - googleapis_proxy_url: getValue('googleapisProxyUrl'), - resource_manager_api_url: getValue('resourceManagerApiUrl'), - service_usage_api_url: getValue('serviceUsageApiUrl'), - antigravity_api_url: getValue('antigravityApiUrl'), - auto_ban_enabled: getChecked('autoBanEnabled'), - auto_ban_error_codes: getValue('autoBanErrorCodes').split(',') - .map(c => parseInt(c.trim())).filter(c => !isNaN(c)), - calls_per_rotation: getInt('callsPerRotation', 10), - retry_429_enabled: getChecked('retry429Enabled'), - retry_429_max_retries: getInt('retry429MaxRetries', 20), - retry_429_interval: getFloat('retry429Interval', 0.1), - compatibility_mode_enabled: getChecked('compatibilityModeEnabled'), - return_thoughts_to_frontend: getChecked('returnThoughtsToFrontend'), - antigravity_stream2nostream: getChecked('antigravityStream2nostream'), - antigravity_switch_credential_enabled: getChecked('antigravitySwitchCredentialEnabled'), - anti_truncation_max_attempts: getInt('antiTruncationMaxAttempts', 3), - keepalive_url: getValue('keepaliveUrl'), - keepalive_interval: getInt('keepaliveInterval', 60) - }; - - const response = await fetch('./config/save', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ config }) - }); - - const data = await response.json(); - - if (response.ok) { - let message = '配置保存成功'; - - if (data.hot_updated && data.hot_updated.length > 0) { - message += `,以下配置已立即生效: ${data.hot_updated.join(', ')}`; - } - - if (data.restart_required && data.restart_required.length > 0) { - message += `\n⚠️ 重启提醒: ${data.restart_notice}`; - showStatus(message, 'info'); - } else { - showStatus(message, 'success'); - } - - setTimeout(() => loadConfig(), 1000); - } else { - showStatus(`保存配置失败: ${data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -// 镜像网址配置 -const mirrorUrls = { - codeAssistEndpoint: 'https://gcli-api.sukaka.top/cloudcode-pa', - oauthProxyUrl: 'https://gcli-api.sukaka.top/oauth2', - googleapisProxyUrl: 'https://gcli-api.sukaka.top/googleapis', - resourceManagerApiUrl: 'https://gcli-api.sukaka.top/cloudresourcemanager', - serviceUsageApiUrl: 'https://gcli-api.sukaka.top/serviceusage', - antigravityApiUrl: 'https://gcli-api.sukaka.top/daily-cloudcode-pa' -}; - -const officialUrls = { - codeAssistEndpoint: 'https://cloudcode-pa.googleapis.com', - oauthProxyUrl: 'https://oauth2.googleapis.com', - googleapisProxyUrl: 'https://www.googleapis.com', - resourceManagerApiUrl: 'https://cloudresourcemanager.googleapis.com', - serviceUsageApiUrl: 'https://serviceusage.googleapis.com', - antigravityApiUrl: 'https://daily-cloudcode-pa.googleapis.com' -}; - -function useMirrorUrls() { - if (confirm('确定要将所有端点配置为镜像网址吗?')) { - for (const [fieldId, url] of Object.entries(mirrorUrls)) { - const field = document.getElementById(fieldId); - if (field && !field.disabled) field.value = url; - } - showStatus('✅ 已切换到镜像网址配置,记得点击"保存配置"按钮保存设置', 'success'); - } -} - -function restoreOfficialUrls() { - if (confirm('确定要将所有端点配置为官方地址吗?')) { - for (const [fieldId, url] of Object.entries(officialUrls)) { - const field = document.getElementById(fieldId); - if (field && !field.disabled) field.value = url; - } - showStatus('✅ 已切换到官方端点配置,记得点击"保存配置"按钮保存设置', 'success'); - } -} - -// ===================================================================== -// 使用统计 -// ===================================================================== -async function refreshUsageStats() { - const loading = document.getElementById('usageLoading'); - const list = document.getElementById('usageList'); - - try { - loading.style.display = 'block'; - list.innerHTML = ''; - - const [statsResponse, aggregatedResponse] = await Promise.all([ - fetch('./usage/stats', { headers: getAuthHeaders() }), - fetch('./usage/aggregated', { headers: getAuthHeaders() }) - ]); - - if (statsResponse.status === 401 || aggregatedResponse.status === 401) { - showStatus('认证失败,请重新登录', 'error'); - setTimeout(() => location.reload(), 1500); - return; - } - - const statsData = await statsResponse.json(); - const aggregatedData = await aggregatedResponse.json(); - - if (statsResponse.ok && aggregatedResponse.ok) { - AppState.usageStatsData = statsData.success ? statsData.data : statsData; - - const aggData = aggregatedData.success ? aggregatedData.data : aggregatedData; - document.getElementById('totalApiCalls').textContent = aggData.total_calls_24h || 0; - document.getElementById('totalFiles').textContent = aggData.total_files || 0; - document.getElementById('avgCallsPerFile').textContent = (aggData.avg_calls_per_file || 0).toFixed(1); - - renderUsageList(); - - showStatus(`已加载 ${aggData.total_files || Object.keys(AppState.usageStatsData).length} 个文件的使用统计`, 'success'); - } else { - const errorMsg = statsData.detail || aggregatedData.detail || '加载使用统计失败'; - showStatus(`错误: ${errorMsg}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } finally { - loading.style.display = 'none'; - } -} - -function renderUsageList() { - const list = document.getElementById('usageList'); - list.innerHTML = ''; - - if (Object.keys(AppState.usageStatsData).length === 0) { - list.innerHTML = '

暂无使用统计数据

'; - return; - } - - for (const [filename, stats] of Object.entries(AppState.usageStatsData)) { - const card = document.createElement('div'); - card.className = 'usage-card'; - - const calls24h = stats.calls_24h || 0; - - card.innerHTML = ` -
-
${filename}
-
-
-
- 24小时内调用次数 - ${calls24h} -
-
-
- -
- `; - - list.appendChild(card); - } -} - -async function resetSingleUsageStats(filename) { - if (!confirm(`确定要重置 ${filename} 的使用统计吗?`)) return; - - try { - const response = await fetch('./usage/reset', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ filename }) - }); - - const data = await response.json(); - - if (response.ok && data.success) { - showStatus(data.message, 'success'); - await refreshUsageStats(); - } else { - showStatus(`重置失败: ${data.message || data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -async function resetAllUsageStats() { - if (!confirm('确定要重置所有文件的使用统计吗?此操作不可恢复!')) return; - - try { - const response = await fetch('./usage/reset', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({}) - }); - - const data = await response.json(); - - if (response.ok && data.success) { - showStatus(data.message, 'success'); - await refreshUsageStats(); - } else { - showStatus(`重置失败: ${data.message || data.detail || data.error || '未知错误'}`, 'error'); - } - } catch (error) { - showStatus(`网络错误: ${error.message}`, 'error'); - } -} - -// ===================================================================== -// 冷却倒计时自动更新 -// ===================================================================== -function startCooldownTimer() { - if (AppState.cooldownTimerInterval) { - clearInterval(AppState.cooldownTimerInterval); - } - - AppState.cooldownTimerInterval = setInterval(() => { - updateCooldownDisplays(); - }, 1000); -} - -function stopCooldownTimer() { - if (AppState.cooldownTimerInterval) { - clearInterval(AppState.cooldownTimerInterval); - AppState.cooldownTimerInterval = null; - } -} - -function updateCooldownDisplays() { - let needsRefresh = false; - - // 检查模型级冷却是否过期 - for (const credInfo of Object.values(AppState.creds.data)) { - if (credInfo.model_cooldowns && Object.keys(credInfo.model_cooldowns).length > 0) { - const currentTime = Date.now() / 1000; - const hasExpiredCooldowns = Object.entries(credInfo.model_cooldowns).some(([, until]) => until <= currentTime); - - if (hasExpiredCooldowns) { - needsRefresh = true; - break; - } - } - } - - if (needsRefresh) { - AppState.creds.renderList(); - return; - } - - // 更新模型级冷却的显示 - document.querySelectorAll('.cooldown-badge').forEach(badge => { - const card = badge.closest('.cred-card'); - const filenameEl = card?.querySelector('.cred-filename'); - if (!filenameEl) return; - - const filename = filenameEl.textContent; - const credInfo = Object.values(AppState.creds.data).find(c => c.filename === filename); - - if (credInfo && credInfo.model_cooldowns) { - const currentTime = Date.now() / 1000; - const titleMatch = badge.getAttribute('title')?.match(/模型: (.+)/); - if (titleMatch) { - const model = titleMatch[1]; - const cooldownUntil = credInfo.model_cooldowns[model]; - if (cooldownUntil) { - const remaining = Math.max(0, Math.floor(cooldownUntil - currentTime)); - if (remaining > 0) { - const shortModel = model.replace('gemini-', '').replace('-exp', '') - .replace('2.0-', '2-').replace('1.5-', '1.5-'); - const timeDisplay = formatCooldownTime(remaining).replace(/s$/, '').replace(/ /g, ''); - badge.innerHTML = `⏰ ${shortModel}: ${timeDisplay}`; - } - } - } - } - }); -} - -// ===================================================================== -// 版本信息管理 -// ===================================================================== - -// 获取并显示版本信息(不检查更新) -async function fetchAndDisplayVersion() { - try { - const response = await fetch('./version/info'); - const data = await response.json(); - - const versionText = document.getElementById('versionText'); - - if (data.success) { - // 只显示版本号 - versionText.textContent = `v${data.version}`; - versionText.title = `完整版本: ${data.full_hash}\n提交信息: ${data.message}\n提交时间: ${data.date}`; - versionText.style.cursor = 'help'; - } else { - versionText.textContent = '未知版本'; - versionText.title = data.error || '无法获取版本信息'; - } - } catch (error) { - console.error('获取版本信息失败:', error); - const versionText = document.getElementById('versionText'); - if (versionText) { - versionText.textContent = '版本信息获取失败'; - } - } -} - -// 检查更新 -async function checkForUpdates() { - const checkBtn = document.getElementById('checkUpdateBtn'); - if (!checkBtn) return; - - const originalText = checkBtn.textContent; - - try { - // 显示检查中状态 - checkBtn.textContent = '检查中...'; - checkBtn.disabled = true; - - // 调用API检查更新 - const response = await fetch('./version/info?check_update=true'); - const data = await response.json(); - - if (data.success) { - if (data.check_update === false) { - // 检查更新失败 - showStatus(`检查更新失败: ${data.update_error || '未知错误'}`, 'error'); - } else if (data.has_update === true) { - // 有更新 - const updateMsg = `发现新版本!\n当前: v${data.version}\n最新: v${data.latest_version}\n\n更新内容: ${data.latest_message || '无'}`; - showStatus(updateMsg.replace(/\n/g, ' '), 'warning'); - - // 更新按钮样式 - checkBtn.style.backgroundColor = '#ffc107'; - checkBtn.textContent = '有新版本'; - - setTimeout(() => { - checkBtn.style.backgroundColor = '#17a2b8'; - checkBtn.textContent = originalText; - }, 5000); - } else if (data.has_update === false) { - // 已是最新 - showStatus('已是最新版本!', 'success'); - - checkBtn.style.backgroundColor = '#28a745'; - checkBtn.textContent = '已是最新'; - - setTimeout(() => { - checkBtn.style.backgroundColor = '#17a2b8'; - checkBtn.textContent = originalText; - }, 3000); - } else { - // 无法确定 - showStatus('无法确定是否有更新', 'info'); - } - } else { - showStatus(`检查更新失败: ${data.error}`, 'error'); - } - } catch (error) { - console.error('检查更新失败:', error); - showStatus(`检查更新失败: ${error.message}`, 'error'); - } finally { - checkBtn.disabled = false; - if (checkBtn.textContent === '检查中...') { - checkBtn.textContent = originalText; - } - } -} - -// ===================================================================== -// 页面初始化 -// ===================================================================== -window.onload = async function () { - const autoLoginSuccess = await autoLogin(); - - if (!autoLoginSuccess) { - showStatus('请输入密码登录', 'info'); - } else { - // 登录成功后获取版本信息 - await fetchAndDisplayVersion(); - } - - startCooldownTimer(); - - const antigravityAuthBtn = document.getElementById('getAntigravityAuthBtn'); - if (antigravityAuthBtn) { - antigravityAuthBtn.addEventListener('click', startAntigravityAuth); - } -}; - -// 拖拽功能 - 初始化 -document.addEventListener('DOMContentLoaded', function () { - const uploadArea = document.getElementById('uploadArea'); - - if (uploadArea) { - uploadArea.addEventListener('dragover', (event) => { - event.preventDefault(); - uploadArea.classList.add('dragover'); - }); - - uploadArea.addEventListener('dragleave', (event) => { - event.preventDefault(); - uploadArea.classList.remove('dragover'); - }); - - uploadArea.addEventListener('drop', (event) => { - event.preventDefault(); - uploadArea.classList.remove('dragover'); - AppState.uploadFiles.addFiles(Array.from(event.dataTransfer.files)); - }); - } -}); - -function autoSetKeepaliveUrl() { - const url = `${window.location.protocol}//${window.location.host}`; - document.getElementById('keepaliveUrl').value = url; -} diff --git a/front/control_panel.html b/front/control_panel.html deleted file mode 100644 index 237826a24816ca913750253a21f2c3b683ec9e28..0000000000000000000000000000000000000000 --- a/front/control_panel.html +++ /dev/null @@ -1,2413 +0,0 @@ - - - - - - - GCLI2API 控制面板 - - - - -
- - - - - - -
- - - - - - - \ No newline at end of file diff --git a/front/control_panel_mobile.html b/front/control_panel_mobile.html deleted file mode 100644 index c146165ccab2731b9cea2a12e37f707a8634c8d4..0000000000000000000000000000000000000000 --- a/front/control_panel_mobile.html +++ /dev/null @@ -1,2140 +0,0 @@ - - - - - - - GCLI2API 移动端控制面板 - - - - -
- - - - - - -
- - - - - - \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..df5814506b73a6dcc709c08b3c114a1132061cc9 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/linguo2625469/workbuddy2api-panel + +go 1.22.5 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..ca30962c24ecc6c4b9bda493feb2ce6d99732e61 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 3c81816c6ec1ea5fb1aeb51a2995ee6be9b44807..0000000000000000000000000000000000000000 --- a/install.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -# 检测是否为管理员 -$IsElevated = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()). - IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - -# Skip Scoop install if already present to avoid stopping the script -if (Get-Command scoop -ErrorAction SilentlyContinue) { - Write-Host "Scoop is already installed. Skipping installation." -} else { - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force - if ($IsElevated) { - # 管理员:使用官方一行命令并传入 -RunAsAdmin - Invoke-Expression "& {$(Invoke-RestMethod get.scoop.sh)} -RunAsAdmin" - } else { - # 普通用户安装 - Invoke-WebRequest -useb get.scoop.sh | Invoke-Expression - } -} - -scoop install git uv -if (Test-Path -LiteralPath "./web.py") { - # Already in target directory; skip clone and cd -} -elseif (Test-Path -LiteralPath "./gcli2api/web.py") { - Set-Location ./gcli2api -} -else { - git clone https://github.com/su-kaka/gcli2api.git - Set-Location ./gcli2api -} -# Create relocatable virtual environment to ensure portability -$env:UV_VENV_CLEAR = "1" -uv venv --relocatable -uv sync -.venv/Scripts/activate.ps1 -python web.py \ No newline at end of file diff --git a/install.sh b/install.sh deleted file mode 100644 index c5372a1352b947e4741b91cc852dd8a2c59c82ff..0000000000000000000000000000000000000000 --- a/install.sh +++ /dev/null @@ -1,302 +0,0 @@ -#!/bin/bash -set -e # Exit on error -set -u # Exit on undefined variable -set -o pipefail # Exit on pipe failure - -# Color codes for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Logging functions -log_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" >&2 -} - -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -log_debug() { - echo -e "${BLUE}[DEBUG]${NC} $1" -} - -# Cleanup function for error handling -cleanup() { - local exit_code=$? - if [ $exit_code -ne 0 ]; then - log_error "Installation failed with exit code $exit_code" - fi - exit $exit_code -} - -trap cleanup EXIT - -# Detect OS and distribution -detect_os() { - log_info "Detecting operating system..." - - if [[ "$OSTYPE" == "linux-gnu"* ]]; then - if [ -f /etc/os-release ]; then - . /etc/os-release - OS_NAME=$ID - OS_VERSION=$VERSION_ID - log_info "Detected: $NAME $VERSION_ID" - elif [ -f /etc/lsb-release ]; then - . /etc/lsb-release - OS_NAME=$DISTRIB_ID - OS_VERSION=$DISTRIB_RELEASE - log_info "Detected: $DISTRIB_ID $DISTRIB_RELEASE" - else - OS_NAME="linux" - OS_VERSION="unknown" - log_warn "Could not determine specific Linux distribution" - fi - elif [[ "$OSTYPE" == "darwin"* ]]; then - OS_NAME="macos" - OS_VERSION=$(sw_vers -productVersion) - log_info "Detected: macOS $OS_VERSION" - elif [[ "$OSTYPE" == "freebsd"* ]]; then - OS_NAME="freebsd" - OS_VERSION=$(freebsd-version) - log_info "Detected: FreeBSD $OS_VERSION" - else - log_error "Unsupported operating system: $OSTYPE" - exit 1 - fi -} - -# Check for root privileges (only for Linux package managers that need it) -check_root_if_needed() { - if [[ "$OS_NAME" == "ubuntu" ]] || [[ "$OS_NAME" == "debian" ]] || [[ "$OS_NAME" == "linuxmint" ]] || [[ "$OS_NAME" == "kali" ]]; then - if [ "$EUID" -ne 0 ]; then - log_error "This script requires root privileges for apt. Please run with sudo." - exit 1 - fi - elif [[ "$OS_NAME" == "fedora" ]] || [[ "$OS_NAME" == "rhel" ]] || [[ "$OS_NAME" == "centos" ]] || [[ "$OS_NAME" == "rocky" ]] || [[ "$OS_NAME" == "almalinux" ]]; then - if [ "$EUID" -ne 0 ]; then - log_error "This script requires root privileges for dnf/yum. Please run with sudo." - exit 1 - fi - elif [[ "$OS_NAME" == "arch" ]] || [[ "$OS_NAME" == "manjaro" ]]; then - if [ "$EUID" -ne 0 ]; then - log_error "This script requires root privileges for pacman. Please run with sudo." - exit 1 - fi - fi -} - -# Update package manager -update_packages() { - log_info "Updating package manager..." - - case "$OS_NAME" in - ubuntu|debian|linuxmint|kali|pop) - if ! apt update; then - log_error "Failed to update apt package lists" - exit 1 - fi - ;; - fedora|rhel|centos|rocky|almalinux) - if command -v dnf &> /dev/null; then - if ! dnf check-update; then - # dnf check-update returns 100 if updates are available, which is not an error - if [ $? -ne 100 ]; then - log_warn "dnf check-update returned non-standard exit code" - fi - fi - else - if ! yum check-update; then - if [ $? -ne 100 ]; then - log_warn "yum check-update returned non-standard exit code" - fi - fi - fi - ;; - arch|manjaro) - if ! pacman -Syu; then - log_error "Failed to update pacman database" - exit 1 - fi - ;; - macos) - if command -v brew &> /dev/null; then - log_info "Updating Homebrew..." - brew update - else - log_warn "Homebrew not installed. Skipping package manager update." - fi - ;; - *) - log_warn "Unknown package manager for $OS_NAME. Skipping update." - ;; - esac -} - -# Install git based on OS -install_git() { - if ! command -v git &> /dev/null; then - log_info "Installing git..." - - case "$OS_NAME" in - ubuntu|debian|linuxmint|kali|pop) - if ! apt install git -y; then - log_error "Failed to install git" - exit 1 - fi - ;; - fedora|rhel|centos|rocky|almalinux) - if command -v dnf &> /dev/null; then - if ! dnf install git -y; then - log_error "Failed to install git" - exit 1 - fi - else - if ! yum install git -y; then - log_error "Failed to install git" - exit 1 - fi - fi - ;; - arch|manjaro) - if ! pacman -S git --noconfirm; then - log_error "Failed to install git" - exit 1 - fi - ;; - macos) - if command -v brew &> /dev/null; then - if ! brew install git; then - log_error "Failed to install git" - exit 1 - fi - else - log_error "Homebrew is required for macOS. Install from https://brew.sh/" - exit 1 - fi - ;; - *) - log_error "Don't know how to install git on $OS_NAME" - exit 1 - ;; - esac - else - log_info "Git is already installed ($(git --version))" - fi -} - -# Detect OS first -detect_os - -# Check root if needed -check_root_if_needed - -log_info "Starting installation process..." - -# Update package lists -update_packages - -# Install git -install_git - -# Install uv if not present -if ! command -v uv &> /dev/null; then - log_info "Installing uv package manager..." - if ! curl -Ls https://astral.sh/uv/install.sh | sh; then - log_error "Failed to install uv" - exit 1 - fi - - # Source environment - if [ -f "$HOME/.local/bin/env" ]; then - source "$HOME/.local/bin/env" - elif [ -f "$HOME/.cargo/env" ]; then - source "$HOME/.cargo/env" - fi - - # Verify uv installation - if ! command -v uv &> /dev/null; then - log_error "uv installation failed - command not found after install" - exit 1 - fi -else - log_info "uv is already installed" -fi - -# Determine working directory -log_info "Checking project directory..." -if [ -f "./web.py" ]; then - log_info "Already in target directory" -elif [ -f "./gcli2api/web.py" ]; then - log_info "Changing to gcli2api directory" - cd ./gcli2api || exit 1 -else - log_info "Cloning repository..." - if [ -d "./gcli2api" ]; then - log_warn "gcli2api directory exists but web.py not found. Removing and re-cloning..." - rm -rf ./gcli2api - fi - - if ! git clone https://github.com/su-kaka/gcli2api.git; then - log_error "Failed to clone repository" - exit 1 - fi - - cd ./gcli2api || exit 1 -fi - -# Update repository if it's a git repo -if [ -d ".git" ]; then - log_info "Updating repository..." - if ! git pull; then - log_warn "Git pull failed, continuing anyway..." - fi -else - log_warn "Not a git repository, skipping update" -fi - -# Create relocatable virtual environment to ensure portability -log_info "Creating relocatable virtual environment..." -export UV_VENV_CLEAR=1 -if ! uv venv --relocatable; then - log_error "Failed to create virtual environment" - exit 1 -fi - -# Sync dependencies -log_info "Syncing dependencies with uv..." -if ! uv sync; then - log_error "Failed to sync dependencies" - exit 1 -fi - -# Activate virtual environment -log_info "Activating virtual environment..." -if [ -f ".venv/bin/activate" ]; then - source .venv/bin/activate -else - log_error "Virtual environment not found at .venv/bin/activate" - exit 1 -fi - -# Verify Python is available -if ! command -v python3 &> /dev/null; then - log_error "python3 not found in virtual environment" - exit 1 -fi - -# Check if web.py exists -if [ ! -f "web.py" ]; then - log_error "web.py not found in current directory" - exit 1 -fi - -# Start the application -log_info "Starting application..." -python3 web.py \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000000000000000000000000000000000000..109dc89d4ed997ee6565500b4e4ae2b881098336 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,378 @@ +// Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态), +// 提供 refresh 后的原子写回。 +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" +) + +// Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。 +type Auth struct { + // mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。 + mu sync.Mutex + + AccessToken string + RefreshToken string + ExpiresAt int64 // Unix 秒 + Domain string + // realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。 + // 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。 + // + // 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用 + // 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。 + realm string + UID string + EnterpriseID string + Nickname string + FilePath string // 来源文件;refresh 后原子写回此处 + + // DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。 + // 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。 + // 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形 + // 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。 + DeviceToken string +} + +// Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。 +func (a *Auth) Lock() { a.mu.Lock() } + +// Unlock 释放 a.Lock 获取的锁。 +func (a *Auth) Unlock() { a.mu.Unlock() } + +// AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。 +// +// 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt +// (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造 +// (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models / +// global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发: +// Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关), +// 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的 +// 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。 +func (a *Auth) AccessTokenValue() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.AccessToken +} + +// DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。 +func (a *Auth) DomainValue() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.Domain +} + +// RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内 +// 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的 +// `a.RefreshToken == ""`)必须经此取值,勿直读字段。 +func (a *Auth) RefreshTokenValue() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.RefreshToken +} + +// globalEnabled 全局开关:global realm 是否路由(D5 双保险)。 +// 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/ +// domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭 +// → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai +// 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。 +var globalEnabled atomic.Bool + +func init() { globalEnabled.Store(true) } + +// SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。 +func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) } + +// GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。 +func GlobalEnabled() bool { return globalEnabled.Load() } + +// Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global", +// 否则 "cn"。显式 global 优先于 domain 回落(D1)。 +// 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。 +// 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。 +func (a *Auth) Realm() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.realmLocked() +} + +// realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入, +// 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内 +// 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。 +func (a *Auth) realmLocked() string { + if !globalEnabled.Load() { + return "cn" + } + if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) { + return "global" + } + return "cn" +} + +// ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断 +// (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定); +// domain 也为空 → "cn"(老 CN 凭证零回归)。 +func ResolveRealm(explicit, domain string) string { + if r := strings.TrimSpace(explicit); r != "" { + return r + } + if isGlobalDomain(domain) { + return "global" + } + return "cn" +} + +// BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」 +// 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。 +// +// 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门 +// (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证 +// (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。 +func (a *Auth) BackfillRealm() (bool, string) { + a.mu.Lock() + defer a.mu.Unlock() + if strings.TrimSpace(a.realm) != "" { + return false, a.realm + } + r := ResolveRealm("", a.Domain) + a.realm = r + return true, r +} + +// RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。 +func (a *Auth) RealmStored() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.realm +} + +// BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了 +// global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global, +// 非法值报错(防写脏)。返回是否发生变更。 +func BackfillRealmFor(a *Auth, realm string) (bool, error) { + if a == nil { + return false, fmt.Errorf("nil auth") + } + switch strings.TrimSpace(realm) { + case "cn", "global": + default: + return false, fmt.Errorf("realm must be cn/global, got %q", realm) + } + a.mu.Lock() + defer a.mu.Unlock() + if a.realm == realm { + return false, nil + } + a.realm = realm + return true, nil +} + +// IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。 +func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } + +// isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。 +// 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。 +func isGlobalDomain(d string) bool { + d = strings.ToLower(strings.TrimSpace(d)) + return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") +} + +// NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。 +func (a *Auth) NeedsRefresh(within time.Duration) bool { + a.mu.Lock() + defer a.mu.Unlock() + if a.ExpiresAt <= 0 { + return true + } + return time.Now().Add(within).Unix() >= a.ExpiresAt +} + +// Parse 兼容两种磁盘形态: +// +// 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出) +// 扁平形 {"accessToken":...,"uid":...} (手写/旧版) +func Parse(raw []byte) (*Auth, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty auth storage") + } + var probe map[string]json.RawMessage + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, fmt.Errorf("storage_parse_error: %w", err) + } + var a Auth + if _, nested := probe["auth"]; nested { + var n struct { + Auth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Domain string `json:"domain"` + Realm string `json:"realm"` + } `json:"auth"` + Account struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } `json:"account"` + // DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。 + DeviceToken string `json:"device_token"` + } + if err := json.Unmarshal(raw, &n); err != nil { + return nil, fmt.Errorf("storage_parse_error: %w", err) + } + a = Auth{ + AccessToken: n.Auth.AccessToken, + RefreshToken: n.Auth.RefreshToken, + ExpiresAt: n.Auth.ExpiresAt, + Domain: n.Auth.Domain, + realm: n.Auth.Realm, + UID: n.Account.UID, + EnterpriseID: n.Account.EnterpriseID, + Nickname: n.Account.Nickname, + DeviceToken: n.DeviceToken, + } + } else { + var f struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Domain string `json:"domain"` + Realm string `json:"realm"` + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + DeviceToken string `json:"device_token"` + } + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("storage_parse_error: %w", err) + } + a = Auth{ + AccessToken: f.AccessToken, + RefreshToken: f.RefreshToken, + ExpiresAt: f.ExpiresAt, + Domain: f.Domain, + realm: f.Realm, + UID: f.UID, + EnterpriseID: f.EnterpriseID, + Nickname: f.Nickname, + DeviceToken: f.DeviceToken, + } + } + if strings.TrimSpace(a.AccessToken) == "" { + return nil, fmt.Errorf("parse_error: missing accessToken") + } + return &a, nil +} + +// SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。 +// 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。 +// 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。 +func (a *Auth) SaveAtomic() error { + a.mu.Lock() + defer a.mu.Unlock() + if strings.TrimSpace(a.AccessToken) == "" { + return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID) + } + if a.FilePath == "" { + return fmt.Errorf("no FilePath set") + } + doc := map[string]any{ + "auth": map[string]any{ + "accessToken": a.AccessToken, + "refreshToken": a.RefreshToken, + "expiresAt": a.ExpiresAt, + "domain": a.Domain, + "realm": a.realm, + }, + "account": map[string]any{ + "uid": a.UID, + "enterpriseId": a.EnterpriseID, + "nickname": a.Nickname, + }, + } + // DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键 + // (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。 + if a.DeviceToken != "" { + doc["device_token"] = a.DeviceToken + } + raw, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + tmp := a.FilePath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + // Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001) + // 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。 + msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err) + if errors.Is(err, fs.ErrPermission) { + msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" + + "1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" + + "2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" + + "3) compose 设 user: \"0:0\" 以 root 运行)" + } + return errors.New(msg) + } + return os.Rename(tmp, a.FilePath) +} + +// LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。 +// 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic +// 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续), +// 避免历史 auth 目录个别文件不可写时整个服务起不来。 +func LoadDir(dir string) ([]*Auth, error) { + files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json")) + if err != nil { + return nil, err + } + // seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零) + // 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。 + seenUID := make(map[string]string, len(files)) + var out []*Auth + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + continue + } + a, err := Parse(raw) + if err != nil { + continue + } + a.FilePath = f + if prev, ok := seenUID[a.UID]; ok { + log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)", + logfmt.Label(a.UID, a.Nickname), prev, f) + } + seenUID[a.UID] = f + if a.RealmStored() == "" { + if changed, r := a.BackfillRealm(); changed { + if err := a.SaveAtomic(); err != nil { + log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err) + } else if r == "global" { + log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain) + } + } + } + out = append(out, a) + } + return out, nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..20254788ece4227ac5518bce46ef921d710edd0c --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,266 @@ +package auth + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseNested(t *testing.T) { + raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"domain":""},"account":{"uid":"u1","enterpriseId":"e1","nickname":"n1"}}`) + sa, err := Parse(raw) + if err != nil { + t.Fatalf("nested parse err: %v", err) + } + if sa.AccessToken != "at" || sa.RefreshToken != "rt" || sa.ExpiresAt != 1753600000 { + t.Errorf("tokens: %+v", sa) + } + if sa.UID != "u1" || sa.EnterpriseID != "e1" || sa.Nickname != "n1" { + t.Errorf("account: %+v", sa) + } +} + +func TestParseFlat(t *testing.T) { + raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"uid":"u2","nickname":"n2"}`) + sa, err := Parse(raw) + if err != nil || sa.UID != "u2" || sa.AccessToken != "at" { + t.Fatalf("flat: %+v %v", sa, err) + } +} + +func TestParseMissingToken(t *testing.T) { + if _, err := Parse([]byte(`{"uid":"u3"}`)); err == nil { + t.Fatal("want error for missing accessToken") + } +} + +func TestSaveAtomicRoundtrip(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "workbuddy-u1.json") + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000, + UID: "u1", EnterpriseID: "e1", Nickname: "n1", FilePath: fp} + if err := a.SaveAtomic(); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(fp + ".tmp"); !os.IsNotExist(err) { + t.Error("tmp file should not remain") + } + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if b.AccessToken != "at" || b.UID != "u1" || b.EnterpriseID != "e1" { + t.Errorf("roundtrip: %+v", b) + } +} + +// TestLoadDirLoadsAllValid 不再按 region 过滤:所有可解析的 auth 文件都被加载, +// 解析失败的文件静默跳过。 +func TestLoadDirLoadsAllValid(t *testing.T) { + dir := t.TempDir() + cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"cn1"}}` + other := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"example.com"},"account":{"uid":"u2"}}` + bad := `not json` + os.WriteFile(filepath.Join(dir, "workbuddy-cn1.json"), []byte(cn), 0o600) + os.WriteFile(filepath.Join(dir, "workbuddy-u2.json"), []byte(other), 0o600) + os.WriteFile(filepath.Join(dir, "workbuddy-bad.json"), []byte(bad), 0o600) + + list, err := LoadDir(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(list) != 2 { + t.Fatalf("want 2 valid accounts, got %+v", list) + } + for _, a := range list { + if a.FilePath == "" { + t.Error("FilePath not set") + } + } +} + +func TestNeedsRefresh(t *testing.T) { + a := &Auth{ExpiresAt: 0} + if !a.NeedsRefresh(0) { + t.Error("zero expiry should need refresh") + } + a.ExpiresAt = 9999999999 + if a.NeedsRefresh(0) { + t.Error("far future should not need refresh") + } +} + +// TestParseDeviceToken 嵌套形与扁平形 auth 文件的顶层 device_token 键均被解析。 +func TestParseDeviceToken(t *testing.T) { + nested := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":""},"account":{"uid":"u1"},"device_token":"dev-tok-nested"}`) + sa, err := Parse(nested) + if err != nil { + t.Fatalf("nested parse: %v", err) + } + if sa.DeviceToken != "dev-tok-nested" { + t.Errorf("nested DeviceToken = %q want %q", sa.DeviceToken, "dev-tok-nested") + } + + flat := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","device_token":"dev-tok-flat"}`) + fa, err := Parse(flat) + if err != nil { + t.Fatalf("flat parse: %v", err) + } + if fa.DeviceToken != "dev-tok-flat" { + t.Errorf("flat DeviceToken = %q want %q", fa.DeviceToken, "dev-tok-flat") + } +} + +// TestSaveAtomicPreservesDeviceToken SaveAtomic 写回后顶层 device_token 被保留并重新解析回来。 +func TestSaveAtomicPreservesDeviceToken(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "workbuddy-dt.json") + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, + UID: "u1", DeviceToken: "persisted-tok", FilePath: fp} + if err := a.SaveAtomic(); err != nil { + t.Fatalf("save: %v", err) + } + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if b.DeviceToken != "persisted-tok" { + t.Errorf("roundtrip DeviceToken = %q want %q", b.DeviceToken, "persisted-tok") + } +} + +// TestLoadDirBackfillsRealm 存量迁移:LoadDir 加载目录时对空 realm 的 auth 自动 +// backfill + SaveAtomic;已有 realm 的保持原值(不被 domain 覆盖);文件全部带标识。 +func TestLoadDirBackfillsRealm(t *testing.T) { + t.Parallel() + dir := t.TempDir() + fixtures := map[string]string{ + "workbuddy-g1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`, + "workbuddy-c1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"c1"}}`, + // 已有 realm 的不因 domain 变化被覆盖:global domain + 显式 cn → 保持 cn + "workbuddy-c2.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai","realm":"cn"},"account":{"uid":"c2"}}`, + } + for name, body := range fixtures { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + + list, err := LoadDir(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(list) != 3 { + t.Fatalf("want 3 accounts, got %d", len(list)) + } + want := map[string]string{"g1": "global", "c1": "cn", "c2": "cn"} + for _, a := range list { + // 内存态已补标识 + if got := a.RealmStored(); got != want[a.UID] { + t.Errorf("uid=%s in-memory realm=%q want %q", a.UID, got, want[a.UID]) + } + // 落盘文件也带 realm 键 + raw, err := os.ReadFile(a.FilePath) + if err != nil { + t.Fatalf("read %s: %v", a.FilePath, err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse %s: %v", a.FilePath, err) + } + if got := b.RealmStored(); got != want[a.UID] { + t.Errorf("uid=%s on-disk realm=%q want %q", a.UID, got, want[a.UID]) + } + } +} + +// TestLoadDirBackfillWriteFailureDoesNotBlock 单个文件 backfill 落盘失败(tmp 预置目录 +// 使 WriteFile 失败)不阻断启动:其他文件照常迁移,LoadDir 不向上抛错。 +// (历史纯 CN auth 目录一次性迁移时,个别文件不可写不应让整个服务起不来。) +func TestLoadDirBackfillWriteFailureDoesNotBlock(t *testing.T) { + t.Parallel() + dir := t.TempDir() + good := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}` + if err := os.WriteFile(filepath.Join(dir, "workbuddy-g1.json"), []byte(good), 0o600); err != nil { + t.Fatal(err) + } + // 预置同名 .tmp 目录 → SaveAtomic 的 os.WriteFile(".tmp") 报 is a directory。 + if err := os.Mkdir(filepath.Join(dir, "workbuddy-c1.json.tmp"), 0o700); err != nil { + t.Fatal(err) + } + bad := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1},"account":{"uid":"c1"}}` + if err := os.WriteFile(filepath.Join(dir, "workbuddy-c1.json"), []byte(bad), 0o600); err != nil { + t.Fatal(err) + } + + list, err := LoadDir(dir) + if err != nil { + t.Fatalf("load err=%v want nil (write failure must not block startup)", err) + } + if len(list) != 2 { + t.Fatalf("want 2 accounts loaded, got %d", len(list)) + } + // 好文件迁移成功 + raw, _ := os.ReadFile(filepath.Join(dir, "workbuddy-g1.json")) + b, _ := Parse(raw) + if b.RealmStored() != "global" { + t.Errorf("good file realm=%q want global (migration should succeed)", b.RealmStored()) + } +} + +// TestLoadDirDuplicateUIDWarning 同 UID 双 realm auth 文件(概率近零的 EDGE):LoadDir +// 检测到重复 UID 时打 WARN(含两文件路径),且不改变加载行为——后载入者胜出(返回 1 个、 +// 不 panic、realm 为后载入者值)。LoadDir 现在有额外 seenUID 副作用,逐字验证 WARN。 +func TestLoadDirDuplicateUIDWarning(t *testing.T) { + dir := t.TempDir() + // 同一 UID u9 的两个文件:cn realm 文件按文件名排序在前(workbuddy-a-...), + // global realm 文件在后 → 后载入者(global)胜出。 + cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":"www.codebuddy.cn"},"account":{"uid":"u9"}}` + gl := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"u9"}}` + if err := os.WriteFile(filepath.Join(dir, "workbuddy-a-cn.json"), []byte(cn), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "workbuddy-z-global.json"), []byte(gl), 0o600); err != nil { + t.Fatal(err) + } + + // 捕获 log 输出(本测试不 t.Parallel:log.SetOutput 是进程级全局,需串行)。 + old := log.Writer() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + log.SetOutput(w) + + list, err := LoadDir(dir) + _ = w.Close() + raw, _ := io.ReadAll(r) + log.SetOutput(old) + + if err != nil { + t.Fatalf("load err=%v", err) + } + // 行为稳定(不改加载结果):LoadDir 返回全部可解析文件(去重发生在 pool.SyncToDir + // 的 UID 键 upsert),不 panic。 + if len(list) != 2 { + t.Fatalf("want 2 accounts loaded (dedup later in pool), got %d", len(list)) + } + // WARN 已触发且含两文件路径。 + if !strings.Contains(string(raw), "WARN: uid") || + !strings.Contains(string(raw), "duplicated") || + !strings.Contains(string(raw), "workbuddy-a-cn.json") || + !strings.Contains(string(raw), "workbuddy-z-global.json") { + t.Errorf("expected WARN with both paths, got output: %s", string(raw)) + } +} diff --git a/internal/auth/permhint_test.go b/internal/auth/permhint_test.go new file mode 100644 index 0000000000000000000000000000000000000000..120447006c5ea235ec9d2d6995c8577bab324c61 --- /dev/null +++ b/internal/auth/permhint_test.go @@ -0,0 +1,29 @@ +package auth + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestSaveAtomicPermissionHint 无写权限目录下保存,错误应包含 Docker chown 指引。 +func TestSaveAtomicPermissionHint(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("Windows 无 POSIX 权限语义 / root 无权限限制,跳过") + } + dir := t.TempDir() + ro := filepath.Join(dir, "ro") + os.MkdirAll(ro, 0o555) // 只读目录 + defer os.Chmod(ro, 0o755) + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, + UID: "u1", FilePath: filepath.Join(ro, "workbuddy-u1.json")} + err := a.SaveAtomic() + if err == nil { + t.Fatal("只读目录保存应失败") + } + if !strings.Contains(err.Error(), "chown") || !strings.Contains(err.Error(), "10001") { + t.Errorf("权限错误应包含 Docker 指引,实际: %v", err) + } +} diff --git a/internal/auth/realm_test.go b/internal/auth/realm_test.go new file mode 100644 index 0000000000000000000000000000000000000000..94b02b65b6eeb3b69891bfe847fa5e35e5db145c --- /dev/null +++ b/internal/auth/realm_test.go @@ -0,0 +1,275 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +// withGlobalEnabled 临时打开 global realm 开关(生产缺省即开,此辅助仅显式确保), +// 测试结束复位为开启态(缺省)。 +func withGlobalEnabled(t *testing.T) { + t.Helper() + globalEnabled.Store(true) + t.Cleanup(func() { globalEnabled.Store(true) }) +} + +// withGlobalDisabled 临时关闭 global realm 开关(逃生门),测试结束复位为开启态(缺省)。 +func withGlobalDisabled(t *testing.T) { + t.Helper() + globalEnabled.Store(false) + t.Cleanup(func() { globalEnabled.Store(true) }) +} + +func TestRealmExplicitGlobal(t *testing.T) { + withGlobalEnabled(t) + a := &Auth{realm: "global"} + if got := a.Realm(); got != "global" { + t.Errorf("Realm()=%q want global", got) + } + if !a.IsGlobal() { + t.Error("IsGlobal()=false want true") + } +} + +func TestRealmExplicitCN(t *testing.T) { + a := &Auth{realm: "cn"} + if got := a.Realm(); got != "cn" { + t.Errorf("Realm()=%q want cn", got) + } + if a.IsGlobal() { + t.Error("IsGlobal()=true want false") + } +} + +func TestRealmDomainFallback(t *testing.T) { + withGlobalEnabled(t) + cases := []struct{ domain, want string }{ + {"www.workbuddy.ai", "global"}, + {"workbuddy.ai", "global"}, + {"sub.workbuddy.ai", "global"}, + {"www.codebuddy.cn", "cn"}, + {"", "cn"}, + } + for _, c := range cases { + a := &Auth{Domain: c.domain} + if got := a.Realm(); got != c.want { + t.Errorf("Domain=%q Realm()=%q want %q", c.domain, got, c.want) + } + } +} + +func TestRealmEmptyFallsBackToCN(t *testing.T) { + // 开关缺省开启(零回归前提):空 realm + 空 domain → cn(老 CN 凭证的核心)。 + a := &Auth{} + if got := a.Realm(); got != "cn" { + t.Errorf("Realm()=%q want cn", got) + } + // 显式 global → global(缺省开启,Realm() 不再因"未配置"而恒 cn)。 + ag := &Auth{realm: "global"} + if got := ag.Realm(); got != "global" { + t.Errorf("Realm()=%q want global", got) + } + // domain 回落照常(缺省开启识别 workbuddy.ai)。 + ad := &Auth{Domain: "www.workbuddy.ai"} + if got := ad.Realm(); got != "global" { + t.Errorf("Realm()=%q want global", got) + } +} + +// TestRealmDefaultOnForCNZeroRegression 开关缺省开启时,老 CN 凭证(无 realm、无 domain) +// Realm() 恒为 cn——「默认开启」不影响纯 CN 部署行为。 +func TestRealmDefaultOnForCNZeroRegression(t *testing.T) { + withGlobalEnabled(t) + cases := []*Auth{ + {}, + {Domain: "www.codebuddy.cn"}, + {Domain: "codebuddy.cn"}, + {realm: "cn"}, + {realm: "cn", Domain: "www.codebuddy.cn"}, + } + for _, a := range cases { + if got := a.Realm(); got != "cn" { + t.Errorf("%+v Realm()=%q want cn", a, got) + } + if a.IsGlobal() { + t.Errorf("%+v IsGlobal()=true want false", a) + } + } +} + +// TestRealmExplicitOffEscapeHatch 逃生门:SetGlobalEnabled(false) 后恒 cn, +// 即便 realm=global / domain=workbuddy.ai(纯 CN 锁定,与旧缺省行为等价)。 +func TestRealmExplicitOffEscapeHatch(t *testing.T) { + withGlobalDisabled(t) + cases := []struct { + auth *Auth + }{ + {&Auth{realm: "global"}}, + {&Auth{realm: "global", Domain: "www.workbuddy.ai"}}, + {&Auth{Domain: "www.workbuddy.ai"}}, + } + for _, tc := range cases { + if got := tc.auth.Realm(); got != "cn" { + t.Errorf("%+v Realm()=%q want cn (switch off)", tc.auth, got) + } + if tc.auth.IsGlobal() { + t.Errorf("%+v IsGlobal()=true want false (switch off)", tc.auth) + } + } +} + +func TestParseNestedRealm(t *testing.T) { + raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":"www.workbuddy.ai","realm":"global"},"account":{"uid":"u1"}}`) + sa, err := Parse(raw) + if err != nil { + t.Fatalf("nested parse err: %v", err) + } + if sa.realm != "global" { + t.Errorf("nested realm=%q want global", sa.realm) + } + // 嵌套形显式 realm 空 → 读不到,靠 domain 回落。 + raw2 := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1},"account":{"uid":"u1"}}`) + sa2, err := Parse(raw2) + if err != nil { + t.Fatalf("nested no-realm parse err: %v", err) + } + if sa2.realm != "" { + t.Errorf("nested missing realm key should be zero, got %q", sa2.realm) + } +} + +func TestParseFlatRealm(t *testing.T) { + withGlobalEnabled(t) + raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","realm":"global"}`) + fa, err := Parse(raw) + if err != nil { + t.Fatalf("flat parse err: %v", err) + } + if fa.realm != "global" { + t.Errorf("flat realm=%q want global", fa.realm) + } + if !fa.IsGlobal() { + t.Error("flat global account IsGlobal()=false want true") + } + // 扁平形缺 realm 键 → 零值 → CN。 + flatCN := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u3"}`) + fc, err := Parse(flatCN) + if err != nil { + t.Fatalf("flat cn parse err: %v", err) + } + if fc.realm != "" { + t.Errorf("flat missing realm should be zero, got %q", fc.realm) + } +} + +func TestSaveAtomicWritesRealm(t *testing.T) { + withGlobalEnabled(t) + dir := t.TempDir() + fp := filepath.Join(dir, "workbuddy-global.json") + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, + UID: "g1", realm: "global", FilePath: fp} + if err := a.SaveAtomic(); err != nil { + t.Fatalf("save: %v", err) + } + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if b.realm != "global" { + t.Errorf("roundtrip realm=%q want global", b.realm) + } + if !b.IsGlobal() { + t.Error("roundtrip global account IsGlobal()=false") + } +} + +// TestBackfillRealmDomain CN/global 按原始 domain 推断(backfill 为导出空 realm 字段服务)。 +func TestBackfillRealmDomain(t *testing.T) { + t.Parallel() // 不触碰全局开关 + cases := []struct { + domain string + want string + }{ + {"www.workbuddy.ai", "global"}, + {"workbuddy.ai", "global"}, + {"sub.workbuddy.ai", "global"}, + {"www.codebuddy.cn", "cn"}, + {"codebuddy.cn", "cn"}, + {"", "cn"}, // 空 domain + 空 realm → cn(老 CN 凭证核心) + } + for _, c := range cases { + a := &Auth{Domain: c.domain} + changed, got := a.BackfillRealm() + if !changed { + t.Errorf("Domain=%q backfill changed=false want true", c.domain) + } + assertRealmStored(a, c.want, t) + if got != c.want { + t.Errorf("Domain=%q backfill got realm=%q want %q", c.domain, got, c.want) + } + } +} + +// TestBackfillRealmEscapeHatchFree 逃生门关闭时 backfill 仍按原始 domain 推断(不受 Realm() 降级影响)。 +func TestBackfillRealmEscapeHatchFree(t *testing.T) { + withGlobalDisabled(t) // 逃生门关闭:Realm() 恒 cn,但 backfill 不得被污染 + a := &Auth{Domain: "www.workbuddy.ai"} + if g := a.Realm(); g != "cn" { + t.Fatalf("precondition Realm()=%q want cn (escape hatch on)", g) + } + changed, got := a.BackfillRealm() + if !changed { + t.Fatal("backfill changed=false want true (escape hatch free)") + } + assertRealmStored(a, "global", t) + if got != "global" { + t.Errorf("backfill got=%q want global (escape hatch must not corrupt backfill)", got) + } +} + +// TestBackfillRealmIdempotent 已有 realm 标识的文件不做修改(幂等)。 +func TestBackfillRealmIdempotent(t *testing.T) { + t.Parallel() + a := &Auth{Domain: "www.workbuddy.ai", realm: "cn"} // 已有 cn,domain 会推断 global——绝不覆盖 + changed, got := a.BackfillRealm() + if changed { + t.Errorf("backfill changed=true want false (existing realm must win)") + } + if got != "cn" { + t.Errorf("backfill got=%q want cn (existing realm preserved)", got) + } + assertRealmStored(a, "cn", t) +} + +func assertRealmStored(a *Auth, want string, t *testing.T) { + t.Helper() + if a.realm != want { + t.Errorf("stored realm field=%q want %q", a.realm, want) + } +} + +// TestResolveRealm 纯函数归一化显式 realm,缺失时按 domain 推断(与 BackfillRealm +// 共用同一来源;不受逃生门影响)。显式值优先于 domain 推断。 +func TestResolveRealm(t *testing.T) { + t.Parallel() // 纯函数:不触碰全局开关 + cases := []struct { + explicit, domain, want string + }{ + {"global", "www.codebuddy.cn", "global"}, // 显式优先:cn domain 也写 global + {"cn", "www.workbuddy.ai", "cn"}, // 显式优先:global domain 也写 cn + {"", "www.workbuddy.ai", "global"}, // 缺省按 domain 推断 + {"", "workbuddy.ai", "global"}, + {"", "codebuddy.cn", "cn"}, + {"", "", "cn"}, // 空 domain → cn(老 CN 凭证零回归) + } + for _, c := range cases { + if got := ResolveRealm(c.explicit, c.domain); got != c.want { + t.Errorf("ResolveRealm(%q,%q)=%q want %q", c.explicit, c.domain, got, c.want) + } + } +} diff --git a/internal/httpauth/httpauth.go b/internal/httpauth/httpauth.go new file mode 100644 index 0000000000000000000000000000000000000000..b1a7fc36b0cc40eac193b418ef31dacc83cec30d --- /dev/null +++ b/internal/httpauth/httpauth.go @@ -0,0 +1,43 @@ +// Package httpauth 网关与面板共用的 Bearer 鉴权原语。 +// +// 单独成包的原因:server(/v1/*、/status)与 panel(/panel/api/*)两处鉴权 +// 必须完全同口径——此前各自复制了一份"字符串直接比较"的实现,既容易漂移, +// 又都带计时侧信道。统一到这里后,口径只有一份,且天然常量时间比较。 +package httpauth + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + "strings" +) + +// bearerPrefix 认证方案前缀(大小写敏感,与 HTTP 规范及既有实现一致)。 +const bearerPrefix = "Bearer " + +// VerifyBearer 校验请求头是否携带正确的 Bearer 密钥。 +// +// key 为空表示"未启用鉴权",恒返回 true(调用方据此放行)。 +// 比较用 SHA-256 摘要 + subtle.ConstantTimeCompare: +// - 常量时间,不因前缀匹配长度而泄露信息; +// - 先摘要再比较,长度差异被吸收进摘要(不会因长度不同提前返回); +// - 摘要本身不可逆,即便有侧信道也拿不到密钥原文。 +func VerifyBearer(r *http.Request, key string) bool { + if key == "" { + return true + } + authz := r.Header.Get("Authorization") + if !strings.HasPrefix(authz, bearerPrefix) { + // 缺头/方案不对:仍走一次摘要比较,保持耗时形状一致。 + subtle.ConstantTimeCompare(digest(""), digest(key)) + return false + } + tok := authz[len(bearerPrefix):] + return subtle.ConstantTimeCompare(digest(tok), digest(key)) == 1 +} + +// digest 返回 s 的 SHA-256(定长 32 字节,供常量时间比较)。 +func digest(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} diff --git a/internal/httpauth/httpauth_test.go b/internal/httpauth/httpauth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bf1ab10a77822e989d8ecbfd5b2138f504912f0b --- /dev/null +++ b/internal/httpauth/httpauth_test.go @@ -0,0 +1,61 @@ +package httpauth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func req(authz string) *http.Request { + r := httptest.NewRequest("GET", "/", nil) + if authz != "" { + r.Header.Set("Authorization", authz) + } + return r +} + +func TestVerifyBearer(t *testing.T) { + cases := []struct { + name string + key string + authz string + want bool + }{ + {"空 key 放行(未启用鉴权)", "", "", true}, + {"空 key 也放行任意头", "", "Bearer whatever", true}, + {"正确 key", "sk-abc123", "Bearer sk-abc123", true}, + {"错误 key", "sk-abc123", "Bearer sk-wrong", false}, + {"缺 Authorization 头", "sk-abc123", "", false}, + {"缺 Bearer 前缀", "sk-abc123", "sk-abc123", false}, + {"前缀大小写不符(规范要求精确)", "sk-abc123", "bearer sk-abc123", false}, + {"多余空格", "sk-abc123", "Bearer sk-abc123", false}, + {"前缀相同但内容短", "sk-abc123", "Bearer sk-abc12", false}, + {"前缀相同但内容长", "sk-abc123", "Bearer sk-abc1234", false}, + {"key 恰好是前缀", "sk-abc", "Bearer sk-abcdef", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := VerifyBearer(req(c.authz), c.key); got != c.want { + t.Errorf("VerifyBearer(key=%q, authz=%q) = %v, want %v", c.key, c.authz, got, c.want) + } + }) + } +} + +// TestVerifyBearerWithoutHeaderStillCompares 缺头路径不应因"提前返回"而暴露形状差异: +// 这里只验证它确实返回 false 且不 panic(常量时间的性质无法用单测断言,靠实现保证)。 +func TestVerifyBearerWithoutHeaderStillCompares(t *testing.T) { + if VerifyBearer(req(""), "any-key") { + t.Error("missing header must not pass") + } +} + +func TestDigestIsFixedLength(t *testing.T) { + // 不同长度输入摘要后应等长(这是常量时间比较的前提) + if len(digest("")) != len(digest("a-much-longer-secret-value")) { + t.Error("digest length must not depend on input length") + } + if len(digest("x")) != 32 { + t.Errorf("sha256 digest length = %d, want 32", len(digest("x"))) + } +} diff --git a/internal/livecfg/livecfg.go b/internal/livecfg/livecfg.go new file mode 100644 index 0000000000000000000000000000000000000000..0db0c3412edf04990f13d072e8d40588e244f7ef --- /dev/null +++ b/internal/livecfg/livecfg.go @@ -0,0 +1,48 @@ +// Package livecfg 运行期可变配置的并发安全持有者。 +// +// 背景:进程启动时读入的配置是普通字段(读多写零),但管理面板允许在线改配置, +// 于是少量"可热生效"的字段需要有并发安全的读写点。此处用不可变快照 + atomic 指针: +// 读方 Load 拿到一致视图,写方 Store 整体替换,无锁无数据竞争。 +// +// 只承载**读路径深、热改需求强**的少数字段;池参数/排程参数等各有既有 setter +// (pool.SetBreaker、scheduler.Reconfigure 等),不重复收编到这里。 +package livecfg + +import ( + "sync/atomic" + "time" +) + +// Snapshot 一次读取的不可变配置视图。 +type Snapshot struct { + APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 + SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) + SanitizeFingerprints bool // 出站请求体指纹脱敏 + RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) +} + +// Holder 原子持有当前快照。 +type Holder struct { + p atomic.Pointer[Snapshot] +} + +// New 以初始快照构建。 +func New(s Snapshot) *Holder { + h := &Holder{} + h.Store(s) + return h +} + +// Load 返回当前快照(Holder 为 nil 或从未 Store 时返回零值快照,调用方无需判空)。 +func (h *Holder) Load() Snapshot { + if h == nil { + return Snapshot{} + } + if s := h.p.Load(); s != nil { + return *s + } + return Snapshot{} +} + +// Store 整体替换快照。 +func (h *Holder) Store(s Snapshot) { h.p.Store(&s) } diff --git a/internal/logfmt/logfmt.go b/internal/logfmt/logfmt.go new file mode 100644 index 0000000000000000000000000000000000000000..e4d01c0824617bbe58c838bfbfd2c087ad43e05f --- /dev/null +++ b/internal/logfmt/logfmt.go @@ -0,0 +1,166 @@ +// Package logfmt 统一网关日志的 uid 截断与模块前缀约定。 +// +// 约定: +// - uid 统一截 8 位:与 chat 流水行(internal/server/logging.go uidPrefix)对齐, +// 日志行只留 uid 前 8 位。全量 uid 可从 data/state.json 查(54 个号无 8 位前缀碰撞)。 +// - 模块前缀:调度四类已有天然前缀(travel/activity/checkin/keepalive)保持; +// 其他补 [pool]/[auth]/[server] 等 [mod] 方括号前缀,redisstore/session 已有保持。 +// - 级别语义:正常流转不打级别字样(保持简洁);可疑/降级/失败行加 WARN:/ERR: 前缀。 +// +// 本包不引入日志库,只提供 UID8 截断 / Label 账号标签 / Pad 显示宽对齐三个纯字符串 +// helper,供各包替代裸写 [:8] 与手算表格列宽(防 uid 短于 8 越界、防中文昵称错位)。 +package logfmt + +import ( + "strings" + "unicode/utf8" +) + +// Truncate 截断字符串到 n 字节上限(先 TrimSpace,与旧 upstream/内部实现口径 +// 一致),切点落在多字节字符中间时回退到 UTF-8 rune 边界——错误 body 多为中文 +// ("将在 … 重置"),按字节切会出半截序列乱码。短于 n 原样返回;n<=0 返回空串。 +func Truncate(s string, n int) string { + if n <= 0 { + return "" + } + s = strings.TrimSpace(s) + if len(s) > n { + // s[n] 是切点后的首字节:是 rune 的后续字节(continuation)说明切点落在 + // 多字节字符中间,逐字节回退到 rune 边界(该字符整个让出)。 + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] + } + return s +} + +// UID8 返回 uid 的前 8 位;空 uid 返回 "-"(与 server.uidPrefix 对齐)。 +// +// 用于调度类与非调度类日志行,把 : ... 改为 : ... +// 全量 uid 留在 state.json 供排查,日志里 8 位足够唯一定位。 +func UID8(uid string) string { + if uid == "" { + return "-" + } + if len(uid) > 8 { + return uid[:8] + } + return uid +} + +// Label 返回日志里的账号标签,形如 "示例昵称甲(a1b2c3d4)";昵称为空时退回 "a1b2c3d4"。 +// +// 为什么需要:uid8 是机器标识,排障时人眼无法直接判断"刚才那个 429/6004 是哪个号", +// 必须再拿 uid8 去 auths/ 或 data/state.json 反查昵称,一条日志要多跳一步。昵称随 +// 登录落在 auths/.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的 +// 标签——昵称认人、uid8 供 grep,两者都保留。 +// +// uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。 +func Label(uid, nick string) string { + short := UID8(uid) + nick = strings.TrimSpace(nick) + if nick == "" { + return short + } + return nick + "(" + short + ")" +} + +// DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。 +// +// 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占 +// 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数, +// 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。 +func DisplayWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。 +// 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。 +func Pad(s string, width int) string { + if width <= 0 { + return s + } + if d := width - DisplayWidth(s); d > 0 { + return s + strings.Repeat(" ", d) + } + return s +} + +// maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、 +// "python-requests/2.31.0" 这类常见客户端标签)。 +const maxShortUALen = 40 + +// shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA +// 恒定包含它们,拿它当客户端标签等于没信息。 +var shortUAEngines = map[string]bool{ + "mozilla": true, "applewebkit": true, "gecko": true, "khtml": true, + "like": true, "safari": true, "compatible": true, "msie": true, "trident": true, +} + +// ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、 +// "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。 +// +// 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄 +// 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端", +// 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。 +// +// 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串 +// 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。 +func ShortUA(ua string) string { + ua = strings.TrimSpace(ua) + if ua == "" { + return "" + } + for _, tok := range strings.Fields(ua) { + tok = strings.Trim(tok, "(),;") + name, _, ok := strings.Cut(tok, "/") + if !ok || name == "" { + continue + } + if shortUAEngines[strings.ToLower(name)] { + continue + } + return Truncate(tok, maxShortUALen) + } + return Truncate(ua, maxShortUALen) +} + +// runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的 +// Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。 +func runeWidth(r rune) int { + switch { + case r == 0: + return 0 + case r < 0x20 || (r >= 0x7f && r < 0xa0): + // 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。 + return 0 + case r < 0x1100: + return 1 + case r <= 0x115f: // Hangul Jamo 初声 + return 2 + case r == 0x2329 || r == 0x232a: + return 2 + case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符) + return 2 + case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节 + return 2 + case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意 + return 2 + case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式 + return 2 + case r >= 0xff00 && r <= 0xff60: // 全角 ASCII + return 2 + case r >= 0xffe0 && r <= 0xffe6: // 全角符号 + return 2 + case r >= 0x1f300 && r <= 0x1f9ff: // emoji + return 2 + case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后 + return 2 + } + return 1 +} diff --git a/internal/logfmt/shortua_test.go b/internal/logfmt/shortua_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ab0882fe38a43681dc47391770a09a9eb373ce4c --- /dev/null +++ b/internal/logfmt/shortua_test.go @@ -0,0 +1,50 @@ +package logfmt + +import "testing" + +func TestShortUA(t *testing.T) { + cases := []struct { + name string + ua string + want string + }{ + {"empty", "", ""}, + {"blank", " ", ""}, + {"curl", "curl/8.4.0", "curl/8.4.0"}, + {"python", "python-requests/2.31.0", "python-requests/2.31.0"}, + {"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"}, + {"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"}, + { + "chrome skips engine tokens", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Chrome/120.0.0.0", + }, + { + "firefox", + "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Firefox/121.0", + }, + {"no version token", "node", "node"}, + {"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ShortUA(tc.ua); got != tc.want { + t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want) + } + }) + } +} + +// ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。 +func TestShortUABounded(t *testing.T) { + long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3" + if got := ShortUA(long); len(got) > maxShortUALen { + t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got) + } + // 没有 name/version token 时回落整串,同样受上限约束。 + got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy") + if len(got) > maxShortUALen { + t.Fatalf("fallback not truncated: %d", len(got)) + } +} diff --git a/internal/panel/app.js b/internal/panel/app.js new file mode 100644 index 0000000000000000000000000000000000000000..00b571d8db1ae916df19d2b2a6dbda9b58111437 --- /dev/null +++ b/internal/panel/app.js @@ -0,0 +1,2604 @@ +'use strict'; +/* ── 状态 ─────────────────────────────────────────────────────────── */ +const LS_KEY = 'wb2api.key', LS_THEME = 'wb2api.theme'; +let theme = localStorage.getItem(LS_THEME) || 'auto'; // auto | light | dark +let view = 'accounts'; +let overviewData = null, cfgLoaded = null; +let logPin = true, loginState = null, loginTimer = null; +let refTimer = null; +/* 视图级筛选状态(模块级声明放在文件顶部,避免顶层 go() 早于声明执行时踩 TDZ)。 */ +let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; +let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined; +let reqFilter = { q: '', outcome: '' }; +let reqEntries = []; +let usDim = 'account', usCreditDim = 'account', usSort = 'total'; +let usageData = null; +let reqRangeState = null; // 请求记录的时间范围(用量页的见 trangeState) + +const $ = id => document.getElementById(id); + +/* ── 主题 ─────────────────────────────────────────────────────────── */ +/* 两态翻转(浅/深),首次访问跟随系统偏好;点击总是切换可见外观,符合直觉。 */ +function effTheme() { + return theme === 'auto' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : theme; +} +function applyTheme() { + const eff = effTheme(); + document.documentElement.dataset.theme = eff; + $('icoTheme').innerHTML = eff === 'light' + ? '' + : ''; + $('btnTheme').title = eff === 'light' ? '切换到深色' : '切换到浅色'; +} +addEventListener('change', applyTheme); +$('btnTheme').onclick = () => { + theme = effTheme() === 'light' ? 'dark' : 'light'; + localStorage.setItem(LS_THEME, theme); + applyTheme(); +}; +applyTheme(); + +/* ── 请求 ─────────────────────────────────────────────────────────── */ +async function api(path, opts = {}) { + const h = Object.assign({}, opts.headers || {}); + const k = localStorage.getItem(LS_KEY); + if (k) h['Authorization'] = 'Bearer ' + k; + if (opts.body) h['Content-Type'] = 'application/json'; + const r = await fetch('/panel/api/' + path, Object.assign({}, opts, { headers: h })); + if (r.status === 401) { openKey(); throw new Error('密钥无效或未填写'); } + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); + return d; +} +function toast(msg, cls) { + const el = document.createElement('div'); + el.className = 'tst ' + (cls || ''); + el.textContent = msg; + $('toasts').appendChild(el); + setTimeout(() => el.remove(), 3600); +} +// esc 文本/属性双安全转义。不能只用 div.innerHTML(它转义 <>& 但不转义引号), +// 否则字符串拼进 HTML 属性(如 title="uid: ...")时引号可闭合属性并注入事件处理器。 +// 显式替换 5 个字符:& < > " '(& 必须最先,避免二次转义)。 +function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} +function ago(iso) { + if (!iso || iso.startsWith('0001-')) return '—'; + const s = (Date.now() - new Date(iso)) / 1000; + if (s < 0) return '刚刚'; + if (s < 60) return Math.floor(s) + ' 秒前'; + if (s < 3600) return Math.floor(s / 60) + ' 分钟前'; + if (s < 86400) return Math.floor(s / 3600) + ' 小时前'; + return Math.floor(s / 86400) + ' 天前'; +} +function dur(sec) { + sec = Math.max(0, Math.round(sec)); + const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; + return h ? h + '时' + String(m).padStart(2, '0') + '分' : m ? m + '分' + String(s).padStart(2, '0') + '秒' : s + '秒'; +} +function parseAPITime(value) { + const text = String(value || ''); + if (!text || text.startsWith('0001-')) return 0; + const ms = Date.parse(text); + return Number.isFinite(ms) ? ms : 0; +} +function fmtLocalDateTime(ms) { + const d = new Date(ms); + const p = n => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()); +} + +/* ── 时间范围控件(用量 / 请求记录共用)──────────────────────────────── + 预设项:今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / 自定义。 + + 为什么区间一律由前端算好再发: + - 「今天」必须是**浏览器本地时区**的 00:00 起。服务端时区未必与浏览器一致 + (容器常挂 TZ=Asia/Shanghai,而浏览器可能在任何时区),让服务端算"今天" + 会在跨时区时切错日子。 + - 「自定义」本来就是用户挑的具体时刻,没有任何服务端推导空间。 + + 滚动预设(近 N 小时/天)则保留 hours 参数:服务端按整点对齐的滚动窗口与旧 + 行为逐位一致,前端自己减 N 小时会多算/少算一个边界桶。 */ +const TRANGE_PRESETS = [ + ['today', '今天'], + ['24', '近 24 小时'], + ['72', '近 3 天'], + ['168', '近 7 天'], + ['720', '近 30 天'], + ['0', '全部历史'], + ['custom', '自定义…'], +]; +const TRANGE_DEFAULT = '72'; +const trangeStates = new Map(); // hostId → { preset, from: Date|null, to: Date|null } + +// dtLocalValue / dtLocalParse 与 的取值格式互转 +// (YYYY-MM-DDTHH:mm,本地时区;ES 里"带时间的日期串"按本地解析,正是我们要的)。 +function dtLocalValue(d) { + const p = n => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T' + + p(d.getHours()) + ':' + p(d.getMinutes()); +} +function dtLocalParse(s) { + if (!s) return null; + const d = new Date(s); + return isNaN(d.getTime()) ? null : d; +} + +// trangeMidnight 今天 00:00(本地时区)。 +function trangeMidnight() { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +function trangeState(id) { + if (!trangeStates.has(id)) { + // 「自定义」的初始值给一段有意义的默认:今天 00:00 → 现在。 + trangeStates.set(id, { preset: TRANGE_DEFAULT, from: trangeMidnight(), to: new Date() }); + } + return trangeStates.get(id); +} + +// trangeRender 画出控件骨架(幂等:重复调用会保留当前状态)。 +function trangeRender(id) { + const host = $(id); + if (!host) return; + const st = trangeState(id); + const custom = st.preset === 'custom'; + host.innerHTML = + '' + + '' + + '' + + '→' + + '' + + ''; + const preset = host.querySelector('.tr-preset'); + if (preset) preset.onchange = () => { + st.preset = preset.value; + // 从别的预设切到自定义时,把区间重置为"今天 00:00 → 现在", + // 免得用户上次留下的半年区间被无声沿用。 + if (st.preset === 'custom' && (!st.from || !st.to)) { st.from = trangeMidnight(); st.to = new Date(); } + trangeRender(id); + trangeEmit(id); + }; + const fromEl = host.querySelector('.tr-from'); + const toEl = host.querySelector('.tr-to'); + const readCustom = () => { + st.from = dtLocalParse(fromEl.value); + st.to = dtLocalParse(toEl.value); + // 起止颠倒就地标红(不静默纠正:用户可能正输到一半)。 + const bad = st.from && st.to && st.from > st.to; + fromEl.classList.toggle('tr-bad', !!bad); + toEl.classList.toggle('tr-bad', !!bad); + if (bad) return; + trangeEmit(id); + }; + if (fromEl) fromEl.onchange = readCustom; + if (toEl) toEl.onchange = readCustom; +} + +const trangeHandlers = new Map(); +// trangeBind 渲染控件并登记变化回调。**不**在绑定时触发回调:各视图的首次加载 +// 由 go() 统一驱动,这里再触发一次会让打开页面时打两遍接口。 +function trangeBind(id, onChange, preset) { + trangeHandlers.set(id, onChange); + if (preset) trangeState(id).preset = preset; + trangeRender(id); +} +function trangeEmit(id) { + const fn = trangeHandlers.get(id); + if (fn) fn(); +} + +// trangeQuery 把当前选择翻译成查询参数。 +// rolling=true → 滚动预设发 hours(服务端整点对齐),今天/自定义发 from/to +// rolling=false → 一律发 from/to(归档是线性日志,前端算区间更直观) +// 「全部历史」两者都不发。 +function trangeQuery(id, rolling) { + const st = trangeState(id); + const q = new URLSearchParams(); + const sec = d => Math.floor(d.getTime() / 1000); + if (st.preset === 'custom') { + if (st.from) q.set('from', sec(st.from)); + if (st.to) q.set('to', sec(st.to)); + return q; + } + if (st.preset === 'today') { + q.set('from', sec(trangeMidnight())); + return q; + } + if (st.preset === '0') return q; + if (rolling) { q.set('hours', st.preset); return q; } + q.set('from', sec(new Date(Date.now() - Number(st.preset) * 3600 * 1000))); + return q; +} + +// trangeLabel 人读口径,用于「用量总览」右上角这类需要回显区间的位置。 +function trangeLabel(id) { + const st = trangeState(id); + const found = TRANGE_PRESETS.find(p => p[0] === st.preset); + if (st.preset !== 'custom') return found ? found[1] : ''; + if (!st.from && !st.to) return '自定义'; + const f = d => d ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + ' ' + + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') : '…'; + return f(st.from) + ' → ' + f(st.to); +} +function rateLimitMeta(row, now) { + const model = String(row && row.model || '未知模型'); + const kind = String(row && row.kind || 'rate_limit'); + const resetAt = parseAPITime(row && row.reset_at); + const until = parseAPITime(row && row.until); + const deadline = resetAt || until; + const remaining = deadline > now ? Math.round((deadline - now) / 1000) : 0; + if (kind === 'model_unavailable') { + return { + model, + kind, + detail: remaining ? '预计 ' + dur(remaining) + ' 后重试' : '等待重新探测', + title: model + '\n模型当前不可用' + (deadline ? '\n最早重试:' + fmtLocalDateTime(deadline) : ''), + }; + } + let detail = resetAt + ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') + : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); + const title = [model, resetAt ? '上游重置:' + fmtLocalDateTime(resetAt) : '上游重置:时间未知']; + if (until && resetAt && until < resetAt) { + detail += ' · 网关最快 ' + dur(Math.max(0, Math.round((until - now) / 1000))) + ' 后重试'; + title.push('网关最早重试:' + fmtLocalDateTime(until)); + } + return { model, kind, detail, title: title.join('\n') }; +} +function rateLimitRowsHtml(rows, now) { + const list = Array.isArray(rows) ? rows.filter(row => row && row.model) : []; + if (!list.length) return ''; + return '
' + list.map(row => { + const m = rateLimitMeta(row, now); + return '
' + esc(m.model) + '' + esc(m.detail) + '
'; + }).join('') + '
'; +} + +function formatTokenCount(tokens) { + if (tokens == null || tokens === '') return '—'; + const n = Number(tokens); + if (!Number.isFinite(n) || n < 0) return '—'; + if (n < 1000) return String(Math.round(n)); + const units = [['k', 1e3], ['m', 1e6], ['b', 1e9]]; + let unit = units[0]; + for (const candidate of units) { + if (n >= candidate[1]) unit = candidate; + } + let value = n / unit[1]; + let rounded = Number(value.toFixed(1)); + // 999999 → 1m,而不是 1000k;四舍五入后自动升级单位。 + const next = units[units.indexOf(unit) + 1]; + if (next && rounded >= 1000) { + unit = next; + value = n / unit[1]; + rounded = Number(value.toFixed(1)); + } + return rounded + unit[0]; +} + +function formatLatency(ms) { + if (ms == null || ms === '') return '—'; + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return '—'; + return n < 1000 ? Math.round(n) + 'ms' : (n / 1000).toFixed(1).replace(/\.0$/, '') + 's'; +} +function formatRate(rate) { + if (rate == null || rate === '') return '—'; + const n = Number(rate); + if (!Number.isFinite(n) || n < 0) return '—'; + return n.toFixed(1) + 'tok/s'; +} + +/* ── 密钥门 ───────────────────────────────────────────────────────── */ +function openKey() { $('keyVeil').classList.add('on'); setTimeout(() => $('keyInput').focus(), 60); } +$('btnKey').onclick = async () => { + const v = $('keyInput').value.trim(); + if (!v) return; + localStorage.setItem(LS_KEY, v); + try { + await api('overview'); + $('keyErr').hidden = true; + $('keyVeil').classList.remove('on'); + start(); + } catch (e) { $('keyErr').hidden = false; } +}; +$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('btnKey').click(); }); + +/* ── 路由 ─────────────────────────────────────────────────────────── */ +const TITLES = { accounts: '账号池', usage: '用量', packages: '积分构成', taskscenter: '任务中心', models: '模型与档位', config: '配置', logs: '运行日志' }; +function go(v) { + view = v; + document.querySelectorAll('.view').forEach(s => s.hidden = s.id !== 'view-' + v); + document.querySelectorAll('.nav a').forEach(a => a.classList.toggle('on', a.dataset.view === v)); + $('ttl').textContent = TITLES[v]; + if (v === 'models' && !$('mdBody').children.length) loadModels(); + if (v === 'config') loadConfig(); + if (v === 'logs') loadLogs(); + if (v === 'usage') loadUsage(); + if (v === 'packages') loadPackages(); + if (v === 'taskscenter') reattachQueueView(); +} +document.querySelectorAll('.nav a').forEach(a => a.onclick = e => { e.preventDefault(); go(a.dataset.view); history.replaceState(null, '', '#' + a.dataset.view); }); +/* 首次进入延到本轮脚本求值之后再 go()。 + 原因:go() 会同步触发视图的数据加载(loadUsage/loadLogs/loadPackages…),而这些 + 函数读到的模块级 let/const(usageRateWarmAt、PK_* 等)在文件后半段才初始化—— + 直接深链 #usage / #packages 打开页面时会踩 TDZ("Cannot access 'x' before + initialization"),表现为该页永远显示"读取失败",而点导航进去一切正常。 + 延迟 0ms 让整份脚本先求值完,是修这一类问题最省事也最不容易再犯的办法。 */ +setTimeout(() => { + const hash = (location.hash || '#accounts').slice(1); + go(hash in TITLES ? hash : 'accounts'); +}, 0); + +/* ── 账号池 ───────────────────────────────────────────────────────── */ +function renderAccounts(list) { + const tb = $('accBody'); + if (!list.length) { + tb.innerHTML = '
账号池是空的
点击右上角「添加账号」,用浏览器登录一个 WorkBuddy 账号
'; + return; + } + // 有总额度(credits_total)→ 进度条按自身 剩余/总额 百分比;旧数据无总额 → 退回池内最高=100% + const maxCred = Math.max(1, ...list.map(s => s.credits || 0)); + tb.innerHTML = list.map(s => { + const bl = (new Date(s.breaker_until || 0) - Date.now()) / 1000; + const dg = (new Date(s.degrade_until || 0) - Date.now()) / 1000; + const cool = Math.max(s.cool_remaining_sec || 0, bl > 0 ? bl : 0, dg > 0 ? dg : 0); + let cls = '', tag; + if (s.disabled) { cls = 'off'; tag = '已禁用'; } + else if (cool > 0) { + cls = 'cool'; + const kind = bl > Math.max(s.cool_remaining_sec || 0, dg > 0 ? dg : 0) ? '熔断' + : (dg > (s.cool_remaining_sec || 0) ? '连败降权' : (s.cool_kind === 'hard_credit' ? '积分冷却' : '限流冷却')); + tag = '' + kind + ' · ' + dur(cool) + ''; + } else tag = '可用' + (s.in_flight ? '' : ''); + const note = s.reason ? '
' + esc(s.reason) + '
' : ''; + const rateLimits = rateLimitRowsHtml(s.rate_limited_models, Date.now()); + const short = s.uid.length > 16 ? s.uid.slice(0, 16) + '…' : s.uid; + const cred = s.credits == null ? '—' : (s.credits_total > 0 ? s.credits + '/' + s.credits_total + '' : String(s.credits)); + const pct = s.credits_total > 0 + ? Math.min(100, Math.round((s.credits || 0) / s.credits_total * 100)) + : Math.round((s.credits || 0) / maxCred * 100); + // 成本台账 tooltip(model_costs):每模型实测单价(≤0 = 实测免费),运维据此 + // 看「为什么总选它」——免费号垄断 / 单价排序一眼可见。 + let credTip = s.credits_total > 0 ? '剩余 ' + s.credits + ' / 总额 ' + s.credits_total + '(' + pct + '%)' : '积分(相对池内最高)'; + const costs = (s.model_costs || []).filter(c => c.model); + if (costs.length) { + credTip += '\n实测单价(credits/1K):\n' + costs.map(c => + ' ' + c.model + ':' + (c.cost_per_1k <= 0 ? '免费' : c.cost_per_1k)).join('\n'); + } + const frozen = s.disabled || cool > 0; + const tu = s.token_usage || {}; + const req = tu.request_count || 0; + const totalTok = formatTokenCount(tu.total_tokens); + const totalTokUnit = totalTok === '—' ? '' : 'tok'; + const latency = formatLatency(tu.last_latency_ms); + const rate = formatRate(tu.last_tokens_per_second); + const usageTitle = '最近一次:' + req + ' 次 / ' + totalTok + ' / 延迟 ' + latency + ' / ' + rate; + return '' + + '' + + '
' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + '
' + esc(short) + '
' + + '' + tag + note + rateLimits + '' + + '
' + cred + '
' + + '' + (s.success_count || 0) + ' / ' + (s.err_total || 0) + '' + + '' + (s.in_flight || 0) + '' + + '' + + '' + req + '次' + + '' + totalTok + '' + totalTokUnit + '' + + '' + latency + '' + + '' + rate + '' + + '' + + '' + ago(s.last_success) + '' + + '' + + '' + + '' + + '' + + (frozen ? '' + : '') + + '' + + ''; + }).join(''); +} + +async function loadOverview(quiet) { + try { + const d = await api('overview'); + overviewData = d; + $('sTotal').textContent = d.total; + $('sHealthy').textContent = d.healthy; + $('sCooling').textContent = d.cooling; + $('sDisabled').textContent = d.disabled; + const remSum = (d.accounts || []).reduce((a, s) => a + (s.credits || 0), 0); + const totSum = (d.accounts || []).reduce((a, s) => a + (s.credits_total || 0), 0); + $('sCredits').textContent = totSum > 0 ? remSum + ' / ' + totSum : remSum; + $('sSticky').textContent = d.sticky_sessions; + $('navSub').textContent = 'v' + d.version; + $('navVer').textContent = 'v' + d.version; + $('navRedis').textContent = d.redis_mode === 'upstash' ? 'Redis 镜像' : '本地内存'; + $('navState').textContent = d.healthy > 0 ? '服务正常' : (d.total ? '无可用账号' : '待添加账号'); + const p = $('navPulse'); + p.className = 'pulse' + (d.healthy > 0 ? '' : (d.total ? ' warn' : ' bad')); + $('accNote').textContent = d.in_flight_full ? d.in_flight_full + ' 个账号在途占满' : ''; + const up = Math.floor(d.uptime_sec); + $('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分'; + renderAccounts(d.accounts || []); + } catch (e) { if (!quiet) toast(e.message, 'err'); } +} + +$('accBody').addEventListener('click', async ev => { + const b = ev.target.closest('button[data-a]'); + if (!b) return; + const u = b.dataset.u, a = b.dataset.a; + if (a === 'remove' && !confirm('移除账号将删除池状态与 auths/ 下的凭证文件,且不可恢复。确认移除?')) return; + if (a === 'disable' && !confirm('禁用后该账号不再参与选号,需手动解冻才能恢复。确认禁用?')) return; + b.disabled = true; + try { + if (a === 'checkin') { + const r = await api('accounts/' + encodeURIComponent(u) + '/checkin', { method: 'POST' }); + toast('签到完成' + (r.credits != null ? ',积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + (r.checkin_message ? '(' + r.checkin_message + ')' : ''), 'ok'); + } else if (a === 'balance') { + const r = await api('accounts/' + encodeURIComponent(u) + '/balance', { method: 'POST' }); + toast('余额已更新:' + r.credits + (r.credits_total > 0 ? ' / ' + r.credits_total : ''), 'ok'); + } else if (a === 'revive') { + await api('accounts/' + encodeURIComponent(u) + '/revive', { method: 'POST' }); + toast('已解冻', 'ok'); + } else if (a === 'disable') { + await api('accounts/' + encodeURIComponent(u) + '/disable', { method: 'POST' }); + toast('已禁用', 'ok'); + } else if (a === 'tasks') { + openTasks(u); + } else if (a === 'remove') { + const r = await api('accounts/' + encodeURIComponent(u) + '/remove', { method: 'POST' }); + toast(r.file_error ? '已移除(凭证文件删除失败:' + r.file_error + ')' : '已移除', 'ok'); + } + } catch (e) { toast(e.message, 'err'); } + finally { b.disabled = false; loadOverview(true); } +}); + +$('btnCheckinAll').onclick = async () => { + try { await api('checkin_all', { method: 'POST' }); toast('全部签到已开始,结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; +$('btnKeepaliveAll').onclick = async () => { + try { await api('keepalive_all', { method: 'POST' }); toast('全部保活已开始,结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; +$('btnTravelAll').onclick = async () => { + try { await api('travel_all', { method: 'POST' }); toast('旅行巡检已开始(含领养链路),结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; +$('btnActivityAll').onclick = async () => { + try { await api('activity_all', { method: 'POST' }); toast('活跃上报已开始,结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; + +/* ── 模型 ─────────────────────────────────────────────────────────── */ +/* 实测上限标注:scripts/probe_max_tokens.py --panel-out 写入探测结果, + /panel/api/model_probes 只读透传。探测键带域前缀(cn:glm-5.2),模型表 + 显示裸名,按「精确命中或 :后缀」关联。无数据时本列退回上游声称值。 */ +function fmtK(n) { n = Number(n || 0); return n >= 1000 ? Math.round(n / 1000) + 'K' : String(n); } +function probeDays(ts) { + if (!ts) return null; + const t = new Date(String(ts).replace(' ', 'T')); + const d = (Date.now() - t.getTime()) / 86400000; + return isNaN(d) ? null : Math.floor(d); +} +function outCell(m, pr) { + if (!pr) return '' + (m.max_output_tokens ? fmtK(m.max_output_tokens) : '—') + ''; + const tip = '声称 ' + (pr.claimed ? fmtK(pr.claimed) : '?') + ' · 实测 ' + (pr.measured ? fmtK(pr.measured) : '?') + + (pr.note ? ' · ' + pr.note : '') + (pr.tested_at ? ' · 探测于 ' + pr.tested_at : ''); + const days = probeDays(pr.tested_at); + const stale = days !== null && days > 30 ? ' · ' + days + ' 天前' : ''; + if (pr.verdict === 'clamped' && pr.measured) { + if (pr.claimed && pr.measured < pr.claimed) { + const x = pr.claimed / pr.measured; + const xs = (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + '×'; + return '' + + fmtK(pr.measured) + ' ⚠
钳制 ' + xs + stale + '
'; + } + return '' + fmtK(pr.measured) + + (pr.claimed && pr.measured > pr.claimed ? ' ↑' : ' ✓') + ''; + } + if (pr.verdict === 'at_least' && pr.measured) + return '≥' + fmtK(pr.measured) + ''; + return '?
未测出' + stale + '
'; +} + +/* rateCell 倍率列:牌价 vs 生效价。上游 credits 是牌价(转正后基准倍率), + modelPromotions 给当前生效折扣(限时免费 factor=0 / 夜间五折 0.5 等)—— + WorkBuddy 客户端显示的正是生效价。有折扣:生效价大字 + 标签 + 划线牌价, + 悬停带时段说明;无 factor 只有标签(错峰类):牌价 + 标签。 */ +function rateCell(m) { + const tip = m.promo_note ? ' title="' + esc(m.promo_note) + '"' : ''; + if (m.promo_factor != null && m.promo_credits) { + const base = m.credits ? ' ' + esc(m.credits) + '' : ''; + const label = m.promo_label ? ' ' + esc(m.promo_label) + '' : ''; + return '' + esc(m.promo_credits) + '' + label + base + ''; + } + if (m.promo_label) { + return '' + (m.credits ? esc(m.credits) : '—') + + ' ' + esc(m.promo_label) + ''; + } + return m.credits ? esc(m.credits) : '—'; +} + +async function loadModels() { + const tb = $('mdBody'); + tb.innerHTML = '
正在向上游查询…
'; + try { + // 探测数据是可选增强:拉取失败不影响模型列表本身 + const [d, pr] = await Promise.all([api('models'), api('model_probes').catch(() => ({}))]); + mdAll = d.models || []; + mdProbes = pr.probes || {}; + if (!mdAll.length) { + tb.innerHTML = '
上游未返回模型
'; + $('mdCount').textContent = ''; + $('mdNote').textContent = '上游未返回模型'; + return; + } + // 探测键带域前缀(cn:glm-5.2),模型表显示裸名,按「精确命中或 :后缀」关联。 + const probeKeys = Object.keys(mdProbes); + mdProbeOf = id => mdProbes[id] || mdProbes[probeKeys.find(k => k.endsWith(':' + id))]; + const hit = mdAll.filter(m => mdProbeOf(m.id)).length; + $('mdNote').textContent = mdAll.length + ' 个模型 · 已刷新降级缓存' + (hit ? ' · ' + hit + ' 个有实测上限' : ''); + renderModels(); + } catch (e) { + mdAll = []; + tb.innerHTML = '
' + esc(e.message) + '
'; + $('mdCount').textContent = ''; + } +} + +/* ── 模型筛选(按条件查询)─────────────────────────────────────────── + 模型目录一次拉全(几十条),筛选与排序全部在前端完成:改条件零延迟,且不会 + 因为调一次筛选就打一次上游——/panel/api/models 是直连上游的实时查询,很贵。 + 条件之间是 AND;每个条件为空即不参与判定。 */ +// mdRateValue 当前生效的积分倍率数值:优先促销价(限时免费 = 0),无倍率记为 +// Infinity 排到最后(排序时"没有价格"不该冒充最便宜)。 +function mdRateValue(m) { + const raw = (m.promo_credits != null && m.promo_credits !== '') ? m.promo_credits : m.credits; + const n = parseFloat(String(raw == null ? '' : raw).replace(/[^\d.]/g, '')); + return Number.isFinite(n) ? n : Infinity; +} + +// mdSearchText 参与关键字搜索的字段(ID / 展示名 / 厂商 / 描述 / 标签)。 +function mdSearchText(m) { + return [m.id, m.name, m.vendor, m.description, (m.tags || []).join(' ')] + .filter(Boolean).join(' ').toLowerCase(); +} + +// mdMatch 单个模型是否满足全部筛选条件。 +function mdMatch(m, f) { + f = f || mdFilter; + if (f.q) { + const text = mdSearchText(m); + // 空格分词后逐个匹配:多关键词是 AND,便于"cn 视觉"这类组合查询。 + for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) { + if (!text.includes(kw)) return false; + } + } + if (f.realm && !String(m.id || '').startsWith(f.realm + ':')) return false; + if (f.cap === 'tool' && !m.supports_tool_call) return false; + if (f.cap === 'vision' && !m.supports_images) return false; + if (f.cap === 'reasoning' && !m.supports_reasoning) return false; + if (f.cap === 'default' && !m.is_default) return false; + if (f.effort === 'off') { + if (!m.can_disable_thinking) return false; + } else if (f.effort && !(m.supported_efforts || []).includes(f.effort)) { + return false; + } + const factor = m.promo_factor == null ? null : Number(m.promo_factor); + if (f.promo === 'promo' && factor == null && !m.promo_label) return false; + if (f.promo === 'free' && !(factor === 0)) return false; + if (f.promo === 'discount' && !(factor != null && factor > 0)) return false; + return true; +} + +// mdSortList 按当前排序条件返回新数组(不改动入参,保持上游原始顺序可回溯)。 +function mdSortList(list, f) { + f = f || mdFilter; + const out = list.slice(); + const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; + if (f.sort === 'rate') out.sort((a, b) => mdRateValue(a) - mdRateValue(b)); + else if (f.sort === 'context') out.sort((a, b) => num(b.context_length) - num(a.context_length)); + else if (f.sort === 'output') out.sort((a, b) => num(b.max_output_tokens) - num(a.max_output_tokens)); + else if (f.sort === 'name') out.sort((a, b) => String(a.id || '').localeCompare(String(b.id || ''))); + return out; +} + +// mdRowHtml 单个模型行(纯渲染,便于独立测试)。 +function mdRowHtml(m, pr) { + const eff = (m.supported_efforts || []).slice(); + if (m.can_disable_thinking && eff.length && !eff.includes('off')) eff.push('off(可关)'); + const effs = eff.length ? eff.map(e => '' + esc(e) + '').join(' ') + : '' + (m.supports_reasoning ? '固定档 · 默认 ' + esc(m.default_effort || '?') : '不支持思考') + ''; + // 能力徽标:默认模型 / 工具调用 / 视觉 / 纯推理(上游目录全字段透出,缺失不显示) + const caps = []; + if (m.is_default) caps.push('默认'); + if (m.supports_tool_call) caps.push('工具'); + if (m.supports_images) caps.push('视觉'); + if (m.supports_reasoning && !m.can_disable_thinking) caps.push('思考常开'); + const capHtml = caps.length ? '
' + caps.join(' ') + '
' : ''; + const tip = m.description ? ' title="' + esc(m.description) + '"' : ''; + return '
' + esc(m.id) + '
' + esc(m.name || '') + '
' + capHtml + '' + + '' + rateCell(m) + '' + + '' + (m.default_effort ? '' + esc(m.default_effort) + '' : '—') + '' + + '' + effs + '' + + '' + (m.context_length ? Math.round(m.context_length / 1000) + 'K' : '—') + '' + + outCell(m, pr) + ''; +} + +function renderModels() { + const tb = $('mdBody'); + const list = mdSortList(mdAll.filter(m => mdMatch(m))); + if (!list.length) { + tb.innerHTML = '
没有符合当前筛选条件的模型
'; + } else { + tb.innerHTML = list.map(m => mdRowHtml(m, mdProbeOf(m.id))).join(''); + } + const filtered = list.length !== mdAll.length; + $('mdCount').textContent = !mdAll.length ? '' + : filtered ? '命中 ' + list.length + ' / ' + mdAll.length + ' 个模型' + : mdAll.length + ' 个模型'; + $('mdCount').className = filtered ? 'note src-off' : 'note'; +} + +function resetModelFilter() { + mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; + $('mdQ').value = ''; $('mdRealm').value = ''; $('mdCap').value = ''; + $('mdEffort').value = ''; $('mdPromo').value = ''; $('mdSort').value = 'default'; + renderModels(); +} + +// 筛选控件:输入框防抖 120ms(长列表逐字符重排不必每键一次),下拉即时。 +let mdQTimer = null; +$('mdQ').oninput = () => { + clearTimeout(mdQTimer); + mdQTimer = setTimeout(() => { mdFilter.q = $('mdQ').value.trim(); renderModels(); }, 120); +}; +for (const [id, key] of [['mdRealm', 'realm'], ['mdCap', 'cap'], ['mdEffort', 'effort'], ['mdPromo', 'promo'], ['mdSort', 'sort']]) { + const el = $(id); + if (!el) continue; + el.onchange = () => { mdFilter[key] = el.value; renderModels(); }; +} +$('mdReset').onclick = resetModelFilter; +$('btnModels').onclick = loadModels; + +/* ── 日志(频道:全部/任务/对话/系统) ─────────────────────────────── */ +let logCh = 'all'; +$('logChips').addEventListener('click', ev => { + const b = ev.target.closest('button[data-ch]'); + if (!b) return; + logCh = b.dataset.ch; + document.querySelectorAll('#logChips .chip').forEach(c => c.classList.toggle('on', c === b)); + loadLogs(); +}); +async function loadLogs() { + const box = $('logBox'); + const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 24; + const limit = ($('reqLimit') && $('reqLimit').value) || 100; + // 时间范围由归档侧过滤(不是前端筛已拉取的条目):区间落在更早的时间段时, + // 「最近 N 条」里根本不会有那些记录,必须让服务端按时间取。 + const rq = trangeQuery('reqRange', false); + rq.set('limit', limit); + try { + const [d, metrics, requestRows] = await Promise.all([ + api('logs'), + api('request_metrics').catch(() => ({})), + api('request_logs?' + rq.toString()).catch(() => ({ entries: [] })), + ]); + // 归档开启时以归档为准——「区间内没有记录」是一个真实结果,不能回落成内存里 + // 的最近 100 条(那会把筛选条件之外、时间范围之外的请求显示出来)。 + // 只有归档关闭时才回落到内存指标,保证没有归档的部署仍能看到最近请求。 + const archiveOn = !!(metrics && metrics.archive && metrics.archive.enabled); + const recent = archiveOn ? (requestRows.entries || []) : (metrics.recent || []); + renderRequestMetrics(metrics, recent); + const entries = (d.entries || []).filter(e => logCh === 'all' || e.ch === logCh); + box.innerHTML = entries.length + ? entries.map(e => { + const lvl = /error|失败|错误/.test(e.text) ? ' e' : /warn|冷却|熔断/.test(e.text) ? ' w' : ''; + const t = e.ts ? new Date(e.ts).toLocaleTimeString('zh-CN', { hour12: false }) : ''; + const ch = logCh === 'all' ? '' + ({ task: '任务', chat: '对话', sys: '系统' }[e.ch] || e.ch) + '' : ''; + return '' + ch + esc(t + ' ' + e.text) + ''; + }).join('') + : '暂无日志'; + if (logPin && atEnd) box.scrollTop = box.scrollHeight; + const counts = {}; + for (const e of (d.entries || [])) counts[e.ch] = (counts[e.ch] || 0) + 1; + $('logNote').textContent = logCh === 'all' + ? '任务 ' + (counts.task || 0) + ' · 对话 ' + (counts.chat || 0) + ' · 系统 ' + (counts.sys || 0) + : (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行'; + } catch (e) { /* 概览已提示 */ } +} + +function renderRequestMetrics(m, entries) { + m = m || {}; + const a = m.archive || {}; + $('reqSummary').textContent = + '已完成 ' + fmtTok(m.completed) + + ' · 成功 ' + (m.success_rate == null ? '—' : Number(m.success_rate).toFixed(1) + '%') + + ' · HTTP ' + (m.http_success_rate == null ? '—' : Number(m.http_success_rate).toFixed(1) + '%') + + ' · 平均 ' + fmtMs(m.avg_duration_ms) + + ' · 进行中 ' + String(m.in_flight || 0); + $('reqNote').textContent = a.enabled + ? 'JSONL 归档 ' + fmtBytes(a.bytes) + (a.dropped_writes ? ' · 丢弃 ' + a.dropped_writes + ' 条' : '') + + (a.last_error ? ' · 错误:' + a.last_error : '') + : '仅内存指标,JSONL 归档已关闭'; + + reqEntries = entries || []; + renderRequestTable(); +} + +/* reqMatch 请求记录筛选:q 对 IP/UA/模型/账号/请求 ID 做空格分词的 AND 包含匹配, + outcome 精确匹配。两者都在已拉取的条目上做(最多 1000 条),不发新请求。 */ +function reqMatch(e, f) { + f = f || reqFilter; + if (f.outcome && String(e && e.outcome || '') !== f.outcome) return false; + if (f.q) { + const text = [e && e.client_ip, e && e.user_agent, e && e.model, e && e.account, e && e.request_id] + .filter(Boolean).join(' ').toLowerCase(); + for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) { + if (!text.includes(kw)) return false; + } + } + return true; +} + +function reqOutcomeTag(e) { + const outcome = String(e && e.outcome || ''); + const label = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }[outcome] || outcome || '—'; + const cls = outcome === 'success' ? 'ok' + : outcome === 'interrupted' ? 'warn' + : outcome ? 'bad' : 'mute'; + return '' + esc(String(e && e.status || '—') + ' ' + label) + ''; +} + +function reqTokenCell(e) { + const total = Number(e && e.total_tokens || 0) || + (Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0)); + return total ? fmtTok(total) : '—'; +} + +function reqCreditCell(e) { + if (!e || !e.credit_known) return '—'; + const v = Number(e.credit); + return Number.isFinite(v) ? trimFixed(v.toFixed(2)) : '—'; +} + +/* renderRequestTable 渲染请求记录表。来源列是这一版的重点:IP 用等宽字体方便扫, + UA 单行截断(完整值在 title 里,行本身用 requestLogText 作 tooltip)。 */ +function renderRequestTable() { + const list = reqEntries.filter(e => reqMatch(e)); + const tb = $('reqBody'); + if (!tb) return; + tb.innerHTML = list.map(e => { + const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; + const ip = e && e.client_ip ? e.client_ip : ''; + const ua = e && e.user_agent ? e.user_agent : ''; + const rid = e && e.request_id ? e.request_id : ''; + return '' + + '' + esc(when) + '' + + '' + reqOutcomeTag(e) + '' + + '' + esc(e && e.model || '—') + '' + + '' + esc(e && e.account || '—') + '' + + '' + (ip ? '' + esc(ip) + '' : '—') + '' + + '' + (ua ? '' + esc(ua) + '' : '—') + '' + + '' + fmtMs(e && e.duration_ms) + '' + + '' + reqTokenCell(e) + '' + + '' + reqCreditCell(e) + '' + + '' + (rid ? '' + esc(rid) + '' : '—') + '' + + ''; + }).join('') || '' + + (reqEntries.length ? '没有符合当前筛选条件的请求记录' : '暂无请求记录') + ''; + + const filtered = list.length !== reqEntries.length; + // 归档里的旧条目没有来源字段(该功能上线前写入):这时提示开关/历史原因, + // 而不是让人以为筛选坏了。 + const hasSource = reqEntries.some(e => e && (e.client_ip || e.user_agent)); + $('reqCount').textContent = !reqEntries.length ? '' + : (filtered ? '命中 ' + list.length + ' / ' + reqEntries.length + ' 条' : reqEntries.length + ' 条') + + (hasSource ? '' : ' · 来源未记录'); + $('reqCount').className = (filtered || !hasSource) ? 'note src-off' : 'note'; +} + +/* 请求记录筛选控件。搜索框防抖 150ms:最多 1000 行重渲染,不必每键一次。 + 这段顶层绑定放在 requestLogText 之前,是为了让"纯函数切片"式前端测试 + (slice requestLogText → fmtBytes)只拿到无副作用的格式化函数。 */ +let reqQTimer = null; +if ($('reqQ')) $('reqQ').oninput = () => { + clearTimeout(reqQTimer); + reqQTimer = setTimeout(() => { reqFilter.q = $('reqQ').value.trim(); renderRequestTable(); }, 150); +}; +if ($('reqOutcome')) $('reqOutcome').onchange = () => { + reqFilter.outcome = $('reqOutcome').value; + renderRequestTable(); +}; +if ($('reqLimit')) $('reqLimit').onchange = loadLogs; +if ($('btnReqReload')) $('btnReqReload').onclick = loadLogs; +// 时间范围:默认「全部历史」——请求记录页的历史行为就是"取最近 N 条", +// 加一个默认收窄的区间会让打开页面时看到的条数凭空变少。 +if ($('reqRange')) trangeBind('reqRange', loadLogs, '0'); + +function requestLogText(e) { + const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; + const outcomeLabel = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }; + const token = Number(e && e.total_tokens || 0) || + (Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0)); + let credit = 'credit —'; + if (e && e.credit_known) { + const value = Number(e.credit); + if (Number.isFinite(value)) credit = String(Number(value.toFixed(2))) + ' credit'; + } + return [ + when, + String(e && e.status || '—') + ' ' + (outcomeLabel[e && e.outcome] || (e && e.outcome) || '—'), + e && e.model || '—', + e && e.account || '—', + e && e.client_ip || '—', + e && e.user_agent || '—', + fmtMs(e && e.duration_ms), + fmtTok(token) + ' tok', + credit, + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens) === '—' ? '' : '命中 ' + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens), + e && e.request_id || '—', + ].filter(Boolean).join(' | '); +} + +/* 缓存命中率纯文本(issue #92):requestLogText 与积分表/kpi 卡共用。 + 自包含(不依赖 trimFixed):前端纯函数切片测试只截取本段。 */ +function cacheRateText(hit, miss) { + const h = Number(hit || 0), m = Number(miss || 0), total = h + m; + if (!total) return '—'; + return String(Math.round(h / total * 1000) / 10) + '%'; +} + +function fmtBytes(bytes) { + const n = Number(bytes || 0); + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} +$('btnLogPin').onclick = () => { + logPin = !logPin; + $('btnLogPin').textContent = '自动滚动:' + (logPin ? '开' : '关'); +}; + +/* ── 配置 ─────────────────────────────────────────────────────────── */ +const CFG_MAP = { + listen: ['listen'], api_key: ['api_key'], + package_detail_limit: ['panel', 'package_detail_limit'], + checkin_hours: ['schedule', 'checkin_hours'], checkin_enabled: ['schedule', 'checkin_enabled'], growth_hours: ['schedule', 'growth_hours'], growth_enabled: ['schedule', 'growth_enabled'], + travel_hours: ['schedule', 'travel_hours'], travel_enabled: ['schedule', 'travel_enabled'], + activity_hours: ['schedule', 'activity_hours'], activity_enabled: ['schedule', 'activity_enabled'], + keepalive_hours: ['schedule', 'keepalive_hours'], keepalive_enabled: ['schedule', 'keepalive_enabled'], + balance_refresh_enabled: ['schedule', 'balance_refresh_enabled'], balance_refresh_minutes: ['schedule', 'balance_refresh_minutes'], + max_in_flight: ['pool', 'max_in_flight'], max_in_flight_global: ['pool', 'max_in_flight_global'], + breaker_threshold: ['pool', 'breaker_threshold'], + degrade_threshold: ['pool', 'degrade_threshold'], degrade_cooldown: ['pool', 'degrade_cooldown'], + degrade_cooldown_max: ['pool', 'degrade_cooldown_max'], + cost_explore_interval: ['pool', 'cost_explore_interval'], + credit_floor: ['pool', 'credit_floor'], + prefer_expiring: ['pool', 'prefer_expiring'], expiring_soon: ['pool', 'expiring_soon'], + soft_rate: ['cooldown', 'soft_rate'], soft_rate_max: ['cooldown', 'soft_rate_max'], + breaker_cooldown: ['pool', 'breaker_cooldown'], breaker_cooldown_max: ['pool', 'breaker_cooldown_max'], + idle_weight_per_hour: ['pool', 'idle_weight_per_hour'], idle_weight_max: ['pool', 'idle_weight_max'], + ttl: ['session_sticky', 'ttl'], + timeout_seconds: ['upstream', 'timeout_seconds'], header_timeout_seconds: ['upstream', 'header_timeout_seconds'], + idle_timeout_seconds: ['upstream', 'idle_timeout_seconds'], user_agent: ['upstream', 'user_agent'], + prompt_mode: ['prompt', 'mode'], prompt_file: ['prompt', 'file'], + sanitize_blacklist_fingerprints: ['features', 'sanitize_blacklist_fingerprints'], + session_sticky_enabled: ['session_sticky', 'enabled'], + request_client_info: ['logging', 'request_client_info'], +}; +function dig(obj, path) { return path.reduce((o, k) => (o == null ? undefined : o[k]), obj); } +function put(obj, path, val) { + let o = obj; + for (let i = 0; i < path.length - 1; i++) { if (typeof o[path[i]] !== 'object' || o[path[i]] === null) o[path[i]] = {}; o = o[path[i]]; } + o[path[path.length - 1]] = val; +} + +async function loadConfig() { + try { + const d = await api('config'); + cfgLoaded = d.config; + $('cfgPath').textContent = d.path || ''; + const f = $('cfgForm'); + for (const [name, path] of Object.entries(CFG_MAP)) { + const el = f.elements[name]; + if (!el) continue; + const v = dig(cfgLoaded, path); + if (el.type === 'checkbox') el.checked = !!v; + else if (Array.isArray(v)) el.value = v.join(', '); + else el.value = v == null ? '' : v; + } + markDurationFields(); // 回填后重置校验态(清掉残留红框;现值来自后端必然合法) + $('cfgNote').textContent = ''; + } catch (e) { toast('读取配置失败:' + e.message, 'err'); } +} +function collectConfig() { + const f = $('cfgForm'), out = {}; + for (const [name, path] of Object.entries(CFG_MAP)) { + const el = f.elements[name]; + if (!el) continue; + let v; + if (el.type === 'checkbox') v = el.checked; + else if (el.type === 'number') { v = el.value.trim() === '' ? undefined : Number(el.value); } + else { + const raw = el.value.trim(); + if (raw === '') v = undefined; + else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number); + else v = raw; + } + if (v !== undefined) put(out, path, v); + } + return out; +} +/* Go 时长字段即时校验:空 = 沿用现值(collectConfig 跳过发送);非空必须是 + ParseDuration 语法(30m / 2h / 600s / 1h30m,可组合可带小数)。与后端 + config.go normalize() 的 time.ParseDuration 同口径,脏值在前端就地标红, + 不再等到保存被拒。 */ +const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; +const DURATION_FIELDS = ['soft_rate', 'soft_rate_max', 'breaker_cooldown', 'breaker_cooldown_max', + 'degrade_cooldown', 'degrade_cooldown_max', 'cost_explore_interval', 'expiring_soon', 'ttl']; +const DURATION_TIP = '格式应为 Go 时长:30m / 2h / 600s / 1h30m'; +function durationBad(name) { + const el = $('cfgForm').elements[name]; + if (!el) return false; + const v = el.value.trim(); + return v !== '' && !DURATION_RE.test(v); +} +function markDurationFields() { + for (const name of DURATION_FIELDS) { + const el = $('cfgForm').elements[name]; + if (!el) continue; + const bad = durationBad(name); + el.classList.toggle('invalid', bad); + el.title = bad ? DURATION_TIP : ''; + } +} +$('cfgForm').addEventListener('input', ev => { + if (DURATION_FIELDS.includes(ev.target.name)) markDurationFields(); +}); +$('btnEye').onclick = () => { + const el = $('cfgKey'); + const show = el.type === 'password'; + el.type = show ? 'text' : 'password'; + $('btnEye').textContent = show ? '隐藏' : '显示'; +}; +$('btnCfgReload').onclick = loadConfig; +$('cfgForm').onsubmit = async ev => { + ev.preventDefault(); + // 时长字段脏值拦截:标红 + toast 点名,不发保存请求(后端同样会拒,这里前置)。 + markDurationFields(); + const firstBad = DURATION_FIELDS.find(durationBad); + if (firstBad) { + const el = $('cfgForm').elements[firstBad]; + el.focus(); + toast('「' + (el.closest('.fld')?.querySelector('.lb')?.textContent || firstBad) + '」' + DURATION_TIP, 'err'); + return; + } + const btn = $('btnCfgSave'); + btn.disabled = true; btn.textContent = '保存中…'; + try { + const r = await api('config', { method: 'POST', body: JSON.stringify(collectConfig()) }); + const n = (r.restart_required || []).length; + toast(n ? '配置已保存,其中 ' + n + ' 项需重启进程生效' : '配置已保存并立即生效', 'ok'); + // 密钥可能已改:本次会话沿用新值,避免下一次轮询被 401。 + const k = $('cfgKey').value.trim(); + if (k) localStorage.setItem(LS_KEY, k); + loadConfig(); + loadOverview(true); + } catch (e) { toast('保存失败:' + e.message, 'err'); } + finally { btn.disabled = false; btn.textContent = '保存配置'; } +}; + +/* ── 添加账号 ─────────────────────────────────────────────────────── */ +function openAdd() { + $('addVeil').classList.add('on'); + // 重置到登录标签 + switchAddTab('login'); + $('addPick').hidden = false; + $('addLoad').hidden = true; $('addReady').hidden = true; + $('addDone').hidden = true; $('addErr').hidden = true; + $('importDone').hidden = true; $('importErr').hidden = true; + $('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true; + $('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false; + stopPoll(); +} +function switchAddTab(tab) { + document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab)); + $('addTabLogin').hidden = tab !== 'login'; + $('addTabImport').hidden = tab !== 'import'; +} +document.querySelectorAll('#addTabs .tab').forEach(b => { + b.onclick = () => switchAddTab(b.dataset.tab); +}); +function startAddLogin() { + const realm = (document.querySelector('input[name="addRealm"]:checked') || {}).value || 'cn'; + $('btnStartLogin').disabled = true; + $('addLoad').hidden = false; $('addErr').hidden = true; + api('login/start', { method: 'POST', body: JSON.stringify({ realm }) }).then(r => { + loginState = r.state; + $('addUrl').textContent = r.url; + $('addPick').hidden = true; // 选域锁定(会话已按该域发起) + $('addLoad').hidden = true; $('addReady').hidden = false; + $('btnStartLogin').hidden = true; + $('btnCopyUrl').hidden = false; $('btnOpenUrl').hidden = false; + loginTimer = setInterval(pollLogin, 3000); + }).catch(e => { + $('addLoad').hidden = true; + $('btnStartLogin').disabled = false; + $('addErr').hidden = false; + $('addErr').textContent = e.message; + }); +} +function stopPoll() { if (loginTimer) { clearInterval(loginTimer); loginTimer = null; } } +async function pollLogin() { + if (!loginState) return; + try { + const r = await api('login/poll?state=' + encodeURIComponent(loginState)); + if (r.done) { + stopPoll(); + $('addReady').hidden = true; + $('addDone').hidden = false; + $('addDone').textContent = '已添加 ' + (r.nickname || r.uid) + (r.realm === 'global' ? '(国际版)' : '') + (r.credits >= 0 ? ' · 积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + ',账号已载入池中'; + setTimeout(() => { closeAdd(); loadOverview(true); }, 1600); + } + } catch (e) { + stopPoll(); + $('addReady').hidden = true; + $('addErr').hidden = false; + $('addErr').textContent = e.message + '(关闭后重新添加)'; + } +} +function closeAdd() { stopPoll(); loginState = null; $('addVeil').classList.remove('on'); } +$('btnCloseAdd').onclick = closeAdd; +$('btnStartLogin').onclick = startAddLogin; +$('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank'); +$('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent) + .then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err')); +$('importFile').onchange = async () => { + const file = $('importFile').files[0]; + if (!file) return; + $('importDone').hidden = true; $('importErr').hidden = true; + const fd = new FormData(); + fd.append('file', file); + const h = {}; + const k = localStorage.getItem(LS_KEY); + if (k) h['Authorization'] = 'Bearer ' + k; + try { + const r = await fetch('/panel/api/import/cockpit', { method: 'POST', body: fd, headers: h }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); + $('importDone').hidden = false; + $('importDone').textContent = '导入完成:成功 ' + d.imported + ' 个' + (d.skipped ? ',跳过 ' + d.skipped + ' 个' : ''); + if (d.errors && d.errors.length) { + console.warn('import errors:', d.errors); + } + loadOverview(true); + } catch (e) { + $('importErr').hidden = false; + $('importErr').textContent = '导入失败:' + e.message; + } + $('importFile').value = ''; +}; + +/* ── 顶部动作 ─────────────────────────────────────────────────────── */ +$('btnAdd').onclick = openAdd; +$('btnRefresh').onclick = async () => { + const b = $('btnRefresh'); + b.disabled = true; b.textContent = '刷新中…'; + try { + await api('balance_all', { method: 'POST' }); + await loadOverview(true); + toast('余额已从上游刷新', 'ok'); + } catch (e) { toast('刷新失败:' + e.message, 'err'); await loadOverview(true); } + finally { b.disabled = false; b.textContent = '刷新'; } + if (view === 'logs') loadLogs(); +}; + +/* ── 轮询 ─────────────────────────────────────────────────────────── */ +function refreshVisible() { + if (view === 'accounts') loadOverview(true); + else if (view === 'logs') loadLogs(); + else if (view === 'taskscenter') reattachQueueView(); +} +function start() { + loadOverview(true); + if (refTimer) clearInterval(refTimer); + refTimer = setInterval(refreshVisible, 5000); + checkAuthGate(); +} +async function checkAuthGate() { + try { await api('overview'); } + catch (e) { if (String(e.message).includes('密钥') || String(e.message).includes('api_key')) return; } +} +start(); + +/* ── 积分任务 ─────────────────────────────────────────────────────── */ +let taskUID = null; + +// 可自动完成的任务(与后端 autoActions 表一致):判据为行为事件、可经网关复现。 +// 其余任务需在官方客户端内交互,面板只展示指引(行 title 提示)。 +// 注意:键含点号(Model_chat_GLM5.2)必须加引号,否则会被解析成属性访问 + 数字字面量。 +const AUTO_TASKS = { + 'chat_5': '上报 5 条对话活跃事件(自动补足差额)', + 'first_buddy': '上报解锁 → 同意协议 → 领取第一只 Buddy', + 'Model_chat_GLM5.2': '接受任务 → glm-5.2 真实对话一次 → 对齐模型上报', + 'RichMeow_Chat': '桌面指纹事件链上报(已验证可点亮)', + 'Buddy_App': '上报「进入 Buddy 应用」事件链(已验证可点亮)', + 'Buddy_App_QQ': '上报「进入企鹅教师助手」事件链(已验证可点亮)', + 'automation_1': '上报「定时任务创建」事件(已验证可点亮)', + 'Library_read': '上报「读资料库介绍」事件(已验证可点亮)', + 'template_5': '上报「使用模板创建任务」事件组 ×5(三账号实测点亮)', + 'playbook_prompt': '上报「灵感案例做同款发送 Prompt」事件组(三账号实测点亮)', + 'create_canvas': '上报「设计创意画布创建」事件组(三账号实测点亮,+300 分)', + 'expert_5': '真实专家召唤+使用链 ×5(专家市场+真实 chat,三账号实测点亮)', + 'Expert_team_use_3': '真实专家团召唤+使用链 ×3(三账号实测点亮)', + 'Hp_Appearance': '设置主题 API + 皮肤生效事件(两账号实测点亮)', + 'black_cat': '夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示等 23 点排程)', + 'Expert_lighthouse': '真实轻量云专家召唤+使用链(真实对话 requestId,两账号实测点亮)', + 'skill_1': '真实对话 + skill_info 技能加载事件(实测点亮)', + 'school_season': '校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)', + 'Sequential_Tasks_1': '小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)', + 'Sequential_Tasks_2': '小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)', + 'Sequential_Tasks_3': '小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)', + 'Sequential_Tasks_4': '小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件 → 领奖(判据待解锁验证)', + 'Sequential_Tasks_5': '小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)', + 'Sequential_Tasks_6': '小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖', + 'Sequential_Tasks_7': '体验灵感功能(预留,疑 PC 口径):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)' +}; + +function openTasks(uid) { + taskUID = uid; + $('taskWho').textContent = uid.slice(0, 16); + $('taskVeil').classList.add('on'); + $('btnTaskReload').hidden = false; + loadTasks(); +} +function closeTasks() { $('taskVeil').classList.remove('on'); taskUID = null; } +$('btnCloseTask').onclick = closeTasks; +$('btnTaskReload').onclick = loadTasks; + +// 全部接受:把该账号未接受的任务一次性报名(幂等,跳过已接受/已领取)。 +$('btnTaskAcceptAll').onclick = async () => { + if (!taskUID) return; + const btn = $('btnTaskAcceptAll'); + btn.disabled = true; btn.textContent = '接受中…'; + try { + const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/accept_all', { method: 'POST' }); + const n = r.accepted || 0; + if (r.failed && r.failed.length) { + toast(`已接受 ${n} 个,${r.failed.length} 个被上游拒绝(可重试)`, 'err'); + } else { + toast(n ? `已接受 ${n} 个任务` : (r.message || '所有任务均已接受'), 'ok'); + } + } catch (e) { toast(e.message, 'err'); } + finally { btn.disabled = false; btn.textContent = '全部接受'; loadTasks(); } +}; + +// 一键完成全部可自动任务(耗时较长:含真实对话,逐项回读验证)。 +$('btnTaskAutoAll').onclick = async () => { + if (!taskUID) return; + const btn = $('btnTaskAutoAll'); + if (!confirm('将依次执行:补报对话事件、领取 Buddy、glm-5.2 对话、尝试上报。\n过程约 1-2 分钟(含真实对话),确认继续?')) return; + btn.disabled = true; btn.textContent = '执行中…'; + try { + const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto_all', { method: 'POST' }); + const okN = (r.results || []).filter(x => x.status === 'done').length; + const skipN = (r.results || []).filter(x => x.status === 'skipped').length; + const errN = (r.results || []).filter(x => x.status === 'error').length; + toast(`执行完成:成功 ${okN} 项,跳过 ${skipN} 项${errN ? ',失败 ' + errN + ' 项' : ''}`, errN ? 'err' : 'ok'); + console.log('auto_all results:', r.results); + } catch (e) { toast(e.message, 'err'); } + finally { btn.disabled = false; btn.textContent = '一键完成可自动任务'; loadTasks(); } +}; + +async function loadTasks() { + if (!taskUID) return; + const st = $('taskState'), tb = $('taskTable'); + st.hidden = false; + st.className = 'state'; + st.innerHTML = '查询中'; + tb.hidden = true; + try { + const d = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks'); + const list = d.tasks || []; + if (!list.length) { + st.className = 'state'; + st.textContent = '该账号暂无任务'; + return; + } + // 有进度或可领取的排前面,已领取沉底——一眼看到"现在该做什么"。 + list.sort((a, b) => (a.claimed - b.claimed) || (b.claimable - a.claimable) || String(a.task_code).localeCompare(String(b.task_code))); + $('taskBody').innerHTML = list.map(t => { + // 进度:current 可能缺失(0 或被上游省略)——用 ?? 兜底,避免渲染成 "undefined / N" + const cur = t.current ?? 0, tgt = t.target ?? 0; + const prog = tgt ? cur + ' / ' + tgt : (tgt === 0 && cur > 0 ? String(cur) : '—'); + const parts = []; + if (t.credit) parts.push('+' + t.credit + ' 分'); + if (t.energy) parts.push('+' + t.energy + ' 能'); + if (t.reward_buddy) parts.push('Buddy'); + const reward = parts.length ? parts.join(' ') : '—'; + const badge = t.claimed ? '已领取' + : t.claimable ? '可领取' + : t.locked ? '未解锁' + : t.accept_status === 'accepted' ? '进行中' + : '未接受'; + const acted = t.claimed || t.locked ? '' + : t.claimable ? '' + : AUTO_TASKS[t.task_code] ? '' + : t.accept_status === 'accepted' ? '' + : ''; + // 操作指引(description/task_desc)挂 title 提示:如何完成交给用户看 + const tip = [t.title, t.task_desc || t.description, t.jump_url ? '跳转:' + t.jump_url : ''].filter(Boolean).join('\n'); + return '' + + '
' + esc(t.title || t.task_code) + '
' + esc(t.task_code) + (t.tag ? ' · ' + esc(t.tag) : '') + '
' + + '' + esc(prog) + '' + + '' + esc(reward) + '' + + '' + badge + '' + + '' + acted + ''; + }).join(''); + st.hidden = true; + tb.hidden = false; + } catch (e) { + st.className = 'state err'; + st.textContent = e.message; + } +} + +$('taskBody').addEventListener('click', async ev => { + const b = ev.target.closest('button[data-t]'); + if (!b || !taskUID) return; + const kind = b.dataset.t, code = b.dataset.c; + b.disabled = true; + try { + if (kind === 'auto') { + // 一键完成:后端执行动作 → 回读进度 → 汇报(耗时可到分钟级,含真实对话) + b.textContent = '执行中…'; + const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto', { + method: 'POST', body: JSON.stringify({ task_code: code }) + }); + if (r.skipped) { + toast(r.message || '已跳过', 'ok'); + } else { + const advanced = r.progress_before !== r.progress_after; + let msg = r.message || '已执行'; + if (r.progress_after) msg += `(进度 ${r.progress_before} → ${r.progress_after})`; + if (r.claimed) msg += ',奖励已自动到账'; + else if (r.claimable) msg += r.claim_error ? ',可点「领取」重试' : ''; + else if (r.attempt && !advanced) msg += ';进度未动,该任务可能需要官方客户端'; + toast(msg, (r.claimed || advanced) ? 'ok' : 'err'); + } + loadOverview(true); + } else { + const path = 'accounts/' + encodeURIComponent(taskUID) + '/tasks/' + (kind === 'claim' ? 'claim' : 'accept'); + const body = kind === 'claim' ? { task_code: code } : { task_codes: [code] }; + await api(path, { method: 'POST', body: JSON.stringify(body) }); + toast(kind === 'claim' ? '已领取奖励' : '已接受任务', 'ok'); + if (kind === 'claim') loadOverview(true); + } + } catch (e) { toast(e.message, 'err'); } + finally { loadTasks(); } +}); + +/* ── 任务中心:开学季 + 全账号扫描/队列 ──────────────────────────── */ +// 开学季任务单元:✓ 已领(绿)|◐ x/y 进行中(琥珀)|○ 未做(灰) +function staskHTML(t) { + if (!t) return '·—'; + if (t.status === 'claimed') return '✓已领'; + if (t.status === 'completed') return '◆可领'; + if (t.status === 'in_progress') { + const fr = t.target_count ? '' + t.progress + '/' + t.target_count + '' : ''; + return '◐' + fr + ''; + } + return '○未做'; +} +const LUCK_SVG = ''; +const SCHOOL_TITLES = { + share_invite: '分享活动 +100c', desktop_chat_1_time: '桌面端体验 +100c(单次)', + chat_3_times: '和 AI 对话 3 次 +50c', expert_use: '召唤开学季专家 +50c', + task_student_verify: '学生认证 +100c(需真实认证,不做)', +}; + +/* ── 精简 QR 编码器(券码二维码用)──────────────────────────────────── + 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。 + 完整性:规范允许任选掩码(解码器按格式信息位自行去掩码),固定掩码不影响 + 可扫描性;已用 python qrcode 库对多输入多版本做逐像素交叉验证(强制 byte + 模式 + mask 0,5/5 全部 diff=0)。面板 CSP 只允许 self,外链 QR 服务不可用。 */ +// qr_gen.js —— 精简 QR 编码器(浏览器用 + node 可跑交叉验证) +// 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。 +// 完整性说明:规范允许编码器任选掩码(解码器按格式信息位自行去掩码), +// 固定掩码不影响可扫描性;券码为短文本,v1-5(26 字节起)绰绰有余。 + +// GF(256) 对数/指数表(本原多项式 0x11d) +const QR_EXP = new Array(512), QR_LOG = new Array(256); +(() => { + let x = 1; + for (let i = 0; i < 255; i++) { QR_EXP[i] = x; QR_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } + for (let i = 255; i < 512; i++) QR_EXP[i] = QR_EXP[i - 255]; +})(); +const gmul = (a, b) => (a && b) ? QR_EXP[QR_LOG[a] + QR_LOG[b]] : 0; + +// 各版本参数(下标 = 版本-1):[数据码字数, 纠错码字数],ECC L 单块 +const QR_V = [[19, 7], [34, 10], [55, 15], [80, 20], [108, 26]]; +// 对齐图案中心坐标(v2+;与定位图案重叠的位置在放置时跳过) +const QR_ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]]; +const QR_MASK = (r, c) => (r + c) % 2 === 0; // 掩码模式 0 + +// 生成多项式(最高次系数在前,g[0] 恒为 1) +function qrGenPoly(deg) { + let g = [1]; + for (let i = 0; i < deg; i++) { + const a = QR_EXP[i], ng = new Array(g.length + 1).fill(0); + ng[0] = g[0]; + for (let j = 1; j < g.length; j++) ng[j] = g[j] ^ gmul(a, g[j - 1]); + ng[g.length] = gmul(a, g[g.length - 1]); + g = ng; + } + return g; +} + +// Reed-Solomon 求余(综合除法),返回 deg 个纠错码字 +function rsRem(data, deg) { + const g = qrGenPoly(deg); + const res = data.concat(new Array(deg).fill(0)); + for (let i = 0; i < data.length; i++) { + const f = res[i]; + if (f) for (let j = 0; j < g.length; j++) res[i + j] ^= gmul(g[j], f); + } + return res.slice(data.length); +} + +// 文本 → 码字流(byte 模式:0100 + 8 位计数 + 数据 + 终止符 + 0xEC/0x11 填充) +function qrDataCodewords(text, dataCap) { + const bytes = Array.from(new TextEncoder().encode(text)); + const bits = []; + const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); }; + push(4, 4); // byte 模式 + push(bytes.length, 8); // v1-9 计数 8 位 + for (const b of bytes) push(b, 8); + const cap = dataCap * 8; + push(0, Math.min(4, cap - bits.length)); // 终止符 + while (bits.length % 8) bits.push(0); + const out = []; + for (let i = 0; i < bits.length; i += 8) { + let v = 0; for (const b of bits.slice(i, i + 8)) v = (v << 1) | b; + out.push(v); + } + for (let p = 0; out.length < dataCap; p ^= 1) out.push(p ? 0x11 : 0xEC); + return out; +} + +// 主入口:text → 布尔矩阵(true=深色模块) +function qrMatrix(text) { + const bytes = Array.from(new TextEncoder().encode(text)); + // 版本选择:需求 ≈ 2 码字头 + 文本长度,取首个放得下的版本 + let ver = 0; + for (let v = 0; v < QR_V.length; v++) { if (bytes.length + 2 <= QR_V[v][0]) { ver = v + 1; break; } } + if (!ver) throw new Error('QR: text too long (>' + QR_V[4][0] + ' bytes)'); + const [dataCap, ecCap] = QR_V[ver - 1]; + const n = 17 + 4 * ver; + + const M = Array.from({ length: n }, () => new Array(n).fill(false)); + const F = Array.from({ length: n }, () => new Array(n).fill(false)); // 功能模块占位 + + const setF = (r, c, v) => { M[r][c] = v; F[r][c] = true; }; + // 定位图案 + 分隔带 + const finder = (r0, c0) => { + for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) { + const rr = r0 + r, cc = c0 + c; + if (rr < 0 || cc < 0 || rr >= n || cc >= n) continue; + const dark = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)); + setF(rr, cc, dark); + } + }; + finder(0, 0); finder(0, n - 7); finder(n - 7, 0); + // 校正图形(仅贯穿两定位图案之间:8..n-9,不得覆盖定位图案本体) + for (let r = 8; r <= n - 9; r++) setF(r, 6, r % 2 === 0); + for (let c = 8; c <= n - 9; c++) setF(6, c, c % 2 === 0); + // 对齐图案(v2+,跳过与定位重叠处) + const align = QR_ALIGN[ver - 1] || []; + for (const ar of align) for (const ac of align) { + if (F[ar][ac]) continue; + for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++) + setF(ar + r, ac + c, Math.max(Math.abs(r), Math.abs(c)) !== 1); + } + // 暗模块 + 格式信息(ECC L=01,掩码 0)——BCH(15,5) + 0x5412 异或。 + // 位序遵循规范(与 python qrcode 逐位对齐验证):bit i 从 LSB 起数, + // 副本一走左上角 L 形、副本二走右下 L 形。 + let fmt = (1 << 3) | 0; // L<<3 | mask + let rem = fmt << 10; + for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10); + fmt = ((fmt << 10) | rem) ^ 0x5412; // 15 位 + const fb = i => (fmt >> i) & 1; + // 副本一(左上):位 0..5 → (i,8);6 → (7,8);7 → (8,8) + for (let i = 0; i <= 5; i++) setF(i, 8, !!fb(i)); + setF(7, 8, !!fb(6)); setF(8, 8, !!fb(7)); + // 副本一续 + 副本二(右下):位 8..14 → (n-15+i, 8);位 0..7 → (8, n-1-i);8 → (8,7);9..14 → (8,14-i) + for (let i = 8; i <= 14; i++) setF(n - 15 + i, 8, !!fb(i)); + for (let i = 0; i <= 7; i++) setF(8, n - 1 - i, !!fb(i)); + setF(8, 7, !!fb(8)); + for (let i = 9; i <= 14; i++) setF(8, 14 - i, !!fb(i)); + // 暗模块(恒为深色,位于副本一垂直段末端) + setF(n - 8, 8, true); + + + // 数据码字 + 纠错码字 → 位流 + const dcw = qrDataCodewords(text, dataCap); + const cw = dcw.concat(rsRem(dcw, ecCap)); + const bits = []; + for (const b of cw) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1); + + // 蛇形放置(成对列,从右向左,跳过第 6 列),写数据时直接异或掩码 + let bi = 0, up = true; + for (let x = n - 1; x > 0; x -= 2) { + if (x === 6) x--; + for (let i = 0; i < n; i++) { + const r = up ? n - 1 - i : i; + for (const c of [x, x - 1]) { + if (F[r][c]) continue; + const bit = bi < bits.length ? bits[bi++] : 0; + M[r][c] = bit ? !QR_MASK(r, c) : QR_MASK(r, c); + } + } + up = !up; + } + return M; +} + +// 矩阵 → SVG(quiet zone 4 模块) +function qrSVG(M, px) { + const n = M.length, q = 4, total = n + q * 2; + let s = ''; + for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) + if (M[r][c]) s += ''; + return s + ''; +} + +/* ── 开学季券码查询(弹窗,仿活动页 #/prizes?tab=vouchers)──────────── */ +/* copyText:clipboard API 只在 secure context(https/localhost)可用, + 远程 http 面板会拿不到 navigator.clipboard → 降级 execCommand。 */ +function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + return new Promise((resolve, reject) => { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy') ? resolve() : reject(new Error('copy failed')); } + catch (e) { reject(e); } + finally { ta.remove(); } + }); +} + +function vcCard(v) { + const expired = v.valid_to && new Date(v.valid_to) < new Date(); + return '
' + + '
' + esc(v.prize_name || v.sku_code || '券') + '' + + (expired ? '已过期' : '可使用') + '
' + + '
' + + (v.valid_to ? '有效期至 ' + esc(v.valid_to) : '长期有效') + + (v.granted_at ? ' · ' + esc(v.granted_at.slice(0, 10)) + ' 抽中' : '') + + '
' + + '
' + + '
券码' + esc(v.code || '-') + '' + + '' + + (v.code ? '' : '') + + '' + + '
' + + '
'; +} + +async function loadSchoolVouchers() { + const body = $('vcBody'); + $('vcVeil').classList.add('on'); + body.innerHTML = '
查询中
'; + $('vcNote').textContent = ''; + try { + const d = await api('school/vouchers'); + const arr = d.accounts || []; + const ok = arr.filter(a => !a.error); + const total = ok.reduce((n, a) => n + (a.vouchers || []).length, 0); + body.innerHTML = ok.filter(a => (a.vouchers || []).length).map(a => + '
' + esc(a.nickname || a.uid) + '' + + '' + a.vouchers.length + ' 张
' + + a.vouchers.map(vcCard).join('') + ).join('') || '
🎟️
还没有抽到券
'; + $('vcNote').textContent = total ? total + ' 张券 · ' + ok.filter(a => !(a.vouchers || []).length).length + ' 个账号未抽中' : ''; + const errs = arr.filter(a => a.error); + if (errs.length) { + body.insertAdjacentHTML('beforeend', '
查询失败:' + + errs.map(a => esc(a.nickname || a.uid.slice(0, 8)) + '(' + esc(a.error) + ')').join('、') + '
'); + } + body.querySelectorAll('button[data-copy]').forEach(b => b.onclick = async () => { + try { await copyText(b.dataset.copy); toast('券码已复制', 'ok'); } + catch (e) { toast('复制失败,请手动选择券码', 'err'); } + }); + // 二维码:券码本体编码为 QR(到店出示扫描),点击切换显示/隐藏 + body.querySelectorAll('button[data-qr]').forEach(b => b.onclick = () => { + const card = b.closest('.vc'); + const old = card.querySelector('.vc-qr'); + if (old) { old.remove(); return; } + const box = document.createElement('div'); + box.className = 'vc-qr'; + try { box.innerHTML = qrSVG(qrMatrix(b.dataset.qr), 148); } + catch (e) { box.innerHTML = '二维码生成失败:' + esc(e.message) + ''; } + card.appendChild(box); + }); + } catch (e) { + body.innerHTML = '
' + esc(e.message) + '
'; + } +} +$('btnSchoolVouchers').onclick = loadSchoolVouchers; +$('btnVcClose').onclick = () => $('vcVeil').classList.remove('on'); +$('btnVcRefresh').onclick = loadSchoolVouchers; + +/* 成长任务队列。lastQueueSeq 记录本页启动过的队列代次:执行结束后的残留 items + (running=false 但 seq 停在旧值)不再回写视图——否则扫描结果 3 秒后被上一轮 + 队列状态覆盖。 */ +let queueTimer = null, lastQueueSeq = 0; +const GROWTH_TITLES = {}; // code → 展示名(扫描时从任务列表带出) +$('btnScanAll').onclick = async () => { + const b = $('btnScanAll'); + // 停掉队列轮询:显式扫描 = 切到待办视图。否则在途队列的下一 tick 会把扫描 + // 结果冲掉重渲染回队列视图(服务端执行不受影响,只是不再实时回写本视图)。 + if (queueTimer) { clearInterval(queueTimer); queueTimer = null; } + b.disabled = true; b.textContent = '扫描中…'; + try { + const d = await api('tasks/scan_all', { method: 'POST' }); + renderQueue(groupItems(d), null, '没有待办任务 🎉', '全部账号的成长任务与开学季活动都已完成,明日再来。'); + } catch (e) { toast(e.message, 'err'); } + finally { b.disabled = false; b.textContent = '扫描待办'; } +}; +$('btnRunQueue').onclick = async () => { + const conc = Number($('qcConc').value) || 1; + if (!confirm('扫描全部账号待办并排队执行(账号并发 ' + conc + ',账号内串行)。\n含真实对话的任务耗时较长,确认继续?')) return; + const b = $('btnRunQueue'); + b.disabled = true; b.textContent = '启动中…'; + try { + const r = await api('tasks/run_queue', { method: 'POST', body: JSON.stringify({ concurrency: conc }) }); + if (!r.started) { toast(r.message || '没有待办任务', 'ok'); return; } + lastQueueSeq = r.seq || 0; + toast('队列已启动:' + r.total + ' 项(并发 ' + conc + ')', 'ok'); + startQueuePolling(); + } catch (e) { toast(e.message, 'err'); } + finally { b.disabled = false; b.textContent = '执行全部待办'; } +}; +// 扫描结果 → 分组条目(无执行状态) +function groupItems(d) { + const groups = []; + for (const a of (d.accounts || [])) { + const rows = []; + for (const t of (a.growth || [])) { + GROWTH_TITLES[t.task_code] = t.title || t.task_code; + rows.push({ kind: 'growth', code: t.task_code, prog: t.target ? t.current + '/' + t.target : '—', status: 'scan' }); + } + if (rows.length) groups.push({ uid: a.uid, nick: a.nickname, rows }); + } + return groups; +} +const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行' }; +function qrowHTML(it) { + const isSchool = it.kind === 'school'; + const title = isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code); + const dotCls = it.status === 'scan' ? 'wait' : it.status === 'running' ? 'run' : it.status === 'error' ? 'err' : it.status === 'skipped' ? 'skip' : it.status === 'done' ? 'done' : 'wait'; + const stWord = it.status === 'scan' ? '待执行' : (ST_WORDS[it.status] || it.status); + return '
' + + '' + esc(it.code) + '' + + '' + esc(title) + '' + (isSchool ? '开学季' : '') + '' + + '' + esc(it.prog || '') + '' + + '' + stWord + '' + + '' + esc(it.message || '') + '' + + '
'; +} +function renderQueue(groups, progress, emptyTitle, emptyDesc) { + const empty = $('tcEmpty'), list = $('qcList'); + if (!groups.length) { + empty.style.display = ''; + if (emptyTitle) empty.querySelector('.t').textContent = emptyTitle; + if (emptyDesc) empty.querySelector('.d').textContent = emptyDesc; + list.innerHTML = ''; + $('qProg').hidden = true; $('qcSummary').textContent = ''; + return; + } + empty.style.display = 'none'; + empty.style.display = 'none'; + let total = 0; + list.innerHTML = groups.map(g => { + total += g.rows.length; + return '
' + esc(g.nick || g.uid.slice(0, 12)) + '' + g.rows.length + ' 项待办
' + + g.rows.map(qrowHTML).join('') + '
'; + }).join(''); + $('qcSummary').textContent = total + ' 项'; + updateProgress(progress); +} +function updateProgress(q) { + if (!q || !q.items) { $('qProg').hidden = true; return; } + const total = q.items.length; + const done = q.items.filter(it => it.status === 'done' || it.status === 'error' || it.status === 'skipped').length; + $('qProg').hidden = false; + $('qBarFill').style.width = (total ? Math.round(done / total * 100) : 0) + '%'; + $('qProgText').textContent = (q.running ? '执行中 ' : '已结束 ') + done + ' / ' + total; +} +// 队列状态 → 分组(执行时轮询) +function groupsFromQueue(items) { + const by = new Map(); + for (const it of items) { + if (!by.has(it.uid)) by.set(it.uid, { uid: it.uid, nick: it.nickname, rows: [] }); + by.get(it.uid).rows.push({ + kind: it.kind, code: it.code, + prog: it.kind === 'school' ? '—' : '', + status: it.status, message: it.message, + }); + } + return Array.from(by.values()); +} +function startQueuePolling() { + if (queueTimer) clearInterval(queueTimer); + queueTimer = setInterval(async () => { + let q; + try { q = await api('tasks/queue'); } catch (e) { return; } + if (!q.started) return; + // 只渲染本页启动过的那轮队列(刷新页面后不再接管旧队列)。 + if (lastQueueSeq && q.seq !== lastQueueSeq) return; + if (q.running) { + renderQueue(groupsFromQueue(q.items || []), q); + return; + } + // 结束:终态只渲染这一次,随即停表。此后残留的 items(running=false)不再 + // 回写视图——曾把用户刚点开的「扫描待办」结果在下一个 tick 冲掉。 + renderQueue(groupsFromQueue(q.items || []), q); + clearInterval(queueTimer); queueTimer = null; + toast('任务队列执行结束', 'ok'); + }, 3000); +} +// reattachQueueView 切回任务中心视图时恢复队列进度:仅当本页启动的队列仍在 +// 执行才重新开轮询(残留态/别页队列不接管——视图不被旧结果冲掉)。 +function reattachQueueView() { + // 全程异步:go() 在顶层(app.js ~143 行)被调用时,本文件下方 let/const + //(queueTimer/lastQueueSeq 等)尚未初始化——同步读取即 TDZ ReferenceError + // 使整个脚本中断。await 之后才碰它们(旧 pollQueueOnce 正是靠开头的 await + // 侥幸安全)。queueTimer 的"已在跑"判定也挪到 await 后,语义不变。 + (async () => { + try { + const q = await api('tasks/queue'); + if (queueTimer) return; // 轮询已在跑(跨视图不中断) + if (q.started && q.running && (!lastQueueSeq || q.seq === lastQueueSeq)) startQueuePolling(); + } catch (e) { /* 静默 */ } + })(); +} + +/* ── 用量 ─────────────────────────────────────────────────────────── */ +/* 图表用原生 SVG 手绘:面板是 go:embed 单文件、无构建步骤,引入图表库 + 就得带上打包器,得不偿失。这里只需要堆叠柱状图,二十行足够。 */ + +function fmtTok(n) { + n = Number(n || 0); + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(n); +} +function fmtMs(ms) { + ms = Number(ms || 0); + if (!ms) return '—'; + if (ms >= 1000) return (ms / 1000).toFixed(2) + 's'; + return Math.round(ms) + 'ms'; +} +function fmtRate(r) { return r ? Number(r).toFixed(1) + ' tok/s' : '—'; } +function trimFixed(s) { + if (!String(s).includes('.')) return String(s); + return String(s).replace(/0+$/, '').replace(/\.$/, ''); +} +function fmtCredit(n) { + const v = Number(n || 0); + if (!Number.isFinite(v)) return '—'; + return trimFixed(v.toFixed(2)); +} +function fmtCreditRatio(v, samples, tokens) { + if (!samples || !tokens) return '—'; + const n = Number(v || 0); + if (!Number.isFinite(n)) return '—'; + return trimFixed(n.toFixed(4)) + ' / 1M'; +} +function fmtModelRate(rate) { + const s = String(rate || '').trim(); + return s ? 'x' + s : '—'; +} + +function usStat(v, k, cls) { + return '
' + esc(v) + + '
' + esc(k) + '
'; +} + +/* usKpi 用量页的指标卡。比账号池的 .stat 多两样:语义色轨(cls)与副标题(sub, + 放"占比 / 均速率"这类解释性数字);bar 是卡片内的构成条 HTML,只有需要时才传。 */ +function usKpi(v, k, cls, sub, bar) { + return '
' + + '
' + esc(k) + '
' + + '
' + esc(v) + '
' + + (bar || '') + + (sub ? '
' + esc(sub) + '
' : '') + + '
'; +} + +/* usMixBar prompt/completion 占比条。宽度按百分比而不是固定像素:表列宽随窗口变化, + 像素宽度在窄屏会溢出、宽屏又显得没信息。 */ +function usMixBar(prompt, completion, total) { + const t = Number(total || 0); + if (!t) return ''; + const pp = Math.max(0, Math.min(100, Number(prompt || 0) / t * 100)); + const pc = Math.max(0, Math.min(100, Number(completion || 0) / t * 100)); + return '' + + '' + + '' + + ''; +} + +/* usPct 占比文案(0 值不显示 "0.0%",直接 —,避免一行全是零)。 */ +function usPct(part, total) { + const t = Number(total || 0); + if (!t) return '—'; + return (Number(part || 0) / t * 100).toFixed(1) + '%'; +} + +/* usRow 生成一行。mid 是插在「名称」之后、请求数之前的额外单元格(如「域」列)。 + withPerf 控制延迟/速率两列;列开关显式传入,避免调用方改动后与表头错列。 */ +function usRow(name, sub, a, mid, withPerf) { + return '' + + '' + + '' + esc(name) + (sub ? '
' + esc(sub) + '
' : '') + '' + + (mid || '') + + '' + fmtTok(a.requests) + '' + + '' + (a.errors ? '' + fmtTok(a.errors) + '' : '—') + '' + + '' + fmtTok(a.prompt_tokens) + '' + + '' + fmtTok(a.completion_tokens) + '' + + '' + fmtTok(a.total_tokens) + + usMixBar(a.prompt_tokens, a.completion_tokens, a.total_tokens) + '' + + (withPerf + ? '' + fmtMs(a.avg_latency_ms) + '' + + '' + fmtRate(a.avg_tokens_per_second) + '' + : '') + + ''; +} + +/* ── 用量明细:三个维度共用一张表 + 页内切换 ────────────────────────── + 账号 / 模型 / 域三张表此前各自占一个 box,页面纵向拉得很长且表头结构几乎一样。 + 现在合成一个 box:表头由维度定义生成,行渲染复用 usRow,切换零请求。 */ +const US_DIMS = { + account: { key: 'by_account', title: '账号', withRealm: true, withPerf: true, span: 10 }, + model: { key: 'by_model', title: '模型', withRealm: false, withPerf: false, span: 7 }, + realm: { key: 'by_realm', title: 'realm', withRealm: false, withPerf: false, span: 7 }, +}; + +// usSortRows 按当前排序字段降序(默认合计 Token,最大者最相关)。 +function usSortRows(rows, sort) { + const out = rows.slice(); + const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; + const val = a => sort === 'requests' ? num(a.requests) + : sort === 'errors' ? num(a.errors) + : sort === 'latency' ? num(a.avg_latency_ms) + : num(a.total_tokens); + out.sort((a, b) => val(b) - val(a)); + return out; +} + +function usDimHead(dim) { + const m = US_DIMS[dim] || US_DIMS.account; + return '' + esc(m.title) + '' + + (m.withRealm ? '域' : '') + + '请求失败' + + 'PromptCompletion合计' + + (m.withPerf ? '均延迟均速率' : '') + + ''; +} + +/* usTabsHtml 维度切换按钮(带条数徽标)。整段 innerHTML 重写而不是逐个改 class: + 容器的 click 监听是委托式的,换掉子节点不会丢事件,代码也更短。 */ +function usTabsHtml(dims, active, counts) { + return dims.map(([k, label]) => { + const n = counts ? counts[k] : null; + return ''; + }).join(''); +} + +const US_DIM_TABS = [['account', '按账号'], ['model', '按模型'], ['realm', '按域']]; + +function renderUsageDim() { + const d = usageData || {}; + const m = US_DIMS[usDim] || US_DIMS.account; + const rows = usSortRows(d[m.key] || [], usSort); + $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, { + account: (d.by_account || []).length, + model: (d.by_model || []).length, + realm: (d.by_realm || []).length, + }); + $('usDimHead').innerHTML = usDimHead(usDim); + $('usDimBody').innerHTML = rows.map(x => + usRow(usDim === 'account' ? String(x.key || '').slice(0, 8) : x.key, + usDim === 'account' ? (x.extra || '') : '', + x, + usDim === 'account' ? '' + esc(x.realm || '') + '' : '', + m.withPerf) + ).join('') || '暂无数据'; + $('usDimNote').textContent = rows.length + ' 行 · 请求数含失败尝试'; +} + +/* 积分扣除:按账号 / 按模型两个维度共用一张表(同上,两个 box 合成一个)。 */ +function usCreditHead(dim) { + return dim === 'model' + ? '模型积分倍率' + + '请求扣除积分' + + '有效样本 Token积分 / 1M Token缓存命中率' + : '账号' + + '请求扣除积分' + + '有效样本 Token积分 / 1M Token缓存命中率'; +} + +/* 缓存命中率(issue #92):颜色即健康度——≥90% 绿 / 80–90% 黄 / <80% 红, + 样本不足灰。title 带命中/未命中绝对量,供逐项核对。 */ +function cacheRateCell(hit, miss) { + const h = Number(hit || 0), m = Number(miss || 0), total = h + m; + if (!total) return '—'; + const pct = h / total * 100; + const color = pct >= 90 ? 'var(--ok)' : (pct >= 80 ? 'var(--warn)' : 'var(--bad)'); + const txt = trimFixed(pct.toFixed(1)) + '%'; + return '' + txt + ''; +} + +function renderCreditDim() { + const d = usageData || {}; + $('usCreditHead').innerHTML = usCreditHead(usCreditDim); + const empty = '暂无积分扣除记录;升级前仅含 Token 的历史不会伪造积分。'; + if (usCreditDim === 'model') { + const models = d.credit_by_model || []; + $('usCreditBody').innerHTML = models.map(row => + '' + + '' + + '' + esc(row.key || '—') + '' + + '' + esc(fmtModelRate(row.rate)) + '' + + '' + fmtTok(row.requests) + '' + + '' + fmtCredit(row.credits) + '' + + '' + fmtTok(row.credit_tokens) + '' + + '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + '' + + '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + '' + + '' + ).join('') || '' + empty + ''; + } else { + const accounts = d.credit_by_account || []; + $('usCreditBody').innerHTML = accounts.map(row => { + const uid = String(row.key || ''); + const account = row.nickname || uid.slice(0, 8) || '—'; + return '' + + '' + + '' + esc(account) + '
' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + '
' + + '' + fmtTok(row.requests) + '' + + '' + fmtCredit(row.credits) + '' + + '' + fmtTok(row.credit_tokens) + '' + + '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + '' + + '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + '' + + ''; + }).join('') || '' + empty + ''; + } + $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, { + account: (d.credit_by_account || []).length, + model: (d.credit_by_model || []).length, + }); +} + +function renderUsage(d) { + usageData = d || {}; + const t = usageData.totals || {}; + const total = Number(t.total_tokens || 0); + const pt = Number(t.prompt_tokens || 0); + const ct = Number(t.completion_tokens || 0); + const reqs = Number(t.requests || 0); + const errs = Number(t.errors || 0); + const okRate = reqs ? (reqs - errs) / reqs * 100 : null; + // 构成条要的是合法 CSS 宽度,usPct 在无样本时返回 "—",不能直接拼进 style。 + const pctW = (part) => total ? Math.max(0, Math.min(100, Number(part || 0) / total * 100)).toFixed(2) + '%' : '0%'; + // 六张卡:主指标用强调色,completion 用成功色(与图表里的绿柱呼应), + // 失败/延迟只在有值时上语义色——全绿全黄的仪表盘等于没有重点。 + $('usStats').innerHTML = + usKpi(fmtTok(reqs), '请求数', 'c-accent', + errs ? '其中失败 ' + errs + ' 次' : '全部成功') + + usKpi(fmtTok(total), '总 token', 'c-accent', + 'prompt ' + usPct(pt, total) + ' · completion ' + usPct(ct, total), + '
' + + '
') + + usKpi(fmtTok(pt), 'prompt', 'c-soft', '占比 ' + usPct(pt, total)) + + usKpi(fmtTok(ct), 'completion', 'c-ok', '占比 ' + usPct(ct, total)) + + usKpi(String(errs), '失败尝试', errs ? 'c-warn' : 'c-mute', + okRate == null ? '—' : (errs ? '成功率 ' + okRate.toFixed(1) + '%' : '成功率 100%')) + + usKpi(fmtMs(t.avg_latency_ms), '平均延迟', 'c-soft', + t.avg_tokens_per_second ? '吐字 ' + fmtRate(t.avg_tokens_per_second) : '无速率样本'); + + // 卡片、明细表与时序图全部按所选窗口统计(切窗口数字随之变化); + // 「全部历史」含 90 天前折叠出的日桶。这里标注当前口径与数据起点。 + const winLabel = trangeLabel('usRange'); + // 服务端回显的实际区间优先(自定义区间下它就是权威口径);滚动窗口没有回显, + // 用控件自己的标签。 + const rangeEcho = usageData.window_from + ? String(usageData.window_from).replace('T', ' ').slice(0, 16) + + (usageData.window_to ? ' → ' + String(usageData.window_to).replace('T', ' ').slice(0, 16) : ' → 现在') + : ''; + const note = (rangeEcho || winLabel ? (rangeEcho || winLabel) + ' · ' : '') + + (usageData.buckets || 0) + ' 个分桶' + + (usageData.since ? ' · 数据自 ' + usageData.since.replace('T', ' ') : '') + + (usageData.file_bytes ? ' · 文件 ' + (usageData.file_bytes / 1024).toFixed(1) + ' KB' : ''); + $('usNote').textContent = note; + $('usNote').title = note; // 窄屏单行截断时靠悬停看全 + + // 积分扣除的四张卡片与说明。 + $('usCreditStats').innerHTML = + usKpi(fmtCredit(t.credits), '扣除积分', 'c-accent', '按上游 usage.credit 累计') + + usKpi(fmtTok(t.credit_tokens), '匹配 Token', 'c-mute', '与积分同时观测到的 Token') + + usKpi(fmtCreditRatio(t.credits_per_1m_tokens, t.credit_samples, t.credit_tokens), + '平均积分 / 1M Token', 'c-ok', '越低越划算') + + usKpi(String(t.credit_samples || 0), '有效积分样本', 'c-mute', '缺字段的历史不参与折算') + + usKpi(cacheRateText(t.cache_hit_tokens, t.cache_miss_tokens), '缓存命中率', 'c-mute', + '上游前缀缓存命中 / (命中+未命中);低命中意味着费用数倍放大'); + $('usCreditNote').textContent = + (usageData.credit_by_account || []).length + ' 个账号 · ' + + (usageData.credit_by_model || []).length + ' 个模型倍率分组 · 仅统计与积分同时观测到的 Token'; + + renderUsageDim(); + renderCreditDim(); + renderUsageChart(usageData.series || []); +} + +// 维度切换 / 排序控件。 +if ($('usDimTabs')) $('usDimTabs').addEventListener('click', ev => { + const b = ev.target.closest('button[data-dim]'); + if (!b) return; + usDim = b.dataset.dim; + renderUsageDim(); +}); +if ($('usCreditTabs')) $('usCreditTabs').addEventListener('click', ev => { + const b = ev.target.closest('button[data-dim]'); + if (!b) return; + usCreditDim = b.dataset.dim; + renderCreditDim(); +}); +if ($('usSort')) $('usSort').onchange = () => { + usSort = $('usSort').value; + renderUsageDim(); +}; + +/* renderUsageChart 画堆叠柱状图。 + * + * x 轴是**真实时间轴**,不是按序号等距。这一点很重要:数据里存在 1 小时的 + * 间隔,也存在 6~8 小时的断档(没请求的时段不产生桶),等距排布会把 8 小时 + * 画得和 1 小时一样宽,让「什么时候用的」完全失真。 + * + * 另外不再用 preserveAspectRatio="none":那会把 viewBox 横向拉伸到容器宽度, + * 柱子和文字都变形。改为固定比例、按容器宽度自适应高度。 + * + * viewBox 取 1200×200(原 760×180):SVG 以 width:100% 渲染,高宽比决定实际 + * 高度——旧比例在 1500px 宽的主区里会撑到 ~355px,只有一两根柱子时整块几乎是 + * 空白。宽 viewBox 把同宽度下的高度压到 ~250px,与下方表格的视觉重量相当。 + * + * 时间轴用本地时间解析(后端返回的就是本地时区),day 点按当天 00:00 参与定位, + * 与 hour 点在同一个连续轴上——日桶本来就是他那天所有小时的聚合。 + */ + +/* parsePointTime 把后端的 t 解析成毫秒时间戳。 */ +function parsePointTime(p) { + // hour: "2026-09-16T13" day: "2026-09-16" + const s = p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00'; + const d = new Date(s); + return isNaN(d.getTime()) ? null : d.getTime(); +} + +/* fmtTokTimeLabel 时间桶的短标签,与 x 轴刻度同一口径(日桶 MM-DD,小时桶 HH:00)。 */ +function fmtTokTimeLabel(p) { + const d = new Date(p.t); + return p.scope === 'day' + ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + : String(d.getHours()).padStart(2, '0') + ':00'; +} + +function renderUsageChart(series) { + const host = $('usChart'); + + // 丢掉时间解析不出来的点,而不是让 NaN 传染整张图。 + const pts = []; + for (const p of series) { + const t = parsePointTime(p); + if (t === null) continue; + const pt = Number(p.prompt_tokens || 0); + const ct = Number(p.completion_tokens || 0); + pts.push({ t, scope: p.scope, raw: p.t, pt, ct, tt: Number(p.total_tokens || 0) || (pt + ct), + req: p.requests || 0 }); + } + if (!pts.length) { + host.innerHTML = '
暂无用量数据。发起一次对话后再刷新。
'; + $('usChartNote').textContent = '—'; + return; + } + + const W = 1200, H = 200, PL = 58, PR = 14, PT = 18, PB = 30; + const iw = W - PL - PR, ih = H - PT - PB; + + const t0 = pts[0].t; + const t1 = pts[pts.length - 1].t; + const span = Math.max(1, t1 - t0); + + const max = Math.max(1, ...pts.map(p => p.tt)); + const peak = pts.reduce((a, b) => (b.tt > a.tt ? b : a), pts[0]); + const avg = pts.reduce((s, p) => s + p.tt, 0) / pts.length; + $('usChartNote').textContent = + pts.length + ' 个点 · 峰值 ' + fmtTok(peak.tt) + ' @ ' + fmtTokTimeLabel(peak) + + ' · 均值 ' + fmtTok(avg); + + // 柱宽取「最小真实间隔」的 70%,并夹在合理区间内——窗口拉到 30 天时柱子会 + // 变细,但不会细到看不见。 + let minGap = Infinity; + for (let i = 1; i < pts.length; i++) minGap = Math.min(minGap, pts[i].t - pts[i - 1].t); + if (!isFinite(minGap) || minGap <= 0) minGap = span; + const slot = iw * (minGap / span); + const bw = Math.max(2, Math.min(30, slot * 0.7)); + + // 首尾各让出半个柱宽:否则第一个点和最后一个点的柱子会各有一半跑到绘图区外 + // (末点柱子贴着卡片右边缘被切掉),刻度仍用同一个 xOf,标签与柱子始终对齐。 + const xOf = t => PL + bw / 2 + (t - t0) / span * Math.max(1, iw - bw); + const yOf = v => PT + ih - ih * (v / max); + + let out = ''; + + // 柱体渐变:顶部实、底部略透,堆叠时两段仍能一眼分清(纯色块并排会糊成一片)。 + // 注意 stop-color 必须走 style 而不是 presentation 属性——Blink/WebKit 不解析 + // 属性里的 var(),写成 stop-color="var(--accent)" 会整条渐变失效(柱子全透明)。 + out += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + // y 轴网格 + 刻度 + for (let i = 0; i <= 4; i++) { + const y = PT + ih - (ih * i / 4); + out += ''; + out += '' + fmtTok(max * i / 4) + ''; + } + + // 均值参考线:一眼看出"这根是不是异常高",比只给刻度省心。 + // 标签放左侧:右侧常被峰值柱占用(峰值柱往往就是最后一根),贴左不会被压住。 + if (avg > 0 && avg < max) { + const y = yOf(avg); + out += ''; + out += '均值 ' + fmtTok(avg) + ''; + } + + // 柱子 + const yBase = PT + ih; + for (const p of pts) { + const x = xOf(p.t) - bw / 2; + const hTot = ih * (p.tt / max); + const hP = p.tt ? hTot * (p.pt / p.tt) : 0; + const hC = Math.max(p.tt && p.ct ? 1 : 0, hTot - hP); + // 圆角只给堆叠顶端(贴轴的底边保持方角,柱子才像"立"在基线上)。 + // 类名用 usbar 而不是 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 + // height 是 rect 的 CSS 几何属性,同名类会把每根柱子压成 3px 高(踩过)。 + if (hP > 0) out += ' 0 ? '' : ' rx="1.5"') + '/>'; + if (hC > 0) out += ''; + out += '' + esc(p.raw) + ' ' + fmtTok(p.pt) + ' prompt / ' + + fmtTok(p.ct) + ' completion / ' + p.req + ' 次'; + } + + // 峰值标注:柱子够窄时文字压在柱顶,够宽时贴右侧避免和柱体重叠。 + { + const px = xOf(peak.t); + const py = yOf(peak.tt); + const anchor = px > W - PR - 90 ? 'end' : 'middle'; + out += '' + + '峰值 ' + fmtTok(peak.tt) + ''; + } + + // x 轴基线画在柱子之后,避免压在柱底 + out += ''; + + // x 轴刻度:按真实时间等距取 6 个位置,取该位置**最近的实际柱子**做标签, + // 所以标签永远落在有数据的点上,不会指到空档里。 + const TICKS = Math.min(6, pts.length); + const usedLabel = new Set(); + for (let k = 0; k < TICKS; k++) { + const target = t0 + span * (TICKS === 1 ? 0.5 : k / (TICKS - 1)); + let bi = 0, best = Infinity; + for (let i = 0; i < pts.length; i++) { + const d = Math.abs(pts[i].t - target); + if (d < best) { best = d; bi = i; } + } + if (usedLabel.has(bi)) continue; + usedLabel.add(bi); + const p = pts[bi]; + // 首尾标签靠边对齐,避免被裁掉 + const cx = xOf(p.t); + const anchor = cx < PL + 14 ? 'start' : (cx > W - PR - 14 ? 'end' : 'middle'); + out += '' + esc(fmtTokTimeLabel(p)) + ''; + } + + // 跨天时补一条日期分隔线,让「日界」在长窗口里可见 + let prevDay = null; + for (const p of pts) { + const d = new Date(p.t).getDate(); + if (prevDay !== null && d !== prevDay) { + const x = xOf(p.t).toFixed(1); + out += ''; + } + prevDay = d; + } + + out += ''; + host.innerHTML = out; +} + +function fmtTokTip(v) { return fmtTok(v); } + +let usageRateWarmAt = 0; +async function warmUsageModelRates() { + if (Date.now() - usageRateWarmAt < 10 * 60 * 1000) return; + try { + await api('models'); + } catch (e) { + // 倍率回填是可选增强;失败不阻塞用量统计,10 分钟后再试。 + } + usageRateWarmAt = Date.now(); +} + +async function loadUsage() { + const q = trangeQuery('usRange', true); + try { + await warmUsageModelRates(); + const d = await api('usage?' + q.toString()); + renderUsage(d); + } catch (e) { + // 失败时三块都要清干净:只改图表会留下上一次窗口的数字,看起来像"刷新成功"。 + usageData = null; + $('usChart').innerHTML = '
读取用量失败:' + esc(e.message) + '
'; + $('usChartNote').textContent = '—'; + $('usStats').innerHTML = ''; + $('usCreditStats').innerHTML = ''; + $('usNote').textContent = '—'; + $('usCreditNote').textContent = '—'; + $('usDimNote').textContent = '—'; + $('usDimHead').innerHTML = ''; + $('usCreditHead').innerHTML = ''; + $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, null); + $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, null); + $('usDimBody').innerHTML = '读取用量失败'; + $('usCreditBody').innerHTML = '读取用量失败'; + } +} + +if ($('btnUsage')) $('btnUsage').onclick = loadUsage; +// 时间范围控件绑定:任何改动(预设切换 / 自定义起止)都重新拉一次用量。 +if ($('usRange')) trangeBind('usRange', loadUsage); + +/* ── 积分构成 ─────────────────────────────────────────────────────── */ +/* 一个账号的余额是若干积分包之和。包按来源命名(「国内运营裂变包」「拉新权益包」 + 「个人体验版」…),面额从 6 到 1500 不等,且**按次发放**。所以两个任务完成度 + 完全一致的账号,余额可能差上千——差别只在包里。这里把逐包明细摊开,并给每个 + 包名一个稳定配色,跨账号对比时同色即同类。 */ + +const PK_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', '#e2607a', + '#5aa9e6', '#8fbf3f', '#b58b5a', '#7d8fa8', '#d4785c']; +const PK_ACCOUNT_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', + '#e2607a', '#20a4a4', '#8fbf3f', '#d4785c', + '#7c83db', '#c48a2f', '#b45f8c', '#5aa9e6']; + +function pkColor(i) { return PK_COLORS[i % PK_COLORS.length]; } + +// pkAccountColorMap 按 UID 稳定分配颜色:排序后分配,账号刷新/重排不会换色。 +function pkAccountColorMap(list) { + const uids = (list || []) + .filter(a => a && !a.error && a.uid) + .map(a => String(a.uid)) + .sort(); + const colors = new Map(); + uids.forEach((uid, i) => colors.set(uid, PK_ACCOUNT_COLORS[i % PK_ACCOUNT_COLORS.length])); + return colors; +} + +/* pkBySource 把包按名称归并,得到「来源 → 面额/余额/个数」。这是对比的关键视图: + 两个号的差异一定体现在某几个来源的面额上。 */ +function pkBySource(packs) { + const m = new Map(); + for (const p of packs) { + // 分组键用 code + name,而不是只 name:上游给「首登赠送」和普通活动包用了 + // **同一个 PackageName 和同一个 PackageCode**,只按 name 会把两类混成一类, + // 那正是当初「两个号为何差 1500」看不出来的原因。这里至少把 code 带进键里, + // 并在卡片上显示最早的发放时间。 + const k = (p.package_code || '') + '|' + (p.name || '(未命名)'); + const e = m.get(k) || { + key: k, name: p.name || '(未命名)', code: p.package_code || '', + n: 0, remain: 0, size: 0, used: 0, minEnd: '', minCreated: '', + }; + e.n += 1; + e.remain += Number(p.remain || 0); + e.size += Number(p.size || 0); + e.used += Number(p.used || 0); + const t = (p.end_time || '').slice(0, 10); + if (t && (!e.minEnd || t < e.minEnd)) e.minEnd = t; + const c = (p.created_at || '').slice(0, 10); + if (c && (!e.minCreated || c < e.minCreated)) e.minCreated = c; + m.set(k, e); + } + return [...m.values()].sort((a, b) => b.size - a.size); +} + +const PK_DEFAULT_DETAIL_LIMIT = 5; + +function pkDetailLimitValue(raw) { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : PK_DEFAULT_DETAIL_LIMIT; +} + +function pkDetailLimit(cfg) { + return pkDetailLimitValue(cfg && cfg.panel && cfg.panel.package_detail_limit); +} + +const PK_DAY_MS = 24 * 3600 * 1000; + +function pkExpiryMs(p) { + const raw = Number(p && p.expires_at); + if (Number.isFinite(raw) && raw > 0) return raw; + const text = String((p && p.end_time) || '').trim(); + if (!text) return null; + let iso = text.includes('T') ? text : text.replace(' ', 'T'); + if (!/(?:Z|[+-]\d\d:\d\d)$/.test(iso)) iso += '+08:00'; + const parsed = Date.parse(iso); + return Number.isFinite(parsed) ? parsed : null; +} + +// pkDetailGroups 只服务单账号逐包明细:正余额包先按到期时间挑选默认展示项, +// 其余正余额包与已用完包分别折叠;同一到期时间按面额降序。 +function pkDetailCompare(a, b) { + const sizeOf = p => { + const n = Number(p && p.size); + return Number.isFinite(n) ? n : 0; + }; + const ea = pkExpiryMs(a), eb = pkExpiryMs(b); + if (ea == null && eb != null) return 1; + if (ea != null && eb == null) return -1; + if (ea != null && eb != null && ea !== eb) return ea - eb; + return sizeOf(b) - sizeOf(a); +} + +function pkDetailGroups(packs, limit) { + const active = [], used = []; + let usedSize = 0, restSize = 0, restRemain = 0; + for (const p of packs || []) { + const remain = Number(p && p.remain); + if (remain > 0) { + active.push(p); + continue; + } + used.push(p); + const size = Number(p && p.size); + if (Number.isFinite(size)) usedSize += size; + } + active.sort(pkDetailCompare); + used.sort(pkDetailCompare); + const visible = active.slice(0, pkDetailLimitValue(limit)); + const rest = active.slice(visible.length); + for (const p of rest) { + const size = Number(p && p.size); + if (Number.isFinite(size)) restSize += size; + const remain = Number(p && p.remain); + if (Number.isFinite(remain)) restRemain += remain; + } + return { visible, rest, used, restSize, restRemain, usedSize }; +} + +function pkCreditOpacity(days) { + if (days == null || !Number.isFinite(Number(days))) return 1; + return 0.25 + 0.75 * Math.max(0, Math.min(29, Number(days) - 1)) / 29; +} + +function pkExpiryText(expiresAt) { + if (!expiresAt) return '无到期时间'; + const diff = expiresAt - Date.now(); + if (diff <= 0) return '已到期'; + const minutes = Math.max(1, Math.ceil(diff / 60000)); + if (minutes < 60) return '剩余 ' + minutes + ' 分钟'; + const hours = Math.ceil(diff / 3600000); + if (hours < 24) return '剩余 ' + hours + ' 小时'; + return '剩余 ' + Math.ceil(diff / PK_DAY_MS) + ' 天'; +} + +function pkExpiryDateTime(expiresAt) { + if (!expiresAt) return '—'; + return new Date(expiresAt).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); +} + +function pkAccountSegments(a, now) { + let balance = Math.max(0, Number(a.remain || 0)); + const out = []; + for (const p of a.packages || []) { + const remain = Number(p.remain || 0); + if (!Number.isFinite(remain) || remain <= 0 || balance <= 0) continue; + const amount = Math.min(balance, remain); + const expiresAt = pkExpiryMs(p); + out.push({ + amount, + expiresAt, + days: expiresAt == null ? null : Math.max(0, Math.ceil((expiresAt - now) / PK_DAY_MS)), + source: p.name || '积分', + uid: String(a.uid || ''), + accountName: a.nickname || String(a.uid || '').slice(0, 8) || '未命名账号', + }); + balance -= amount; + } + return out.sort((x, y) => { + if (x.expiresAt == null && y.expiresAt != null) return 1; + if (x.expiresAt != null && y.expiresAt == null) return -1; + return (x.expiresAt || 0) - (y.expiresAt || 0); + }); +} + +// summarizeCreditDays 对齐 WorkDaddy:按精确剩余天数逐行聚合,无有效到期时间的余额 +// 不进入图表,也不猜测到期日。账号内先按总余额约束逐包金额,避免上游重复记录膨胀。 +function summarizeCreditDays(list, now) { + const buckets = new Map(); + let unavailable = 0; + for (const a of list || []) { + if (a.error || !Number.isFinite(Number(a.remain))) { + unavailable++; + continue; + } + for (const segment of pkAccountSegments(a, now)) { + if (segment.days == null) continue; + let row = buckets.get(segment.days); + if (!row) { + row = { days: segment.days, credits: 0, segments: [] }; + buckets.set(segment.days, row); + } + row.credits += segment.amount; + row.segments.push(segment); + } + } + const rows = [...buckets.values()].sort((a, b) => a.days - b.days); + for (const row of rows) { + row.segments.sort((a, b) => + (a.expiresAt || Infinity) - (b.expiresAt || Infinity) || + a.accountName.localeCompare(b.accountName) || + a.source.localeCompare(b.source)); + } + return { rows, accountCount: (list || []).length, unavailable }; +} + +function renderExpiryDistribution(list, now) { + const summary = summarizeCreditDays(list, now); + const colors = pkAccountColorMap(list); + const rows = summary.rows.map(row => { + const total = row.credits || 1; + const nodes = row.segments.map(segment => { + const color = colors.get(segment.uid) || 'var(--accent)'; + const title = segment.source + '\n' + fmtTok(segment.amount) + ' 积分\n到期时间 ' + + pkExpiryDateTime(segment.expiresAt) + '(' + pkExpiryText(segment.expiresAt) + ')\n' + + segment.accountName; + return ''; + }).join(''); + return '
' + esc(row.days === 0 ? '已到期' : row.days + ' 天') + + '
' + nodes + '
' + esc(fmtTok(row.credits)) + + '
'; + }).join(''); + const foot = summary.accountCount + ' 个账号' + + (summary.unavailable ? ' · ' + summary.unavailable + ' 个未获取余额' : ''); + const legend = (list || []).filter(a => + a && !a.error && a.uid && pkAccountSegments(a, now).some(s => s.days != null) + ).map(a => '' + esc(a.nickname || String(a.uid).slice(0, 8)) + '').join(''); + const hdr = '
剩余天数各账号该批剩余剩余积分
'; + $('pkExpiry').innerHTML = (rows + ? hdr + '
' + rows + '
' + : '
暂无可汇总积分
') + + (legend ? '
' + legend + '
' : '') + + '
' + esc(foot) + '
'; +} + +function renderPackages(d, detailLimit) { + const list = (d.accounts || []); + const now = Date.now(); + const expiryColors = pkAccountColorMap(list); + renderExpiryDistribution(list, now); + if (!list.length) { + $('pkSummary').innerHTML = '
没有账号
'; + return; + } + + // 包名 → 稳定色号(跨账号一致,方便肉眼对齐) + const names = []; + for (const a of list) for (const s of pkBySource(a.packages || [])) { + if (!names.includes(s.key)) names.push(s.key); + } + names.sort((x, y) => { + const sz = n => Math.max(...list.map(a => { + const f = pkBySource(a.packages || []).find(s => s.key === n); + return f ? f.size : 0; + })); + return sz(y) - sz(x); + }); + const colorOf = n => pkColor(names.indexOf(n)); + // 键 → 展示名,供卡片与明细表共用(同一来源必然同色同名)。 + const labelOf = {}; + for (const a of list) for (const s of pkBySource(a.packages || [])) labelOf[s.key] = s; + + const maxRemain = Math.max(1, ...list.map(a => Number(a.remain || 0))); + + $('pkSummary').innerHTML = list.map(a => { + if (a.error) { + return '
' + + esc((a.nickname || a.uid.slice(0, 8))) + '' + + '' + esc(a.realm || '') + '
' + + '
查询失败:' + esc(a.error) + '
'; + } + const srcs = pkBySource(a.packages || []); + const total = Math.max(1, Number(a.size || 0)); + const bar = srcs.map(s => + '' + ).join(''); + const legend = srcs.map(s => + '' + + esc(s.name.replace(/^CodeBuddy/, '')) + ' x' + s.n + ' · ' + fmtTok(s.size) + + (s.minCreated ? ' · 首发 ' + esc(s.minCreated.slice(5)) : '') + '' + ).join(''); + const expiry = pkAccountSegments(a, now); + const expiryTotal = Math.max(1, expiry.reduce((sum, s) => sum + s.amount, 0)); + const expiryColor = expiryColors.get(String(a.uid)) || 'var(--accent)'; + const expiryBar = expiry.length ? '' : ''; + return '
' + + '
' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(a.realm || '') + '
' + + '
' + fmtTok(a.remain) + '
' + + '
共 ' + fmtTok(a.size) + ' · ' + (a.packages || []).length + + ' 个包 · 占最高 ' + (Number(a.remain || 0) / maxRemain * 100).toFixed(0) + '%
' + + '
' + bar + '
' + + expiryBar + + '
' + legend + '
' + + '
'; + }).join(''); + + $('pkNote').textContent = list.length + ' 个账号 · 实时查询上游'; + + // 逐包明细:每个账号一个表,包的**面额**列是重点 + $('pkDetail').innerHTML = list.map(a => { + if (a.error) return ''; + const groups = pkDetailGroups(a.packages || [], detailLimit); + const rowOf = (p, rowGroup) => { + const k = (p.package_code || '') + '|' + (p.name || '(未命名)'); + const sub = (p.sub_product_code || '').replace(/^sp_tcaca_codebuddyide_?/, '') || + (p.package_code || '').replace(/^TCACA_/, ''); + return '' + + '' + esc(p.name || '(未命名)') + + (sub ? '
' + esc(sub) + '
' : '') + '' + + '' + fmtTok(p.size) + '' + + '' + fmtTok(p.remain) + '' + + '' + fmtTok(p.used) + '' + + '' + esc((p.created_at || '').slice(0, 16).replace('T', ' ') || '—') + '' + + '' + esc((p.end_time || '').slice(0, 10) || '—') + '' + + ''; + }; + const groupSummary = (group, label, count, size, remain) => + ''; + const rows = groups.visible.map(p => rowOf(p, '')).join(''); + const restSummary = groups.rest.length + ? groupSummary('rest', '其余未用完 ' + groups.rest.length + ' 个包(面额合计 ' + + fmtTok(groups.restSize) + ' · 剩余 ' + fmtTok(groups.restRemain) + ')', + groups.rest.length, groups.restSize, groups.restRemain) + + groups.rest.map(p => rowOf(p, 'rest')).join('') + : ''; + const usedSummary = groups.used.length + ? groupSummary('used', '已用完 ' + groups.used.length + ' 个包(面额合计 ' + + fmtTok(groups.usedSize) + ')', groups.used.length, groups.usedSize, 0) + + groups.used.map(p => rowOf(p, 'used')).join('') + : ''; + return '

' + + esc(a.nickname || a.uid.slice(0, 8)) + ' · ' + esc(a.realm || '') + + '

余额 ' + fmtTok(a.remain) + + ' / 总额 ' + fmtTok(a.size) + ' · 可用 ' + (groups.visible.length + groups.rest.length) + ' 个包' + + (groups.used.length ? ' / 已用完 ' + groups.used.length + ' 个' : '') + + ' · 默认展示最早到期 ' + pkDetailLimitValue(detailLimit) + ' 条' + + '
' + + '' + + '' + + '' + + '' + rows + restSummary + usedSummary + '
包名 / 来源面额剩余已用发放到期
'; + }).join(''); +} + +if ($('pkDetail')) $('pkDetail').addEventListener('click', ev => { + const btn = ev.target.closest('button[data-pk-group]'); + if (!btn) return; + const body = btn.closest('tbody'); + if (!body) return; + const group = btn.dataset.pkGroup; + const expanded = btn.getAttribute('aria-expanded') === 'true'; + body.querySelectorAll('tr[data-pk-row="' + group + '"]').forEach(row => { row.hidden = expanded; }); + const count = btn.dataset.count || '0'; + const size = btn.dataset.size || '0'; + const remain = btn.dataset.remain || '0'; + btn.setAttribute('aria-expanded', String(!expanded)); + if (group === 'rest') { + btn.textContent = expanded + ? '其余未用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + ' · 剩余 ' + + fmtTok(remain) + '),展开' + : '收起其余未用完 ' + count + ' 个包'; + } else { + btn.textContent = expanded + ? '已用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + '),展开' + : '收起已用完 ' + count + ' 个包'; + } +}); + +async function loadPackages() { + $('pkSummary').innerHTML = '
查询中…(逐账号向上游实时查询)
'; + $('pkDetail').innerHTML = ''; + $('pkExpiry').innerHTML = '
查询中…
'; + try { + const [d, c] = await Promise.all([ + api('packages'), + api('config').catch(() => null), + ]); + renderPackages(d, pkDetailLimit(c && c.config)); + } catch (e) { + $('pkSummary').innerHTML = '
读取失败:' + esc(e.message) + '
'; + $('pkExpiry').innerHTML = '
读取失败:' + esc(e.message) + '
'; + } +} + +if ($('btnPk')) $('btnPk').onclick = loadPackages; diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go new file mode 100644 index 0000000000000000000000000000000000000000..bfe1a2f80cab2d1521ae32578f0f368c8a80713d --- /dev/null +++ b/internal/panel/autotask.go @@ -0,0 +1,1265 @@ +// autotask.go 面板「一键完成」任务的动作实现。 +// +// 设计依据:上游 scripts/task_*.py 实测结论 + 2026-09-12 桌面指纹协议逆向 +// (data/desktop-task-protocol.md)—— +// - first_buddy(+300 分):report(前置解锁)→ agreement → buddy/first +// - chat_5(+100 分):累计 5 条 chat_request_send 上报 +// - Model_chat_GLM5.2(+100 分):accept → 用 glm-5.2 真实对话一次 → 上报(模型字段对齐) +// - RichMeow_Chat:桌面指纹(workbuddy-desktop)完整对话事件链,纯 API 可点亮(三账号实测) +// - Buddy_App / Buddy_App_QQ:buddyapp 五连事件,纯 API 可点亮(两账号实测) +// - automation_1:automated_task_create_suc 事件,纯 API 可点亮(两账号实测) +// - Library_read:web 域 web_element_click(library_doc_intro_click)(三账号实测) +// - template_5 / playbook_prompt / create_canvas:asar 逆向出的判据事件 +// (template_used / playbook_prompt_send / wbx_design_canvas_*),纯 API 可点亮(三账号实测) +// - expert_5 / Expert_team_use_3:真实专家列表 + 召唤链 + 真实 chat(服务端 requestId) +// - expert_actual_use(三账号实测) +// - Hp_Appearance:appearance/set + appearance_skin_apply 事件(两账号实测) +// +// 仍未破解:skill_1(疑似要求真实 Skill 工具调用)。 +// 不做:Expert_lighthouse(需真实连接器授权)、Expert_Philanthropy(真实捐款)。 +// +// 所有动作幂等:已 claimed/已达标的任务直接跳过,不重复消耗上游配额。 +package panel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/rand/v2" + "net/http" + "strings" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// autoAction 一个可自动化的任务动作。 +type autoAction struct { + TaskCode string // 目标任务 code + Desc string // 展示用说明 + Attempt bool // true = 尝试型(上游未证实可脚本化,跑了可能不点亮) + run func(p *Panel, a *auth.Auth) (string, error) +} + +// autoActions 已实现的任务动作表(顺序即执行顺序:先解锁依赖项)。 +// first_buddy 依赖活跃上报解锁,故 chat_5/first_buddy 的执行都自带 report 步骤。 +var autoActions = []autoAction{ + { + TaskCode: "chat_5", + Desc: "上报 5 条对话活跃事件(自动补足差额)", + run: runChat5, + }, + { + TaskCode: "first_buddy", + Desc: "上报解锁 → 同意协议 → 领取第一只 Buddy(+300 分)", + run: runFirstBuddy, + }, + { + TaskCode: "Model_chat_GLM5.2", + Desc: "接受任务 → glm-5.2 真实对话一次 → 对齐模型上报", + run: runModelChat, + }, + { + TaskCode: "RichMeow_Chat", + Desc: "桌面指纹事件链上报(已验证:纯 API 可点亮,三账号实测)", + run: runRichMeow, + }, + { + TaskCode: "Buddy_App", + Desc: "上报「进入 Buddy 应用」事件链(已验证:纯 API 可点亮)", + run: runBuddyApp, + }, + { + TaskCode: "Buddy_App_QQ", + Desc: "上报「进入企鹅教师助手」事件链(已验证:纯 API 可点亮)", + run: runBuddyApp, + }, + { + TaskCode: "automation_1", + Desc: "上报「定时任务创建」事件(已验证:纯 API 可点亮)", + run: runAutomationCreate, + }, + { + TaskCode: "Library_read", + Desc: "上报「读资料库介绍」事件(已验证:纯 API 可点亮)", + run: runLibraryRead, + }, + { + TaskCode: "template_5", + Desc: "上报「使用模板创建任务」事件组 ×5(已验证:三账号点亮)", + run: runTemplateUse, + }, + { + TaskCode: "playbook_prompt", + Desc: "上报「灵感案例做同款发送 Prompt」事件组(已验证:三账号点亮)", + run: runPlaybookPrompt, + }, + { + TaskCode: "create_canvas", + Desc: "上报「设计创意画布创建」事件组(已验证:三账号点亮,+300 分)", + run: runCreateCanvas, + }, + { + TaskCode: "expert_5", + Desc: "真实专家召唤+使用链 ×5(专家市场列表+真实 chat,已验证:三账号点亮)", + run: runExpertUse, + }, + { + TaskCode: "Expert_team_use_3", + Desc: "真实专家团召唤+使用链 ×3(已验证:三账号点亮)", + run: runExpertTeamUse, + }, + { + TaskCode: "Hp_Appearance", + Desc: "设置主题 API + 皮肤生效事件(已验证:两账号点亮)", + run: runAppearance, + }, + { + TaskCode: "skill_1", + Desc: "真实对话 + skill_info 技能加载事件(已验证:人杰2 点亮)", + run: runSkillFresh, + }, + { + TaskCode: "Expert_lighthouse", + Desc: "真实轻量云专家召唤+使用链(chat 链带 has_expert,已验证:两账号点亮)", + run: runExpertLighthouse, + }, + { + TaskCode: "black_cat", + Desc: "夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示稍后再试)", + Attempt: true, + run: runBlackCat, + }, + { + TaskCode: "school_season", + Desc: "校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)", + run: runSchoolSeason, + }, + { + TaskCode: "Sequential_Tasks_1", + Desc: "小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)", + run: runSequentialChat, + }, + { + TaskCode: "Sequential_Tasks_2", + Desc: "小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)", + run: runMiniExpert, + }, + { + TaskCode: "Sequential_Tasks_3", + Desc: "小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)", + run: runSequentialChat5, + }, + { + TaskCode: "Sequential_Tasks_4", + Desc: "小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件(PC 同源)→ 领奖(判据待解锁验证)", + run: runSequentialAutomation, + }, + { + TaskCode: "Sequential_Tasks_5", + Desc: "小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)", + run: runSequentialModelChat, + }, + { + TaskCode: "Sequential_Tasks_6", + Desc: "小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖", + run: runSequentialChat10, + }, + { + TaskCode: "Sequential_Tasks_7", + Desc: "体验灵感功能(预留,疑 PC 口径 +500c+5e):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)", + run: runSequentialPlaybook, + }, +} + +// autoActionFor 查任务对应的动作;无则返回 nil(不可自动化)。 +func autoActionFor(code string) *autoAction { + for i := range autoActions { + if autoActions[i].TaskCode == strings.TrimSpace(code) { + return &autoActions[i] + } + } + return nil +} + +// autoActionIndex 任务在 autoActions 中的顺序(队列执行按依赖序排;未知返回大值)。 +func autoActionIndex(code string) int { + for i := range autoActions { + if autoActions[i].TaskCode == code { + return i + } + } + return 1 << 20 +} + +// mpTaskCodes 小程序口径专属下发的成长任务:默认(无 mp 头)列表不出现, +// accept/claim 均要求 X-Client-Platform: miniprogram。新任务出现时在此登记。 +var mpTaskCodes = map[string]bool{ + "school_season": true, // 校园日(mini chat + activityId) + "Sequential_Tasks_1": true, // 小程序首对话(mini chat,无 activityId) + "Sequential_Tasks_2": true, // 小程序选中专家并完成有效对话(mp 指纹 expert_actual_use) + "Sequential_Tasks_3": true, // 小程序完成 5 次对话(与 Tasks_1 同形状,target=5 逐条累加) + // Tasks_4..7 存在性已实测(accept 返回 prerequisite not met 链式依赖;Tasks_8 not + // found 封顶)。链条每日零点解锁一环(task locked until 次日),判据为 issue #42 + // 描述 + mpsrc 事件形状预置,解锁后逐个实测校正。 + "Sequential_Tasks_4": true, + "Sequential_Tasks_5": true, + "Sequential_Tasks_6": true, + "Sequential_Tasks_7": true, +} + +// isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。 +func isMPTaskCode(code string) bool { return mpTaskCodes[code] } + +// taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。 +// 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码, +// 未知码不多打一次上游)。 +func (p *Panel) taskByCode(a *auth.Auth, code string) (*upstream.Task, error) { + tasks, err := p.cfg.Upstream.ListTasks(a) + if err != nil { + return nil, err + } + for i := range tasks { + if tasks[i].TaskCode == code { + return &tasks[i], nil + } + } + if isMPTaskCode(code) { + return p.taskByCodeMP(a, code) + } + return nil, nil +} + +// claimPollAttempts / claimPollGap 达标回读的有界轮询参数。 +// 背景:上游计分是**异步**的——行为事件上报后进度要数秒才刷新(实测 Model_chat +// 对话完成后立即回读仍是 0/1,约 5-8 秒后才变 1/1)。一次性回读会误判"未达标", +// 从而跳过自动领奖。这里最多轮询 N 次、每次间隔 gap,总预算约 12 秒。 +var ( + claimPollAttempts = 4 + claimPollGap = 3 * time.Second +) + +// taskByCodeWaiting 回读任务,若未达标则在有界预算内轮询等待(上游异步计分)。 +// 已达标(claimable)立即返回;预算耗尽返回最后一次结果(可能仍未达标)。 +func (p *Panel) taskByCodeWaiting(a *auth.Auth, code string) (*upstream.Task, error) { + t, err := p.taskByCode(a, code) + if err != nil || t == nil { + return t, err + } + if t.Claimable || t.Claimed { + return t, nil + } + for i := 1; i < claimPollAttempts; i++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCode(a, code) + if err2 != nil { + return t, nil // 轮询期间的查询失败不覆盖已拿到的结果 + } + if t2 != nil { + t = t2 + if t.Claimable || t.Claimed { + return t, nil + } + } + } + return t, nil +} + +// taskByCodeMP 以小程序口径拉取任务列表并定位单个任务;未找到返回 nil。 +// 小程序限定任务(school_season / Sequential_Tasks_1)在默认口径列表不出现。 +func (p *Panel) taskByCodeMP(a *auth.Auth, code string) (*upstream.Task, error) { + tasks, err := p.cfg.Upstream.ListTasksMP(a) + if err != nil { + return nil, err + } + for i := range tasks { + if tasks[i].TaskCode == code { + return &tasks[i], nil + } + } + return nil, nil +} + +// acceptWithVerifyMP accept 并回读验证登记生效:上游存在 200+OK 但 accept 未真正 +// 登记的形态(此时上报事件全部不归账,任务永远点不亮,上游 task_runner c793ae3 +// 实测)——判定以回读 accept_status 为准,未生效重试一次。 +func (p *Panel) acceptWithVerifyMP(a *auth.Auth, code string) bool { + for attempt := 1; attempt <= 2; attempt++ { + if err := p.cfg.Upstream.AcceptTasksMP(a, []string{code}); err != nil { + log.Printf("autotask %s %s: accept 尝试%d: %v", logfmt.Label(a.UID, a.Nickname), code, attempt, err) + continue + } + time.Sleep(mpActionGap) + t, err := p.taskByCodeMP(a, code) + if err == nil && t != nil && t.AcceptStatus != "not_accepted" && t.AcceptStatus != "" { + return true + } + log.Printf("autotask %s %s: accept 尝试%d 未登记生效(回读=%q)", logfmt.Label(a.UID, a.Nickname), code, attempt, acceptStatusOr(t)) + } + return false +} + +// acceptStatusOr 安全读取任务 accept_status(nil 任务返回 "?")。 +func acceptStatusOr(t *upstream.Task) string { + if t == nil { + return "?" + } + if t.AcceptStatus == "" { + return "?" + } + return t.AcceptStatus +} + +// mpActionGap mp 任务写动作间隔(accept/上报/领奖之间,防频控)。 +var mpActionGap = 2 * time.Second + +// mpChatEventGap mp 对话事件(chat_request_send)的真人节奏间隔。上游对 +// Sequential_Tasks_3「5 次有效对话」有反作弊校验:数秒级连发的事件会先被计入 +// 进度(回读 5/5、accept_status 甚至短暂转 completed),随后被判定无效整体回滚 +// (进度回落、claim 返回 400 "task not completed")——2026-09-26 实测 2s 连发 +// 4 条全灭,45s 间隔逐条上报全存活且 claim +300c+5e 成功。每条上报前 +// sleep gap + 0~10s 抖动;首条也等(上一轮残留进度被回滚后立即重报同样无效)。 +var mpChatEventGap = 45 * time.Second + +// runMPMiniChatTask growth 域小程序限定任务通用闭环: +// mp 查询 → accept(带登记回读验证)→ mini chat 事件上报(withActivityId 决定 +// 是否带开学季 activityId:school_season 必带,Sequential_Tasks_1 不带——服务端按 +// source=mini_program 指纹关联)→ 回读 → 达标即领奖。 +func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool) (string, error) { + t, err := p.taskByCodeMP(a, code) + if err != nil { + return "", err + } + if t == nil { + return "mp 口径未下发该任务(活动可能已结束)", nil + } + if t.Claimed { + return "已领取", nil + } + if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { + if !p.acceptWithVerifyMP(a, code) { + return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil + } + // accept 前的任务进度为 null(target 下发 0),兜底 target=1 会少报—— + // Tasks_6 首轮实测:accept 后真实 target=10,只补 1 条就误判达标去领奖 + // (claim 400 task not completed)。接受后回读一次拿真实 target/current。 + if t2, err := p.taskByCodeMP(a, code); err == nil && t2 != nil { + t = t2 + } + } + // 已达标(含 completed 未领):直接领奖。 + target := t.Target + if target <= 0 { + target = 1 + } + if t.Current >= target || t.AcceptStatus == "completed" { + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil + } + // 判据上报:按差额补 mini chat 事件。每条前 sleep mpChatEventGap+抖动—— + // 连发会被上游反作弊判无效(见 mpChatEventGap 注释),宁可慢不可白报。 + need := target - t.Current + for i := int64(0); i < need; i++ { + time.Sleep(mpChatEventGap + time.Duration(rand.Int64N(int64(10*time.Second)))) + conv := fmt.Sprintf("wb2api-mp-%d-%d", time.Now().UnixMilli(), i) + var ev map[string]any + if withActivityId { + ev = upstream.SchoolSeasonChatEvent(conv) + } else { + ev = upstream.SchoolChatTimesEvents(conv) + } + if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil { + return fmt.Sprintf("完成 %d/%d 次上报后中断: %v", i, need, err), nil + } + } + // 回读(异步计分,有界轮询复用 claimPoll 预算的紧凑版:两轮各隔 3s)。 + for i := 0; i < 2; i++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCodeMP(a, code) + if err2 != nil || t2 == nil { + continue + } + t = t2 + if t.Claimable || t.Claimed || t.Current >= target { + break + } + } + if t.Claimed { + return "本轮已入账(claimed)", nil + } + if t.Current < target { + return fmt.Sprintf("已上报 %d 次但进度未达 %d/%d(异步计分未归账,下次重试)", need, t.Current, target), nil + } + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil +} + +// runSchoolSeason 完成 school_season「校园日」(growth 域小程序限定)。 +// 判据 = mini chat_request_send + activityId=school_open_day_2026(无 activityId +// 不点亮,与 school 域开学季同活动关联;上游 task_runner e2e 实测 +100c+5e)。 +func runSchoolSeason(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "school_season", true) +} + +// runSequentialChat 完成 Sequential_Tasks_1「小程序内完成 1 次有效对话」。 +// 判据 = mini chat_request_send(无 activityId,服务端按 source=mini_program +// 指纹关联;上游 task_runner 实测 +100c+5e)。 +func runSequentialChat(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "Sequential_Tasks_1", false) +} + +// runSequentialChat5 完成 Sequential_Tasks_3「在小程序内完成 5 次有效对话」。 +// 判据与 Sequential_Tasks_1 同形状(mini 指纹 chat_request_send,无 activityId), +// 仅 target=5——服务端按上报条数累加进度,但**要求真人节奏**:连发事件先计数 +// 后被反作弊回滚(claim 400 "task not completed"),由 mpChatEventGap 间隔保证 +// (2026-09-26 实测:45s 间隔补满 5/5 → claim +300c+5e 成功,领后 accept_status +// =claimed 稳定不回滚)。 +func runSequentialChat5(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "Sequential_Tasks_3", false) +} + +// runSequentialChat10 完成 Sequential_Tasks_6「在小程序内完成 10 次有效对话」(预留)。 +// 判据假定与 Tasks_1/3 同形状(mini chat_request_send),target 由任务自带(回读), +// runMPMiniChatTask 按差额补报——issue #42 称 target=10,以解锁后实际下发为准。 +// 真人节奏间隔同样适用(mpChatEventGap):9 条 × ~50s ≈ 8 分钟/账号,夜间队列可接受。 +func runSequentialChat10(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "Sequential_Tasks_6", false) +} + +// runSequentialEventTask Sequential 链预留任务通用骨架:mp 查询 → accept(带验证) +// → 判据事件上报(primary;未点亮且 fallback 非空时补一轮)→ 回读 → 达标领奖。 +// 每日零点解锁一环:locked 期间 accept 不落账,返回等下次调度(无需人工干预)。 +func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallback func() error) (string, error) { + t, err := p.taskByCodeMP(a, code) + if err != nil { + return "", err + } + if t == nil { + return "mp 口径未下发该任务(前置任务未完成或活动未开始)", nil + } + if t.Claimed { + return "已领取", nil + } + target := t.Target + if target <= 0 { + target = 1 + } + if t.Current >= target || t.AcceptStatus == "completed" { + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil + } + if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { + if !p.acceptWithVerifyMP(a, code) { + return "accept 未登记生效(任务可能处于每日锁定窗口,等解锁后自动重试)", nil + } + } + if err := primary(); err != nil { + return fmt.Sprintf("判据上报失败: %v", err), nil + } + // 回读(两轮各隔 3s);未点亮且有 fallback 时补报一轮再读。 + for round := 0; round < 2; round++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCodeMP(a, code) + if err2 != nil || t2 == nil { + continue + } + t = t2 + if t.Claimable || t.Claimed || t.Current >= target { + break + } + if round == 0 && fallback != nil { + if err := fallback(); err != nil { + return fmt.Sprintf("备选判据上报失败: %v", err), nil + } + } + } + if t.Claimed { + return "本轮已入账(claimed)", nil + } + if t.Current < target { + return "已上报但进度未点亮(判据形态待解锁后校正,下次重试)", nil + } + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil +} + +// runSequentialAutomation 完成 Sequential_Tasks_4「创建定时任务」(预留)。 +// mp 源码无 automation 事件发射点 → 判据疑为 PC 口径:复用 automation_1 同源 +// 事件(DesktopAutomationCreateEvent,PC 任务三账号实测点亮)。 +func runSequentialAutomation(p *Panel, a *auth.Auth) (string, error) { + return p.runSequentialEventTask(a, "Sequential_Tasks_4", + func() error { + return p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopAutomationCreateEvent("wb2api 自动化")) + }, nil) +} + +// runSequentialModelChat 完成 Sequential_Tasks_5「使用 GLM5.2」(预留)。 +// primary:mp 对话事件带 requestModelId/requestModelName=glm-5.2(mpsrc main 32904 +// 发射点实测形状);fallback:PC 域模型活跃上报(Model_chat_GLM5.2 同源)。 +func runSequentialModelChat(p *Panel, a *auth.Auth) (string, error) { + return p.runSequentialEventTask(a, "Sequential_Tasks_5", + func() error { + conv := fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()) + return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniChatModelEvent(conv, "glm-5.2", "GLM-5.2")) + }, + func() error { + return p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()), "", "glm-5.2", "GLM-5.2") + }) +} + +// runSequentialPlaybook 完成 Sequential_Tasks_7「体验灵感功能」(预留,疑 PC 口径)。 +// primary:PC 灵感事件组(DesktopPlaybookPromptSequence,playbook_prompt 三账号 +// 实测点亮);fallback:mp 指纹灵感事件组(MiniPlaybookEvents,mpsrc 形状)。 +func runSequentialPlaybook(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + return p.runSequentialEventTask(a, "Sequential_Tasks_7", + func() error { + conv := fmt.Sprintf("wb2api-pb-%d", ms) + req := fmt.Sprintf("wb2api-pb-req-%d", ms) + return p.cfg.Upstream.ReportDesktopEvent(a, + upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...) + }, + func() error { + return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniPlaybookEvents("pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...) + }) +} + +// runMiniExpert 完成 Sequential_Tasks_2「在小程序内选中专家并完成有效对话」。 +// 判据 = mp 指纹 expert_actual_use(**不带** activityId/conversationId、 +// extVersion=2.2.8、type=send_message——小程序源码实测形状,与 school 域 expert +// 事件两套口径勿混;上游 task_runner 实测上报即 completed,claim +200c+5e)。 +// 专家 id 必须是市场真实 ex_ id(空 id 服务端不入账)→ **accept 之前**先解析市场 +// 列表:拉不到就整任务不动作,避免留下「已登记未上报」的半程态(上游 9a26ae7 +// 的 ids 前置判定同款)。复用既有 MarketExpertList(expert_5 任务同源,实测可用)。 +func runMiniExpert(p *Panel, a *auth.Auth) (string, error) { + const code = "Sequential_Tasks_2" + t, err := p.taskByCodeMP(a, code) + if err != nil { + return "", err + } + if t == nil { + return "mp 口径未下发该任务(活动可能已结束)", nil + } + if t.Claimed { + return "已领取", nil + } + target := t.Target + if target <= 0 { + target = 1 // 未 accept 的 mp 任务 progress 为 null,target 兜底(上游实测) + } + // 已达标(含 completed 未领):直接领奖。 + if t.Current >= target || t.AcceptStatus == "completed" { + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil + } + // 判据载体前置(accept 之前):市场真实专家 id。 + experts, merr := p.cfg.Upstream.MarketExpertList(a, "") + if merr != nil || len(experts) == 0 { + return fmt.Sprintf("专家市场不可用(%v),跳过以防半程态", merr), nil + } + e := experts[0] + name := e.DisplayNameZH + if name == "" { + name = e.ProfessionZH + } + if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { + if !p.acceptWithVerifyMP(a, code) { + return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil + } + } + ev := upstream.MiniExpertUseEvent(e.ExpertID, name, e.ExpertType) + if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil { + return fmt.Sprintf("上报 expert_actual_use 失败: %v", err), nil + } + // 回读(异步计分,两轮各隔 3s——与 runMPMiniChatTask 同预算)。 + for i := 0; i < 2; i++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCodeMP(a, code) + if err2 != nil || t2 == nil { + continue + } + t = t2 + if t.Claimable || t.Claimed || t.Current >= target { + break + } + } + if t.Claimed { + return "本轮已入账(claimed)", nil + } + if t.Current < target { + return "已上报但进度未归账(异步计分,下次重试)", nil + } + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil +} + +// accountTaskAuto 一键完成单个任务:执行对应动作 → 回读进度 → 汇报结果。 +func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.accountByUID(w, uid) + if a == nil { + return + } + var body struct { + TaskCode string `json:"task_code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TaskCode == "" { + writeErr(w, http.StatusBadRequest, "task_code required") + return + } + act := autoActionFor(body.TaskCode) + if act == nil { + writeErr(w, http.StatusNotImplemented, + "该任务需要客户端内交互(无对应接口),无法自动完成;请按任务说明在官方客户端操作") + return + } + // per-account 互斥:同账号的任务动作正在跑(单任务或全量)时直接 409, + // 不并发重跑(动作幂等但 expert/skill 系含真实对话,重跑浪费配额)。 + if !p.tryLockAccount(uid) { + writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") + return + } + defer p.unlockAccount(uid) + // 前置读取:已完成的任务直接跳过(幂等,不浪费上游调用)。 + // taskByCode 已双口径(mp 专属码自动回落 mp 列表)。 + before, err := p.taskByCode(a, act.TaskCode) + if err != nil { + writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error()) + return + } + if before == nil { + writeErr(w, http.StatusNotFound, "该账号没有此任务") + return + } + isMP := isMPTaskCode(act.TaskCode) + if before.Claimed { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "skipped": true, "message": "该任务已领取过奖励"}) + return + } + msg, err := act.run(p, a) + if err != nil { + writeErr(w, http.StatusBadGateway, "执行失败: "+err.Error()) + return + } + // 回读验证:上报 200 ≠ 计分(上游可能静默丢弃 + 计分异步), + // 用有界轮询等异步计时落定,再决定是否自动领奖(mp 任务同口径回读)。 + var after *upstream.Task + var aerr error + if isMP { + after, aerr = p.taskByCodeMP(a, act.TaskCode) + } else { + after, aerr = p.taskByCodeWaiting(a, act.TaskCode) + } + progressBefore, progressAfter := taskProgressText(before), "" + claimable := false + if aerr == nil && after != nil { + progressAfter = taskProgressText(after) + claimable = after.Claimable + } + resp := map[string]any{ + "ok": true, + "message": msg, + "progress_before": progressBefore, + "progress_after": progressAfter, + "claimable": claimable, + "attempt": act.Attempt, + "verify_supported": true, + } + // 达标即自动领奖(Web 端 claim):把"完成→领奖"收敛成一步,无需用户再点一次。 + if claimable { + var credit, energy int64 + var cerr error + if isMP { + credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) + } else { + credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) + } + if cerr == nil { + resp["claimed"] = true + resp["credit"] = credit + resp["energy"] = energy + if credit > 0 || energy > 0 { + resp["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy) + } else { + resp["message"] = msg + ";奖励此前已领取" + } + } else { + resp["claim_error"] = cerr.Error() + resp["message"] = msg + ";达标但领奖失败,可在任务列表手动点「领取」重试" + } + } + log.Printf("panel: 任务动作 uid=%s code=%s progress %s -> %s claimable=%v claimed=%v", + uid, act.TaskCode, progressBefore, progressAfter, claimable, resp["claimed"]) + writeJSON(w, http.StatusOK, resp) +} + +// taskProgressText 任务进度的可读表示(回读对比用)。 +func taskProgressText(t *upstream.Task) string { + if t == nil { + return "?" + } + if t.Target > 0 { + return fmt.Sprintf("%d/%d", t.Current, t.Target) + } + if t.Claimed { + return "claimed" + } + return t.AcceptStatus +} + +// truncateStr 截断错误文本(避免把上游长响应原样透给前端)。 +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// --------------------------------------------------------------------------- +// 各任务动作实现 +// --------------------------------------------------------------------------- + +// reportGap 连续上报之间的间隔(对齐上游脚本实测的 1.05s 口径,避免风控)。 +var reportGap = 1050 * time.Millisecond + +// runChat5 补足 chat_5 的进度:按差额上报 chat_request_send。 +func runChat5(p *Panel, a *auth.Auth) (string, error) { + t, err := p.taskByCode(a, "chat_5") + if err != nil { + return "", err + } + if t == nil { + return "", fmt.Errorf("任务不存在") + } + target := t.Target + if target <= 0 { + target = 5 + } + need := target - t.Current + if need <= 0 { + return "进度已达标,无需上报", nil + } + for i := int64(0); i < need; i++ { + cid := fmt.Sprintf("wb2api-chat5-%d-%d", time.Now().UnixMilli(), i) + if err := p.cfg.Upstream.ReportChatActivity(a, cid, ""); err != nil { + return fmt.Sprintf("上报第 %d/%d 条失败: %v", i+1, need, err), nil + } + if i < need-1 { + time.Sleep(reportGap) + } + } + return fmt.Sprintf("已补报 %d 条对话事件", need), nil +} + +// runFirstBuddy 领养:report(解锁前置)→ agreement → first。 +func runFirstBuddy(p *Panel, a *auth.Auth) (string, error) { + if err := p.cfg.Upstream.ReportChatActivity(a, fmt.Sprintf("wb2api-adopt-%d", time.Now().UnixMilli()), ""); err != nil { + return "", fmt.Errorf("前置上报: %w", err) + } + time.Sleep(reportGap) // 给上游事件处理留时间(脚本实测口径) + if err := p.cfg.Upstream.BuddyAgreement(a); err != nil { + return "", fmt.Errorf("同意协议: %w", err) + } + if err := p.cfg.Upstream.BuddyFirst(a); err != nil { + if upstream.IsBuddyTaskIncomplete(err) { + return "前置已上报,但领养门槛未过(上游要求当日活跃),请稍后重试", nil + } + return "", fmt.Errorf("领取 Buddy: %w", err) + } + return "已领取 Buddy(+300 分 +8 能量)", nil +} + +// runModelChat 完成 Model_chat_GLM5.2:accept → 真实对话 → 对齐模型上报。 +func runModelChat(p *Panel, a *auth.Auth) (string, error) { + const code, modelID, modelName = "Model_chat_GLM5.2", "glm-5.2", "GLM-5.2" + // 1. accept(报名;失败不阻塞——行为事件才是判据) + if err := p.cfg.Upstream.AcceptTasks(a, []string{code}); err != nil { + log.Printf("panel: accept %s: %v(继续走行为链路)", code, err) + } + time.Sleep(reportGap) + // 2. 真实对话一次(判据的最直接证据) + body, _ := json.Marshal(map[string]any{ + "model": modelID, + "messages": []map[string]any{ + {"role": "user", "content": "hi,请回复一句话"}, + }, + "stream": true, + }) + rc, status, respBody, err := p.cfg.Upstream.ChatStream(a, body, "", upstream.ChatMeta{}) + if err != nil { + return "", fmt.Errorf("对话请求: %w", err) + } + if status >= 400 { + rc.Close() + return "", fmt.Errorf("对话失败 http=%d: %s", status, truncateStr(string(respBody), 160)) + } + // 读干 SSE(网关对上游强制 flow:不读完会残留连接) + _, _ = io.Copy(io.Discard, io.LimitReader(rc, 1<<20)) + rc.Close() + time.Sleep(reportGap) + // 3. 对齐模型的上报(触发进度) + if err := p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-glm52-%d", time.Now().UnixMilli()), "", modelID, modelName); err != nil { + return "对话已完成,但进度上报失败:" + err.Error(), nil + } + return "已完成 glm-5.2 对话并上报", nil +} + +// runRichMeow 完成 RichMeow_Chat(桌面端对话1次)。 +// 2026-09-12 三账号实测验证:以桌面指纹(extName=workbuddy-desktop)向 +// copilot.tencent.com/v2/report 上报完整对话事件链(agent_task_created → +// chat_message_response isSuccessful=true 等 6 事件),纯 API 即可点亮并领奖 +// (紫川/人杰2 两账号无桌面客户端登录状态下 3 秒内 0/1 → 1/1)。 +// Hp_Appearance 的纯 API set 不计分(需客户端在主题下活跃),区别对待。 +func runRichMeow(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-rm-%d", ms) + req := fmt.Sprintf("wb2api-rm-req-%d", ms) + msg := fmt.Sprintf("req-%d-user", ms) + events := upstream.DesktopChatSequence(conv, req, msg, "fast-model", "fast-model") + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已按桌面端指纹上报完整对话事件链(agent_task_created→chat_response)", nil +} + +// runBuddyApp 完成 Buddy_App / Buddy_App_QQ(进入 Buddy 应用)。 +// 2026-09-12 两账号实测:buddyapp 五连事件(discover→show→enter→auth_confirm→ +// bind_skip)以 workbuddy-desktop 指纹上报即点亮,服务端不校验真实授权。 +// 用企鹅教师助手(Buddy_App_QQ 判据应用)作载体,同一组事件同时满足 +// Buddy_App「进入任一应用」——两个表项共用本 run,幂等由任务状态跳过兜底。 +func runBuddyApp(p *Panel, a *auth.Auth) (string, error) { + events := upstream.DesktopBuddyAppSequence("cb_y5Dy46tPQGGWtueMxXbe", "企鹅教师助手") + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已上报 buddyapp 进入五连事件(同时覆盖 Buddy_App 与 Buddy_App_QQ)", nil +} + +// runAutomationCreate 完成 automation_1(设置自动化任务)。 +// 2026-09-12 两账号实测:automated_task_create_suc 事件纯 API 上报即点亮, +// 无需真实创建定时任务。 +func runAutomationCreate(p *Panel, a *auth.Auth) (string, error) { + if err := p.cfg.Upstream.ReportDesktopEvent(a, + upstream.DesktopAutomationCreateEvent("wb2api 自动化")); err != nil { + return "", err + } + return "已上报定时任务创建事件", nil +} + +// runLibraryRead 完成 Library_read(体验资料库)。 +// 2026-09-12 三账号实测:web 域 /v2/report 上报 web_element_click +// (elementId=library_doc_intro_click) 即点亮(space 的 open/WS/inlong 都不是判据)。 +func runLibraryRead(p *Panel, a *auth.Auth) (string, error) { + const docURL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm" + if err := p.cfg.Upstream.ReportWebEvent(a, "web_element_click", docURL, + "library_doc_intro_click", "WorkBuddy资料库介绍"); err != nil { + return "", err + } + return "已上报资料库介绍阅读事件", nil +} + +// runBlackCat 完成 black_cat(夜猫子,夜间 23:00–08:00 计数)。 +// 判据 = 夜间窗口内 glm-5.2 真实对话 + chat 事件上报(WorkBuddy-Daily 实测口径)。 +// 窗口外不做(提示等排程);网关 blackcat_hours(默认 23 点)排程会自动补足。 +func runBlackCat(p *Panel, a *auth.Auth) (string, error) { + if !upstream.InNightWindow(time.Now()) { + return "当前不在 23:00–08:00 计数窗口,行为不计分;网关会在每日 23 点自动补足", nil + } + need, err := p.cfg.Upstream.BlackcatNeed(a) + if err != nil { + return "", err + } + if need <= 0 { + return "进度已达标,无需补足", nil + } + ok, err := p.cfg.Upstream.RunNightChats(a, int(need)) + if err != nil { + return fmt.Sprintf("完成 %d/%d 次后中断: %v", ok, need, err), nil + } + return fmt.Sprintf("已完成 %d 次夜间对话并上报", ok), nil +} + +// runSkillFresh 完成 skill_1(尝鲜热门技能)。 +// 2026-09-12 判据(紫川手动完成抓包 row 209):`skill_info` 事件(桌面指纹)—— +// {id:<技能名>, skillId, skillVersion, toolStatus:"success", fileCount, +// source:"workbuddy-desktop"} JOIN 真实会话(conversationId/requestId=服务端 +// id)。此前的 skill_request_send/skill_installed/skill_action 全是错误方向。 +// 人杰2 实测 0/1 → 1/1 点亮。 +func runSkillFresh(p *Panel, a *auth.Auth) (string, error) { + conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, "") + if err != nil { + return "", fmt.Errorf("真实对话: %w", err) + } + msgID := "msg-" + req[len(req)-8:] + events := upstream.DesktopChatSequence(conv, req, msgID, "fast-model", "fast-model") + for _, ev := range events { + if ev["eventCode"] == "chat_message_response" { + ev["finishReason"] = "tool_calls" // 模型发起工具调用(技能加载)语义 + } + } + events = append(events, upstream.DesktopEvent{ + "eventCode": "skill_info", + "id": "润泽小馆·日报撰写", + "skillId": "skill_2097350077599879168", + "skillVersion": "1.0.0", + "toolStatus": "success", + "fileCount": 56, + "source": "workbuddy-desktop", + "conversationId": conv, "requestId": req, "messageId": msgID, + "requestModelId": "fast-model", "requestModelName": "fast-model", + "traceId": req, + }) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", fmt.Errorf("skill_info 事件: %w", err) + } + return "已上报真实对话 + skill_info 技能加载事件", nil +} + +// runExpertLighthouse 完成 Expert_lighthouse(体验「腾讯轻量云」专家)。 +// 2026-09-12 判据(真实样本 Sunny row 868):与 expert_5 同构,但两处差异—— +// chat 链的 agent_task_created 需带 has_expert:true + expert_id(我们默认 false), +// expert_actual_use 的 mode 为 "LOCAL"(非 craft)。id 固定为轻量云专家 +// ex_2cvvUZQhDyeJ;requestId 必须是真实 chat 的服务端 id。紫川/人杰2 实测点亮。 +func runExpertLighthouse(p *Panel, a *auth.Auth) (string, error) { + const lhID = "ex_2cvvUZQhDyeJ" + lh := upstream.MarketExpert{ + ExpertID: lhID, ExpertType: "agent", + DisplayNameZH: "腾讯轻量云专家", ProfessionZH: "腾讯轻量云专家", Version: "1.0.2", + } + // 市场列表若命中真实条目则用其信息(version 等以服务端为准)。 + if experts, err := p.cfg.Upstream.MarketExpertList(a, "agent"); err == nil { + for _, e := range experts { + if e.ExpertID == lhID { + lh = e + break + } + } + } + if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopExpertSummonSequence(lh)...); err != nil { + return "", fmt.Errorf("召唤链: %w", err) + } + conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, lhID) + if err != nil { + return "", fmt.Errorf("真实对话: %w", err) + } + events := upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model") + for _, ev := range events { + if ev["eventCode"] == "agent_task_created" { + ev["has_expert"] = true + ev["expert_id"] = lh.ExpertID + ev["expert_name"] = lh.DisplayNameZH + ev["expert_industry_id"] = "" + } + } + events = append(events, upstream.DesktopExpertActualUseLocal(lh, conv, req)) + // 对齐真实样本细节:轻量云专家 actual_use 的 type 为空、cost=0。 + events[len(events)-1]["type"] = "" + events[len(events)-1]["cost"] = 0 + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", fmt.Errorf("使用事件: %w", err) + } + return "已上报轻量云专家召唤+使用链(真实对话 requestId)", nil +} + +// runAppearance 完成 Hp_Appearance(换主题)。 +// 2026-09-12 紫川/人杰2 实测(desktopverify12):判据是 appearance_skin_apply +// 事件 {action:"apply",source:"settings_close",id:,...}(客户端在 +// 主题生效状态下离开设置页时上报)——早期"纯 API set 不计分"的结论不准确, +// 真相是当时只调了 appearance/set 没发事件。组合:set API 留痕 + 事件上报。 +func runAppearance(p *Panel, a *auth.Auth) (string, error) { + const themeKey = "theme-tkmw7j" // 和平精英激战金秋(Hp_Appearance 判据主题) + if err := p.cfg.Upstream.SetAppearanceTheme(a, themeKey); err != nil { + return "", fmt.Errorf("设置主题: %w", err) + } + time.Sleep(2 * time.Second) + if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopEvent{ + "eventCode": "appearance_skin_apply", "action": "apply", "source": "settings_close", + "id": themeKey, "vipLevel": 0, "series": "", "type": "unknown", + }); err != nil { + return "", err + } + return "已设置主题并上报皮肤生效事件", nil +} + +// runTemplateUse 完成 template_5(使用 5 个模板创建任务)。 +// 2026-09-12 三账号实测:agent_task_created_with_template + template_used 事件组 +// (JOIN chat 链)一次上报 5 组即 5/5 点亮。template_id 服务端不校验真实性。 +func runTemplateUse(p *Panel, a *auth.Auth) (string, error) { + templates := [][2]string{{"1", "深度研究"}, {"2", "周报生成"}, {"3", "竞品分析"}, {"4", "活动策划"}, {"5", "代码评审"}} + for i, tp := range templates { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-tpl-%d-%d", ms, i) + req := fmt.Sprintf("wb2api-tpl-req-%d-%d", ms, i) + events := upstream.DesktopTemplateUseSequence(conv, req, tp[0], tp[1]) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return fmt.Sprintf("第 %d 组模板事件上报失败: %v", i+1, err), nil + } + time.Sleep(300 * time.Millisecond) + } + return "已上报 template_used ×5", nil +} + +// runPlaybookPrompt 完成 playbook_prompt(灵感案例 Dialog 中发送 Prompt)。 +// 判据是 playbook_prompt_send(Dialog 发送)而非卡片曝光/点击——asar 逆向确认。 +func runPlaybookPrompt(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-pb-%d", ms) + req := fmt.Sprintf("wb2api-pb-req-%d", ms) + events := upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸") + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已上报 playbook_cta_click + playbook_prompt_send", nil +} + +// runCreateCanvas 完成 create_canvas(设计创意模式创建画布,+300 分)。 +// 判据是 wbx_design_canvas_task_create/open(Ardot create_design 工具完成遥测)。 +func runCreateCanvas(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-canvas-%d", ms) + req := fmt.Sprintf("wb2api-canvas-req-%d", ms) + events := upstream.DesktopDesignCanvasSequence(conv, req) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已上报 wbx_design_canvas_task_create/open", nil +} + +// expertSummonGap 专家召唤链的间隔(真实使用节奏,v11 实测 8s 成功率 100%)。 +const expertSummonGap = 6 * time.Second + +// runExpertUse 完成 expert_5(使用 5 个平台专家)。 +// 2026-09-12 三账号实测公式:真实专家列表(id 必须真实存在)→ 召唤链 +// (summon_click/summoned)→ 真实 chat 拿服务端 requestId → expert_actual_use。 +// 自造专家 id 或自造 requestId 均不计数。 +func runExpertUse(p *Panel, a *auth.Auth) (string, error) { + return runExpertBatch(p, a, "agent", 5) +} + +// runExpertTeamUse 完成 Expert_team_use_3(使用 3 个专家团,expertType=team)。 +func runExpertTeamUse(p *Panel, a *auth.Auth) (string, error) { + return runExpertBatch(p, a, "team", 3) +} + +// runExpertBatch 专家召唤+使用的公共实现。失败逐个继续,返回汇总信息。 +func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (string, error) { + experts, err := p.cfg.Upstream.MarketExpertList(a, expertType) + if err != nil { + return "", fmt.Errorf("拉取专家列表: %w", err) + } + if len(experts) == 0 { + return "", fmt.Errorf("专家市场列表为空") + } + ok, fail := 0, 0 + for i, e := range experts { + if ok >= count { + break + } + // 召唤链(web_element_click + summon_click + summoned)。 + summonEvents := upstream.DesktopExpertSummonSequence(e) + if err := p.cfg.Upstream.ReportDesktopEvent(a, summonEvents...); err != nil { + fail++ + continue + } + // 真实 chat(带 X-Expert-Id)→ 服务端 requestId。 + conv, req, cerr := p.cfg.Upstream.DesktopChatWithExpert(a, e.ExpertID) + if cerr != nil { + fail++ + continue + } + // 使用事件(JOIN 服务端 requestId)+ chat 链。 + events := append(upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model"), + upstream.DesktopExpertActualUseEvent(e, conv, req)) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + fail++ + continue + } + ok++ + if i < len(experts)-1 { + time.Sleep(expertSummonGap) + } + } + _ = fail + return fmt.Sprintf("已对 %d 位真实专家完成召唤+使用链(类型 %s)", ok, expertType), nil +} + +// --------------------------------------------------------------------------- +// 全量自动完成 +// --------------------------------------------------------------------------- + +// runAutoAll 对单账号依次执行所有可自动化任务,返回逐项结果。 +// 供「一键完成全部可自动任务」使用;单项失败不影响后续项。 +// +// 流程:先把所有未接受的任务批量 accept(规范状态机;上游脚本建议"先 accept"), +// 再逐项执行行为链路。accept 不是进度产生的必要条件,但让后续状态流转规范。 +func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { + var out []map[string]any + + // 阶段 0:批量接受尚未接受的任务(失败不阻塞——行为事件才是进度唯一判据)。 + if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil { + var codes []string + for _, t := range tasks { + if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" { + codes = append(codes, t.TaskCode) + } + } + if len(codes) > 0 { + if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil { + out = append(out, map[string]any{ + "task_code": "(批量接受)", "status": "error", + "message": "接受任务失败(不阻塞后续): " + err.Error(), + }) + } else { + out = append(out, map[string]any{ + "task_code": "(批量接受)", "status": "done", + "message": fmt.Sprintf("已接受 %d 个任务", len(codes)), + }) + time.Sleep(reportGap) + } + } + } + + // 阶段 0b:小程序口径任务单独接受(默认列表不含 mp 码;失败不阻塞)。 + if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil { + var mpCodes []string + for _, t := range mpTasks { + if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" { + mpCodes = append(mpCodes, t.TaskCode) + } + } + if len(mpCodes) > 0 { + if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil { + out = append(out, map[string]any{ + "task_code": "(批量接受-mp)", "status": "error", + "message": "接受小程序任务失败(不阻塞后续): " + err.Error(), + }) + } else { + out = append(out, map[string]any{ + "task_code": "(批量接受-mp)", "status": "done", + "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)), + }) + time.Sleep(reportGap) + } + } + } + + for _, act := range autoActions { + item := map[string]any{"task_code": act.TaskCode, "desc": act.Desc} + before, err := p.taskByCode(a, act.TaskCode) + if err != nil { + item["status"] = "error" + item["message"] = "查询失败: " + err.Error() + out = append(out, item) + continue + } + if before == nil { + item["status"] = "skipped" + item["message"] = "该账号无此任务" + out = append(out, item) + continue + } + if before.Claimed || before.Current >= before.Target && before.Target > 0 { + item["status"] = "skipped" + item["message"] = "已完成(" + taskProgressText(before) + ")" + out = append(out, item) + continue + } + msg, err := act.run(p, a) + if err != nil { + item["status"] = "error" + item["message"] = err.Error() + out = append(out, item) + continue + } + var after *upstream.Task + if isMPTaskCode(act.TaskCode) { + after, _ = p.taskByCodeMP(a, act.TaskCode) + } else { + after, _ = p.taskByCodeWaiting(a, act.TaskCode) + } + item["status"] = "done" + item["message"] = msg + item["progress_after"] = taskProgressText(after) + // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。 + // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。 + if after != nil && after.Claimable { + item["claimable"] = true + var credit, energy int64 + var cerr error + if isMPTaskCode(act.TaskCode) { + credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) + } else { + credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) + } + if cerr == nil { + item["claimed"] = true + item["credit"] = credit + item["energy"] = energy + if credit > 0 || energy > 0 { + item["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy) + } else { + item["message"] = msg + ";奖励此前已领取" + } + } else { + item["claim_error"] = cerr.Error() + item["message"] = msg + ";达标但领奖失败(可在列表手动重试)" + } + } + out = append(out, item) + time.Sleep(reportGap) // 项间节流 + } + return out +} + +// accountTaskAutoAll 一键完成该账号全部可自动任务。 +func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.accountByUID(w, uid) + if a == nil { + return + } + // per-account 互斥(与单任务动作共用一把锁):重复点击 409。 + if !p.tryLockAccount(uid) { + writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") + return + } + // 用 context 兜底超时(多项任务串联 + 每项含真实对话,可能耗时较长)。 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + done := make(chan []map[string]any, 1) + go func() { + defer p.unlockAccount(uid) // 流水线真正结束(而非 HTTP 超时返回)才放锁 + done <- p.runAutoAll(a) + }() + select { + case results := <-done: + log.Printf("panel: 一键完成可自动任务 uid=%s 共 %d 项", uid, len(results)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "results": results}) + case <-ctx.Done(): + // HTTP 侧超时返回,但后台流水线仍在跑——锁在流水线 goroutine 内释放, + // 期间重复点击会被 409 挡住,不会出现两轮并发。 + writeErr(w, http.StatusGatewayTimeout, "执行超时(任务仍在后台继续)") + } +} diff --git a/internal/panel/autotask_lock_test.go b/internal/panel/autotask_lock_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e7d0e6706cc051ec070cf4206e914c1cf4ae2e7c --- /dev/null +++ b/internal/panel/autotask_lock_test.go @@ -0,0 +1,52 @@ +package panel + +import ( + "testing" +) + +// TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败, +// 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。 +func TestTaskAccountLockSameAccountExclusive(t *testing.T) { + p := &Panel{} + uid := "u1" + + if !p.tryLockAccount(uid) { + t.Fatal("首次加锁应成功") + } + if p.tryLockAccount(uid) { + t.Fatal("同账号第二次加锁应失败(互斥)") + } + p.unlockAccount(uid) + + if !p.tryLockAccount(uid) { + t.Fatal("解锁后应可再次加锁") + } + p.unlockAccount(uid) +} + +// TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。 +func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) { + p := &Panel{} + if !p.tryLockAccount("u1") { + t.Fatal("u1 加锁应成功") + } + if !p.tryLockAccount("u2") { + t.Fatal("u2 加锁应成功(不同账号不互斥)") + } + p.unlockAccount("u2") + p.unlockAccount("u1") +} + +// TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁 +// (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。 +func TestTaskAccountLockCrossEntryShared(t *testing.T) { + p := &Panel{} + if !p.tryLockAccount("u1") { + t.Fatal("u1 加锁应成功") + } + // 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。 + if p.tryLockAccount("u1") { + t.Fatal("同 uid 跨入口加锁应失败(共用锁)") + } + p.unlockAccount("u1") +} diff --git a/internal/panel/config.go b/internal/panel/config.go new file mode 100644 index 0000000000000000000000000000000000000000..efda8ae327dd8e6099bacd1a8ee7e63f6c6d8bcc --- /dev/null +++ b/internal/panel/config.go @@ -0,0 +1,57 @@ +// config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。 +// +// 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做 +// HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成 +// "校验 → 落盘 → 热应用 → 返回需重启字段列表")。 +package panel + +import ( + "io" + "log" + "net/http" +) + +// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。 +func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) { + if p.cfg.LoadConfig == nil { + writeErr(w, http.StatusNotImplemented, "config api not available") + return + } + cfg, err := p.cfg.LoadConfig() + if err != nil { + writeErr(w, http.StatusInternalServerError, "load config: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "path": p.cfg.ConfigPath, + "config": cfg, + }) +} + +// saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。 +// SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。 +func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) { + if p.cfg.SaveConfig == nil { + writeErr(w, http.StatusNotImplemented, "config api not available") + return + } + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeErr(w, http.StatusBadRequest, "read body: "+err.Error()) + return + } + restartRequired, err := p.cfg.SaveConfig(raw) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if restartRequired == nil { + restartRequired = []string{} + } + log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired)) + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "restart_required": restartRequired, + }) +} diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d9d3873026875b53875bd108a47c9fa0d121c869 --- /dev/null +++ b/internal/panel/frontend_test.go @@ -0,0 +1,647 @@ +package panel + +import ( + "bytes" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。 +// +// 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容—— +// 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量) +// 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。 +// 无 node 环境时跳过(不阻塞无 Node 的构建机)。 +func TestAppJSSyntax(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not available; skipping JS syntax check") + } + path, err := filepath.Abs("app.js") + if err != nil { + t.Fatal(err) + } + out, err := exec.Command(node, "--check", path).CombinedOutput() + if err != nil { + t.Fatalf("app.js syntax error:\n%s", out) + } +} + +// TestIndexHTMLNoInlineScript index.html 不得含内联 + + diff --git a/internal/panel/login.go b/internal/panel/login.go new file mode 100644 index 0000000000000000000000000000000000000000..c11fd477afc46337d503eb9721cb00df797d5f3e --- /dev/null +++ b/internal/panel/login.go @@ -0,0 +1,322 @@ +// login.go 面板内嵌的 WorkBuddy CN OAuth 设备授权流程(cmd/login 的进程内移植)。 +// +// POST /panel/api/login/start → 拿 state+authUrl,state 存进程内(不再落 /tmp, +// 原方案在 Windows 上不可用),返回授权 URL; +// GET /panel/api/login/poll → 面板前端每 3s 轮询本接口;未完成返回 done=false, +// 完成后取 uid/nickname、凭证落盘 auths/workbuddy-.json、热加载进池 +// (pool.Add + Revive),并顺带签到 + 余额刷新 —— 免重启加载新账号。 +// +// 无 PKCE(workbuddy 设备流由服务端签发 state),请求头与上游端点与 cmd/login 保持一致。 +package panel + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + upstreamBaseCN = "https://copilot.tencent.com" + upstreamBaseGlobal = "https://www.workbuddy.ai" + clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" + originRefererCN = "https://www.codebuddy.cn" + originRefererGlobal = "https://www.workbuddy.ai" +) + +// loginEndpoints 按 realm 返回设备授权三端点(auth/state、token、account)+ Origin。 +// realm=="global" → 国际版(workbuddy.ai 同域);cn/非法/缺省 → CN(零回归)。 +func loginEndpoints(realm string) (state, token, account, origin string) { + if realm == "global" { + base := upstreamBaseGlobal + return base + "/v2/plugin/auth/state?platform=CLI", + base + "/v2/plugin/auth/token?state=", + base + "/v2/plugin/login/account?state=", + originRefererGlobal + } + base := upstreamBaseCN + return base + "/v2/plugin/auth/state?platform=CLI", + base + "/v2/plugin/auth/token?state=", + base + "/v2/plugin/login/account?state=", + originRefererCN +} + +// loginHTTP 设备授权专用 client:短超时、无 cookie(每请求携带 state,无会话态)。 +var loginHTTP = &http.Client{Timeout: 30 * time.Second} + +func commonHeaders(req *http.Request, origin string) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Origin", origin) + req.Header.Set("Referer", origin+"/") + req.Header.Set("User-Agent", clientUA) +} + +// validUID 校验上游返回的 uid 是否可安全用于拼文件名。 +// 只放行字母、数字、下划线、连字符(腾讯侧 uid 实测为 UUID 形态), +// 长度上限 64 兜底异常超长串;拒绝 . / \ 等路径字符与空串。 +func validUID(uid string) bool { + if uid == "" || len(uid) > 64 { + return false + } + for _, c := range uid { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + default: + return false + } + } + return true +} + +// apiEnvelope 与 upstream 同形:{code,msg,data},code!=0 视为业务错误。 +type apiEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// doJSON 发一次 JSON 请求并解信封。origin 为 Origin/Referer 基础域(随 realm 切)。 +func doJSON(method, fullURL, bearer string, body io.Reader, origin string) (json.RawMessage, int, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + commonHeaders(req, origin) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := loginHTTP.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) + } + var env apiEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) + } + if env.Code != 0 { + return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) + } + return env.Data, resp.StatusCode, nil +} + +// loginStart 发起设备授权:POST auth/state 拿授权 URL。 +// body 可带 {"realm":"global"}(缺省 cn);state 会话记 realm,poll 同 realm 落盘。 +func (p *Panel) loginStart(w http.ResponseWriter, r *http.Request) { + realm := "cn" + if r.Body != nil { + var reqBody struct { + Realm string `json:"realm"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&reqBody); err == nil { + if reqBody.Realm == "global" { + realm = "global" + } + } + } + epState, _, _, origin := loginEndpoints(realm) + data, status, err := doJSON(http.MethodPost, epState, "", bytes.NewReader([]byte("{}")), origin) + if err != nil { + writeErr(w, http.StatusBadGateway, fmt.Sprintf("auth state (upstream %d): %v", status, err)) + return + } + var st struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` + } + if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { + writeErr(w, http.StatusBadGateway, "auth state: missing state or authUrl") + return + } + p.loginMu.Lock() + // 顺手回收过期会话,防"开弹窗走开"的 state 滞留。 + for s, sess := range p.logins { + if time.Since(sess.created) > loginTTL { + delete(p.logins, s) + } + } + p.logins[st.State] = loginSession{created: time.Now(), realm: realm} + p.loginMu.Unlock() + log.Printf("panel: 发起 OAuth 添加账号 realm=%s(state=%s...)", realm, st.State[:min(8, len(st.State))]) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": st.AuthURL, "state": st.State, "realm": realm}) +} + +// loginPoll 轮询登录态。未完成 → {done:false};完成 → 建凭证、落盘、热加载、签到。 +func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + if state == "" { + writeErr(w, http.StatusBadRequest, "missing state") + return + } + p.loginMu.Lock() + sess, known := p.logins[state] + p.loginMu.Unlock() + if !known { + writeErr(w, http.StatusNotFound, "unknown or expired state(请重新发起添加账号)") + return + } + _, epToken, epAcct, origin := loginEndpoints(sess.realm) + + // auth/token 是权威登录状态端点:pending 时业务 code 非 0("login ing")。 + tokRaw, _, err := doJSON(http.MethodGet, epToken+state, "", nil, origin) + if err != nil { + // pending / 未完成:面板前端继续轮询。 + writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": err.Error()}) + return + } + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + } + if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { + writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": "waiting for login"}) + return + } + + // 完成:取 uid/nickname(失败不阻塞,仅缺展示名)。 + var acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } + if acctRaw, _, err := doJSON(http.MethodGet, epAcct+state, tok.AccessToken, nil, origin); err == nil { + _ = json.Unmarshal(acctRaw, &acct) + } + if acct.UID == "" { + writeErr(w, http.StatusBadGateway, "login done but no uid(token 已发但账号信息获取失败,请重试)") + return + } + // UID 来自上游响应,未经校验就用于拼文件名会被路径穿越利用 + // (filepath.Join("./auths", "workbuddy-../../evil.json") → auths/evil.json)。 + // UID 是腾讯侧账号标识,实测为 UUID(十六进制与连字符),故只放行 [A-Za-z0-9_-]。 + if !validUID(acct.UID) { + writeErr(w, http.StatusBadGateway, "上游返回的 uid 含非法字符,拒绝落盘(防路径穿越)") + return + } + + // 凭证落盘(嵌套形,与 auths/ 目录既有格式一致)→ 热加载进池。 + if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil { + writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error()) + return + } + a := &auth.Auth{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(), + Domain: tok.Domain, + UID: acct.UID, + EnterpriseID: acct.EnterpriseID, + Nickname: acct.Nickname, + FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", acct.UID)), + } + // global 登录:落盘 auth.realm=global(Realm() 按此判域;不写则依赖 domain 后缀回落)。 + if sess.realm == "global" { + if _, err := auth.BackfillRealmFor(a, "global"); err != nil { + writeErr(w, http.StatusInternalServerError, "set realm: "+err.Error()) + return + } + } else { + // CN 也显式补 realm 键(幂等),让 auth 文件形态统一(与 LoadDir 存量迁移对齐)。 + _, _ = a.BackfillRealm() + } + if err := a.SaveAtomic(); err != nil { + writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error()) + return + } + p.cfg.Pool.Add(a) + p.cfg.Pool.Revive(acct.UID) // 全新登录 = 人工恢复口径:清掉旧号遗留的禁用/冷却/熔断 + + // 顺带签到 + 余额刷新(幂等;失败不影响登录结果,只体现在返回字段里)。 + // realm 分支:CN 走 DailyCheckin;global 无 CN 签到体系,改为注册激活 + trial 领取 + // (D4 门控同 scheduler:CN 任务端点对 global 不发起任何调用)。 + checkinMsg := "" + remain := int64(-1) + total := int64(0) + if sess.realm == "global" { + // 注册激活(幂等):region required 时自动补地区(白名单首个,HK)后重新激活。 + // 失败不阻断登录结果(auth 已落盘),只在返回字段里体现。 + if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil { + checkinMsg = "注册激活失败: " + err.Error() + log.Printf("panel: global 注册激活 uid=%s: %v", acct.UID, err) + } else if activated { + log.Printf("panel: global 注册激活 uid=%s 完成", acct.UID) + } + // trial 加油包(幂等 14051 = 已领过,非错误)。 + if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil { + checkinMsg = joinMsg(checkinMsg, "trial 领取失败: "+err.Error()) + log.Printf("panel: global trial uid=%s: %v", acct.UID, err) + } else if claimed { + log.Printf("panel: global trial uid=%s 已领", acct.UID) + } + } else { + if err := p.cfg.Upstream.DailyCheckin(a); err != nil { + checkinMsg = err.Error() + } + } + if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil { + remain, total = rm, tt + p.cfg.Pool.ReenableIfCredits(acct.UID, rm, tt) + } + + p.loginMu.Lock() + delete(p.logins, state) + p.loginMu.Unlock() + log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm) + writeJSON(w, http.StatusOK, map[string]any{ + "done": true, + "uid": acct.UID, + "nickname": acct.Nickname, + "realm": sess.realm, + "credits": remain, + "credits_total": total, + "checkin_message": checkinMsg, + }) +} + +// joinMsg 拼接 login 完成后的提示消息(多段用「;」连接,空段跳过)。 +func joinMsg(parts ...string) string { + out := "" + for _, s := range parts { + if s == "" { + continue + } + if out != "" { + out += ";" + } + out += s + } + return out +} + +// loginRegions 返回 global 注册可选地区(panel 前端选地区弹窗用;CN 不调用)。 +// 未持账号时返回白名单静态兜底(前端只读展示,不依赖上游)。 +func (p *Panel) loginRegions(w http.ResponseWriter, r *http.Request) { + // 静态白名单(对齐国际版 web 展示集):面板前端只读展示,无需账号态。 + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "regions": []map[string]string{ + {"code": "HK", "name": "Hong Kong"}, + {"code": "MO", "name": "Macao"}, + {"code": "SG", "name": "Singapore"}, + {"code": "TH", "name": "Thailand"}, + {"code": "PH", "name": "Philippines"}, + {"code": "MY", "name": "Malaysia"}, + {"code": "ID", "name": "Indonesia"}, + }, + }) +} diff --git a/internal/panel/login_realm_test.go b/internal/panel/login_realm_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3c246483d5aa314dc6e987191531594f8fee96a3 --- /dev/null +++ b/internal/panel/login_realm_test.go @@ -0,0 +1,43 @@ +package panel + +import ( + "testing" +) + +// TestLoginEndpoints realm→端点映射:global 三端点全在 workbuddy.ai 域, +// cn/空/非法值 → copilot.tencent.com(零回归兜底)。 +func TestLoginEndpoints(t *testing.T) { + cases := []struct { + realm string + wantState string + wantOrigin string + }{ + {"global", "https://www.workbuddy.ai/v2/plugin/auth/state?platform=CLI", "https://www.workbuddy.ai"}, + {"cn", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, + {"", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, + {"weird", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, + } + for _, c := range cases { + t.Run(c.realm, func(t *testing.T) { + st, tok, acct, origin := loginEndpoints(c.realm) + if st != c.wantState { + t.Errorf("realm=%q state=%q want %q", c.realm, st, c.wantState) + } + if origin != c.wantOrigin { + t.Errorf("realm=%q origin=%q want %q", c.realm, origin, c.wantOrigin) + } + // token/account 端点必与 state 同 base。 + if c.realm == "global" { + if tok[:len("https://www.workbuddy.ai")] != "https://www.workbuddy.ai" || + acct[:len("https://www.workbuddy.ai")] != "https://www.workbuddy.ai" { + t.Errorf("global token/account endpoints should be workbuddy.ai: %q %q", tok, acct) + } + } else { + if tok[:len("https://copilot.tencent.com")] != "https://copilot.tencent.com" || + acct[:len("https://copilot.tencent.com")] != "https://copilot.tencent.com" { + t.Errorf("cn token/account endpoints should be copilot.tencent.com: %q %q", tok, acct) + } + } + }) + } +} diff --git a/internal/panel/model_probes_test.go b/internal/panel/model_probes_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6e5dd06b592fd7dd8ccc3b44e3465fc0b0b85501 --- /dev/null +++ b/internal/panel/model_probes_test.go @@ -0,0 +1,92 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// model_probes 端点三态:文件存在 → 透传 + exists:true + updated_at; +// 未配置 / 文件缺失 → 空集(面板退化为无标注);文件损坏 → 502。 +// 契约字段(claimed/measured/verdict…)由前端消费,网关只透传不解析。 +func TestModelProbesEndpoint(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "output_probes.json") + body := `{"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000,` + + `"verdict":"clamped","note":"钳制","tested_at":"2026-09-15 18:30:00","source":"probe_max_tokens.py"}}}` + if err := os.WriteFile(f, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + get := func(cfg Config) *httptest.ResponseRecorder { + p := New(cfg) + req := httptest.NewRequest("GET", "/panel/api/model_probes", nil) + req.Header.Set("Authorization", "Bearer test-key") + rec := httptest.NewRecorder() + p.ServeHTTP(rec, req) + return rec + } + + // 1) 文件存在:透传 + exists + updated_at + rec := get(Config{Version: "test", APIKey: "test-key", ProbeFile: f}) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Probes map[string]json.RawMessage `json:"probes"` + Exists bool `json:"exists"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.Exists || len(got.Probes) != 1 || got.UpdatedAt == "" { + t.Fatalf("exists=%v probes=%d updated_at=%q", got.Exists, len(got.Probes), got.UpdatedAt) + } + var rec1 struct { + Verdict string `json:"verdict"` + Measured int64 `json:"measured"` + } + if err := json.Unmarshal(got.Probes["cn:glm-5.2"], &rec1); err != nil { + t.Fatal(err) + } + if rec1.Verdict != "clamped" || rec1.Measured != 48000 { + t.Fatalf("probe passthrough = %+v", rec1) + } + + // 2) 文件缺失:空集 200(不是错误——无数据 = 无标注)。 + // 注意每个 case 用全新结构体:向已非 nil 的 map 再次 Unmarshal 是合并不是替换。 + rec = get(Config{Version: "test", APIKey: "test-key", ProbeFile: filepath.Join(dir, "nope.json")}) + if rec.Code != http.StatusOK { + t.Fatalf("missing file: code=%d want 200", rec.Code) + } + var gotEmpty struct { + Probes map[string]json.RawMessage `json:"probes"` + Exists bool `json:"exists"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &gotEmpty); err != nil { + t.Fatal(err) + } + if gotEmpty.Exists || len(gotEmpty.Probes) != 0 { + t.Fatalf("missing file: exists=%v probes=%d, want false/0", gotEmpty.Exists, len(gotEmpty.Probes)) + } + + // 3) 未配置:同文件缺失 + rec = get(Config{Version: "test", APIKey: "test-key"}) + if rec.Code != http.StatusOK { + t.Fatalf("unconfigured: code=%d want 200", rec.Code) + } + + // 4) 文件损坏:502(让面板显示读取失败而不是静默空白) + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte(`{not json`), 0o600); err != nil { + t.Fatal(err) + } + rec = get(Config{Version: "test", APIKey: "test-key", ProbeFile: bad}) + if rec.Code != http.StatusBadGateway { + t.Fatalf("corrupt file: code=%d want 502", rec.Code) + } +} diff --git a/internal/panel/panel.go b/internal/panel/panel.go new file mode 100644 index 0000000000000000000000000000000000000000..4148a43317d89086c4ba8a7e659ccc186a536736 --- /dev/null +++ b/internal/panel/panel.go @@ -0,0 +1,816 @@ +// Package panel 内嵌式 Web 管理面板:账号池总览、单号运维(解冻/禁用/签到/ +// 刷新余额/移除)、浏览器内 OAuth 添加账号(免重启热加载进池)、手动批量 +// 签到/保活,以及运行日志环形缓冲(镜像 log 包与 chat 表格日志)。 +// +// 设计约束: +// - 前端 go:embed 单文件(index.html),无任何外部构建依赖,与二进制同体部署; +// - 鉴权复用网关 api_key(Bearer),与 /v1/* 同一口径;api_key 为空 = 不鉴权 +// (仅本机/私网使用)。面板 HTML 本身无秘密,可匿名加载,密钥只发给 /panel/api/*; +// - 不改写既有池语义:所有运维操作落到 pool 已有入口(Revive/Disable/Remove...), +// 添加账号走 auth.SaveAtomic + pool.Add,重启后与 auths/ 目录天然对齐。 +package panel + +import ( + "encoding/json" + "log" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/httpauth" + "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" + "github.com/linguo2625469/workbuddy2api-panel/internal/usage" +) + +// Config 面板依赖(main 装配注入)。 +type Config struct { + Pool *pool.Pool + Upstream *upstream.Client + Scheduler *scheduler.Scheduler // 手动触发签到/保活;nil 时对应接口返回 501 + AuthDir string // OAuth 登录完成后凭证落盘目录 + APIKey string // 空 = 不鉴权(与主服务同语义);与 Live 同时给出时 Live 优先 + RedisMode string // "upstash" / "noop",仅观测透出 + Version string // 面板版本号(展示用) + + // Live 运行期可变配置(在线改配置立即生效)。 + Live *livecfg.Holder + + // ConfigPath config.json 路径与加载器(配置页读写用)。 + // LoadConfig 返回解析后的配置对象(前端展示/校验用,具体类型由 main 注入的闭包决定); + // nil 时配置页返回 501。 + ConfigPath string + LoadConfig func() (any, error) + // SaveConfig 校验并落盘配置,返回需要重启才能生效的字段列表;随后由 main 注入的 + // ApplyConfig 闭包完成热生效(池参数/排程/密钥/脱敏)。error 时配置不写盘。 + SaveConfig func(raw []byte) (restartRequired []string, err error) + + // StickyCount 返回粘性会话绑定数;nil 时报告 0。 + StickyCount func() int + + // Usage 逐请求用量记录器(nil = 用量接口返回 501)。 + Usage *usage.Recorder + // RequestLog 请求指标与归档(nil = 对应接口返回 501)。 + RequestLog *reqlog.Recorder + + // ProbeFile 模型输出上限探测结果文件(scripts/probe_max_tokens.py --panel-out + // 写入;空或文件不存在 = model_probes 端点返回空集,面板不显示任何实测标注)。 + // 只读展示:网关不解析、不依赖其内容做任何路由/出站决策。 + ProbeFile string +} + +// Panel 管理面板 handler。挂载方式:外层 mux Handle("/panel/", panel), +// 本 mux 的 pattern 均带 /panel 前缀(外层不做前缀剥离)。 +type Panel struct { + cfg Config + mux *http.ServeMux + started time.Time + logs *Ring + + // logins 进行中的 OAuth 设备授权会话(state → 会话信息)。 + // poll 成功或超时(loginTTL)后剔除;面板常驻进程,容量天然有界。 + loginMu sync.Mutex + logins map[string]loginSession + + // taskMu/taskLocks 一键完成任务的 per-account 互斥:同一账号的任务动作 + // (单任务 / 全量)同时只允许一条在跑。重复点击直接返回 409"仍在执行", + // 而不是并发跑两遍浪费上游请求(动作虽幂等,expert 系每遍含 8 次真实对话)。 + // 不同账号之间不互斥(并行照旧)。TryLock 语义,锁条目常驻(账号数有界)。 + taskMu sync.Mutex + taskLocks map[string]*sync.Mutex + + // 任务中心执行队列(taskcenter.go)。 + queueOnce sync.Once + q *queueState +} + +// tryLockAccount 尝试锁定账号的任务执行;已在执行返回 false。 +func (p *Panel) tryLockAccount(uid string) bool { + p.taskMu.Lock() + if p.taskLocks == nil { + p.taskLocks = make(map[string]*sync.Mutex) + } + mu := p.taskLocks[uid] + if mu == nil { + mu = &sync.Mutex{} + p.taskLocks[uid] = mu + } + p.taskMu.Unlock() + return mu.TryLock() +} + +// unlockAccount 释放账号任务锁(与 tryLockAccount 配对)。 +func (p *Panel) unlockAccount(uid string) { + p.taskMu.Lock() + mu := p.taskLocks[uid] + p.taskMu.Unlock() + if mu != nil { + mu.Unlock() + } +} + +// loginTTL 授权 URL 的最长有效期:超时的 state 直接回收, +// 防止"开了添加账号弹窗就走开"的会话永久滞留。 +const loginTTL = 15 * time.Minute + +// loginSession 进行中的 OAuth 会话:创建时刻 + realm(cn/global,用于落盘与端点切换)。 +type loginSession struct { + created time.Time + realm string // "cn" / "global",缺省 cn +} + +// New 构建面板。 +func New(cfg Config) *Panel { + if cfg.RedisMode == "" { + cfg.RedisMode = "noop" + } + p := &Panel{ + cfg: cfg, + mux: http.NewServeMux(), + started: time.Now(), + logs: NewRing(500), + logins: map[string]loginSession{}, + } + p.routes() + return p +} + +// Logs 返回日志环形缓冲(main 经 MultiWriter 镜像 log 与 chat 表格日志进来)。 +func (p *Panel) Logs() *Ring { return p.logs } + +func (p *Panel) routes() { + p.mux.HandleFunc("GET /panel/{$}", p.index) + p.mux.HandleFunc("GET /panel/app.js", p.appScript) + p.mux.HandleFunc("GET /panel/api/overview", p.withAuth(p.overview)) + p.mux.HandleFunc("GET /panel/api/logs", p.withAuth(p.logsHandler)) + p.mux.HandleFunc("GET /panel/api/request_metrics", p.withAuth(p.requestMetrics)) + p.mux.HandleFunc("GET /panel/api/request_logs", p.withAuth(p.requestLogs)) + p.mux.HandleFunc("GET /panel/api/models", p.withAuth(p.models)) + p.mux.HandleFunc("POST /panel/api/login/start", p.withAuth(p.loginStart)) + p.mux.HandleFunc("GET /panel/api/login/poll", p.withAuth(p.loginPoll)) + p.mux.HandleFunc("GET /panel/api/login/regions", p.withAuth(p.loginRegions)) + p.mux.HandleFunc("POST /panel/api/import/cockpit", p.withAuth(p.importCockpit)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/revive", p.withAuth(p.accountRevive)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/disable", p.withAuth(p.accountDisable)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/checkin", p.withAuth(p.accountCheckin)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/balance", p.withAuth(p.accountBalance)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/remove", p.withAuth(p.accountRemove)) + p.mux.HandleFunc("GET /panel/api/accounts/{uid}/tasks", p.withAuth(p.accountTasks)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept", p.withAuth(p.accountTaskAccept)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept_all", p.withAuth(p.taskAcceptAll)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/claim", p.withAuth(p.accountTaskClaim)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/auto", p.withAuth(p.accountTaskAuto)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/auto_all", p.withAuth(p.accountTaskAutoAll)) + p.mux.HandleFunc("POST /panel/api/tasks/scan_all", p.withAuth(p.tasksScanAll)) + p.mux.HandleFunc("POST /panel/api/tasks/run_queue", p.withAuth(p.tasksRunQueue)) + p.mux.HandleFunc("GET /panel/api/tasks/queue", p.withAuth(p.tasksQueueStatus)) + p.mux.HandleFunc("GET /panel/api/school/vouchers", p.withAuth(p.schoolVouchers)) + p.mux.HandleFunc("POST /panel/api/checkin_all", p.withAuth(p.checkinAll)) + p.mux.HandleFunc("POST /panel/api/travel_all", p.withAuth(p.travelAll)) + p.mux.HandleFunc("POST /panel/api/activity_all", p.withAuth(p.activityAll)) + p.mux.HandleFunc("POST /panel/api/keepalive_all", p.withAuth(p.keepaliveAll)) + p.mux.HandleFunc("POST /panel/api/balance_all", p.withAuth(p.balanceAll)) + p.mux.HandleFunc("GET /panel/api/packages", p.withAuth(p.packages)) + p.mux.HandleFunc("GET /panel/api/usage", p.withAuth(p.usage)) + p.mux.HandleFunc("POST /panel/api/usage/save", p.withAuth(p.usageSave)) + p.mux.HandleFunc("GET /panel/api/model_probes", p.withAuth(p.modelProbes)) + p.mux.HandleFunc("GET /panel/api/config", p.withAuth(p.getConfig)) + p.mux.HandleFunc("POST /panel/api/config", p.withAuth(p.saveConfig)) +} + +// ServeHTTP 统一入口:先写安全响应头再分发,保证页面、静态资源、API +// 与 401 错误响应全都带上(API 也可能在浏览器里被直接打开)。 +func (p *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + setSecurityHeaders(w) + p.mux.ServeHTTP(w, r) +} + +// withAuth 与 server 包同口径的 Bearer 鉴权(经 httpauth 常量时间比较); +// api_key 为空时放行。密钥经 livecfg 快照读取:面板里改了 api_key,下一个请求 +// 即用新值(无需重启)。 +func (p *Panel) withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !httpauth.VerifyBearer(r, p.apiKey()) { + writeErr(w, http.StatusUnauthorized, "invalid_api_key") + return + } + next(w, r) + } +} + +// apiKey 当前生效密钥(Live 优先,回落静态字段)。 +func (p *Panel) apiKey() string { + if p.cfg.Live != nil { + return p.cfg.Live.Load().APIKey + } + return p.cfg.APIKey +} + +// expiringSoonWindow 返回调度器当前生效的快过期路由窗口;测试面板无调度器时返回 0。 +func (p *Panel) expiringSoonWindow() time.Duration { + if p.cfg.Scheduler == nil { + return 0 + } + return p.cfg.Scheduler.ExpiringSoonWindow() +} + +// --------------------------------------------------------------------------- +// 只读接口 +// --------------------------------------------------------------------------- + +// overview 总览:池计数 + 每账号状态 + 面板元信息。 +func (p *Panel) overview(w http.ResponseWriter, r *http.Request) { + total, healthy, cooling, disabled, inFlightFull := p.cfg.Pool.CountsDetailed() + sticky := 0 + if p.cfg.StickyCount != nil { + sticky = p.cfg.StickyCount() + } + writeJSON(w, http.StatusOK, map[string]any{ + "version": p.cfg.Version, + "uptime_sec": int(time.Since(p.started).Seconds()), + "auth_required": p.apiKey() != "", + "redis_mode": p.cfg.RedisMode, + "sticky_sessions": sticky, + "total": total, + "healthy": healthy, + "cooling": cooling, + "disabled": disabled, + "in_flight_full": inFlightFull, + "accounts": p.cfg.Pool.List(), + }) +} + +// logsHandler 返回日志环形缓冲快照(时间升序,含频道标记 chat/task/sys)。 +func (p *Panel) logsHandler(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"entries": p.logs.Snapshot()}) +} + +// requestMetrics 返回进程内请求指标、最近 100 条与归档状态。 +func (p *Panel) requestMetrics(w http.ResponseWriter, r *http.Request) { + if p.cfg.RequestLog == nil { + writeErr(w, http.StatusNotImplemented, "request logger not available") + return + } + writeJSON(w, http.StatusOK, p.cfg.RequestLog.Snapshot()) +} + +// requestLogs 从 JSONL 归档读取最近请求;limit 默认 200、最大 1000。 +// 支持按 outcome/account/model/client_ip/user_agent 过滤(字符串字段为包含匹配) +// 与 from/to 时间区间(闭区间,unix 秒或 RFC3339)——面板「运行日志」的筛选框、 +// 来源查询与「今天 / 自定义区间」都走这里。 +func (p *Panel) requestLogs(w http.ResponseWriter, r *http.Request) { + if p.cfg.RequestLog == nil { + writeErr(w, http.StatusNotImplemented, "request logger not available") + return + } + limit := 200 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if limit > 1000 { + limit = 1000 + } + q := r.URL.Query() + rows, err := p.cfg.RequestLog.ReadArchive(limit, reqlog.Filter{ + Outcome: q.Get("outcome"), + Account: q.Get("account"), + Model: q.Get("model"), + ClientIP: q.Get("client_ip"), + UserAgent: q.Get("user_agent"), + From: parseTimeParam(q.Get("from")), + To: parseTimeParam(q.Get("to")), + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + // 空结果回 [](而不是 JSON null):前端把 null 与"归档关闭"混在一起会走错分支, + // 显示成不满足筛选条件的最近请求。 + if rows == nil { + rows = []reqlog.Event{} + } + writeJSON(w, http.StatusOK, map[string]any{"entries": rows, "limit": limit}) +} + +// models 实时查询上游模型列表与 reasoning 实际档位(直连上游,不读路由层 1h 缓存): +// 回答"该模型到底支持哪几档思考"。顺带刷新 client 的 effort 降级能力缓存。 +// 与 /v1/models 同口径的双域输出:CN 域模型加 "cn:" 前缀、global 域加 "global:" 前缀 +// (gateway 路由协议,前端显示的 id 就是调用时要填的完整 model 值)。 +// 各域独立探测、独立容错:某域无可用账号则整域跳过;两域全空时才报错 +// (有错误明细回 502,一个账号都没有回 503)。 +func (p *Panel) models(w http.ResponseWriter, r *http.Request) { + out := make([]map[string]any, 0) + var fetchErrs []string + + // CN 域:有可用 CN 账号才查(此前无条件 Pool.Pick()+FetchModels——选中 global + // 账号时打 CN 端点必然失败,混合池表现为偶发 502,纯 global 池必炸)。 + if uids := p.cfg.Pool.AvailableUIDsForRealm("cn"); len(uids) > 0 { + if acct := p.cfg.Pool.AuthByUID(uids[0]); acct != nil { + infos, err := p.cfg.Upstream.FetchModels(acct) + if err != nil { + fetchErrs = append(fetchErrs, "cn: "+err.Error()) + } else { + for _, mi := range infos { + out = append(out, panelModelEntry("cn", mi, mi.Efforts, mi.DefaultEffort, p.cfg.Upstream.HTTP)) + } + } + } + } + + // global 域:路由开关开且有可用 global 账号才查(独立目录端点,FetchGlobalModelInfos; + // Upstream.GlobalEnabled 是探测侧同一道闸,与 main 装配的 config global.enabled 一致)。 + if p.cfg.Upstream.GlobalEnabled { + if uids := p.cfg.Pool.AvailableUIDsForRealm("global"); len(uids) > 0 { + if acct := p.cfg.Pool.AuthByUID(uids[0]); acct != nil { + infos := p.cfg.Upstream.FetchGlobalModelInfos(acct) + if len(infos) == 0 { + fetchErrs = append(fetchErrs, "global: 上游未返回可用模型") + } else { + efforts, defaults := p.cfg.Upstream.GlobalEffortSnapshot() + for _, mi := range infos { + out = append(out, panelModelEntry("global", mi, efforts[mi.ID], defaults[mi.ID], p.cfg.Upstream.HTTP)) + } + } + } + } + } + + if len(out) == 0 { + if len(fetchErrs) > 0 { + writeErr(w, http.StatusBadGateway, "fetch models: "+strings.Join(fetchErrs, "; ")) + return + } + writeErr(w, http.StatusServiceUnavailable, "没有可用账号:请先在面板添加账号再查询") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": out}) +} + +// panelModelEntry 构造单个模型条目(两域共用):id 带 realm 前缀(调用值即显示值), +// context_length / max_output_tokens 走四级查找链,effort 档位按 realm 域取 +// EffortListing(远端权威 ∪ 静态兜底表)——与 /v1/models 同一口径,两侧不再漂移。 +func panelModelEntry(realm string, mi upstream.ModelInfo, remoteEfforts []string, remoteDefault string, httpc *http.Client) map[string]any { + entry := map[string]any{ + "id": realm + ":" + mi.ID, + "name": mi.Name, + "default_effort": mi.DefaultEffort, + "supported_efforts": mi.Efforts, + "can_disable_thinking": mi.CanDisableThinking, + "supports_reasoning": mi.SupportsReasoning, + "supports_images": mi.SupportsImages, + "credits": mi.Credits, + "description": mi.Description, + "tags": mi.Tags, + "vendor": mi.Vendor, + "is_default": mi.IsDefault, + "supports_tool_call": mi.SupportsToolCall, + "only_reasoning": mi.OnlyReasoning, + "reasoning_effort": mi.ReasoningEffort, + "reasoning_summary": mi.ReasoningSummary, + } + // 限时优惠(modelPromotions):credits 是牌价,promo_* 是当前生效折扣 + //(WorkBuddy 客户端显示的就是这个生效价)。前端据此显示「生效价+标签+划线牌价」。 + if mi.PromoFactor != nil { + entry["promo_factor"] = *mi.PromoFactor + entry["promo_credits"] = mi.PromoCredits + } + if mi.PromoLabel != "" { + entry["promo_label"] = mi.PromoLabel + } + if mi.PromoNote != "" { + entry["promo_note"] = mi.PromoNote + } + if mi.MaxAllowedSize > 0 { + entry["max_allowed_size"] = mi.MaxAllowedSize + } + entry["context_length"] = upstream.ContextWindowListingV4(mi.ID, mi.ContextWindow, httpc) + if mo, ok := upstream.MaxOutputTokensListingV4(mi.ID, mi.MaxTokens, httpc); ok { + entry["max_output_tokens"] = mo + } + if efforts, def := upstream.EffortListing(realm, mi.ID, remoteEfforts, remoteDefault); efforts != nil { + entry["supported_efforts"] = efforts + if def != "" { + entry["default_effort"] = def + } + } + return entry +} + +// modelProbes 返回模型输出上限的探测结果(scripts/probe_max_tokens.py --panel-out +// 写入的契约文件),供前端在「模型与档位」的实测列做风险标注。 +// +// 设计边界:纯只读透传——文件缺失/未配置返回空集(面板退化为无标注,与历史行为 +// 一致),网关自身不解析字段语义、不据此做任何路由或出站决策;上游改了限制后 +// 重跑一次工具、下次查询即刷新,无需重启网关。 +func (p *Panel) modelProbes(w http.ResponseWriter, r *http.Request) { + out := map[string]any{"probes": map[string]json.RawMessage{}, "exists": false} + if p.cfg.ProbeFile == "" { + writeJSON(w, http.StatusOK, out) + return + } + raw, err := os.ReadFile(p.cfg.ProbeFile) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, out) + return + } + writeErr(w, http.StatusInternalServerError, "read probes: "+err.Error()) + return + } + var f struct { + Version int `json:"version"` + Probes map[string]json.RawMessage `json:"probes"` + } + if err := json.Unmarshal(raw, &f); err != nil { + writeErr(w, http.StatusBadGateway, "parse probes: "+err.Error()) + return + } + if f.Probes == nil { + f.Probes = map[string]json.RawMessage{} + } + out["probes"] = f.Probes + out["exists"] = true + if fi, err := os.Stat(p.cfg.ProbeFile); err == nil { + out["updated_at"] = fi.ModTime().Format(time.RFC3339) + } + writeJSON(w, http.StatusOK, out) +} + +// --------------------------------------------------------------------------- +// 账号运维 +// --------------------------------------------------------------------------- + +// accountRevive 手动复活:清禁用 + 冷却 + 熔断(运维口径无条件恢复)。 +func (p *Panel) accountRevive(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + if _, ok := p.cfg.Pool.Status(uid); !ok { + writeErr(w, http.StatusNotFound, "account not found") + return + } + p.cfg.Pool.Revive(uid) + log.Printf("panel: revive uid=%s(人工清除禁用/冷却/熔断)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// accountDisable 人工禁用(不再参与选号,需面板 revive 或重登恢复)。 +func (p *Panel) accountDisable(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + if _, ok := p.cfg.Pool.Status(uid); !ok { + writeErr(w, http.StatusNotFound, "account not found") + return + } + p.cfg.Pool.Disable(uid, "manual disable (panel)") + log.Printf("panel: disable uid=%s(人工禁用)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// accountCheckin 单号签到:DailyCheckin + 余额查询解冻(已签到等业务错误不阻塞余额刷新), +// 与 scheduler.RunCheckinNow 的单号语义一致。 +func (p *Panel) accountCheckin(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.cfg.Pool.AuthByUID(uid) + if a == nil { + writeErr(w, http.StatusNotFound, "account not found") + return + } + checkinMsg := "" + checkinDone := false + if err := p.cfg.Upstream.DailyCheckin(a); err != nil { + checkinMsg = err.Error() // "今天已签到"等业务错误照常查余额 + // 幂等拒绝同样是「今日已签」,标记后按钮显示「已签」。 + if upstream.IsAlreadyCheckin(err) { + p.cfg.Pool.NoteCheckinDone(uid) + checkinDone = true + } + } else { + p.cfg.Pool.NoteCheckinDone(uid) + checkinDone = true + } + resp := map[string]any{"ok": true, "checkin_done": checkinDone} + if checkinMsg != "" { + resp["checkin_message"] = checkinMsg + } + remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + if err != nil { + resp["balance_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + p.cfg.Pool.ReenableIfCredits(uid, remain, total) + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + resp["credits"] = remain + resp["credits_total"] = total + log.Printf("panel: checkin uid=%s msg=%q credits=%d/%d", uid, checkinMsg, remain, total) + writeJSON(w, http.StatusOK, resp) +} + +// accountBalance 单号余额刷新:更新余额与到期快照,不触碰冷却状态。 +func (p *Panel) accountBalance(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.cfg.Pool.AuthByUID(uid) + if a == nil { + writeErr(w, http.StatusNotFound, "account not found") + return + } + remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + if err != nil { + writeErr(w, http.StatusBadGateway, "user resource: "+err.Error()) + return + } + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "credits": remain, "credits_total": total}) +} + +// accountRemove 移除账号:先出池(立即落盘 state),再删 auth 文件。 +func (p *Panel) accountRemove(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.cfg.Pool.Remove(uid) + if a == nil { + writeErr(w, http.StatusNotFound, "account not found") + return + } + fileMsg := "" + if a.FilePath != "" { + if err := os.Remove(a.FilePath); err != nil && !os.IsNotExist(err) { + fileMsg = err.Error() + } + } + if fileMsg != "" { + log.Printf("panel: remove uid=%s(auth 文件删除失败: %s)", uid, fileMsg) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "file_error": fileMsg}) + return + } + log.Printf("panel: remove uid=%s(已出池并删除凭证文件)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// --------------------------------------------------------------------------- +// 批量任务 +// --------------------------------------------------------------------------- + +// checkinAll 手动触发全量签到(异步执行,进度看日志区/账号状态变化)。 +func (p *Panel) checkinAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunCheckinNow() + log.Printf("panel: 手动全量签到已触发(含猫猫旅行)") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// travelAll 手动触发全量猫猫旅行巡检(异步执行)。 +func (p *Panel) travelAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunTravelNow() + log.Printf("panel: 手动全量旅行巡检已触发") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// activityAll 手动触发全量活跃上报(异步执行;点亮连登 + 解锁领养前置)。 +func (p *Panel) activityAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunActivityNow() + log.Printf("panel: 手动全量活跃上报已触发") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// keepaliveAll 手动触发全量 token 保活(异步执行)。 +func (p *Panel) keepaliveAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunKeepaliveNow() + log.Printf("panel: 手动全量保活已触发") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// balanceAll 手动全量刷新余额:并发查上游、写回池内 credits(含解冻语义), +// 完成后返回——面板紧接着拉 overview 即是最新值。账号量小(个位数), +// 同步等待(上限受短 RPC 超时约束)比"触发后盲刷"体验更确定。 +func (p *Panel) balanceAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + p.cfg.Scheduler.RunBalanceRefreshNow() + p.syncNicknames() + log.Printf("panel: 手动全量余额刷新完成") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "accounts": p.cfg.Pool.List()}) +} + +// syncNicknames 手动刷新时的昵称同步(issue #94:上游改名免重登)。 +// 只在面板手动「刷新」路径调用——后台余额定时器不触发(用户明确要求资料接口 +// 仅手动触达)。逐号拉 /console/account,只取 nickname(手机号等敏感字段在 +// upstream.FetchAccountProfile 内即被丢弃);单号失败静默跳过,不打断余额刷新 +// 的既有结果。 +func (p *Panel) syncNicknames() { + type job struct { + uid string + a *auth.Auth + } + var jobs []job + for _, st := range p.cfg.Pool.List() { + if st.Disabled { + continue + } + if a := p.cfg.Pool.AuthByUID(st.UID); a != nil && a.AccessTokenValue() != "" { + jobs = append(jobs, job{uid: st.UID, a: a}) + } + } + if len(jobs) == 0 { + return + } + var ( + mu sync.Mutex + updated int + failed int + sem = make(chan struct{}, 3) + wg sync.WaitGroup + ) + for _, j := range jobs { + wg.Add(1) + go func(j job) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + nick, err := p.cfg.Upstream.FetchAccountProfile(j.a) + if err != nil { + mu.Lock() + failed++ + mu.Unlock() + return + } + if p.cfg.Pool.SetNickname(j.uid, nick) { + mu.Lock() + updated++ + mu.Unlock() + } + }(j) + } + wg.Wait() + if updated > 0 || failed > 0 { + log.Printf("panel: 昵称同步:更新 %d 个,失败 %d 个(未变化不计数)", updated, failed) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// usage 返回逐请求用量聚合。统计窗口三选一: +// - from/to(unix 秒):显式区间,用于「今天」与「自定义」——区间由浏览器按 +// 本地时区算好再发,服务端时区与浏览器不一致时「今天」才不会被算错; +// - hours:滚动窗口(默认 72,上限 1440=60 天),卡片汇总/按域/按账号/按模型/ +// 时序**全部**按该窗口统计;显式 hours=0 表示全部历史(含 90 天前折叠出的日桶); +// - 都不给:等同于 hours=72(保持旧调用方行为)。 +func (p *Panel) usage(w http.ResponseWriter, r *http.Request) { + if p.cfg.Usage == nil { + writeErr(w, http.StatusNotImplemented, "usage recorder not available") + return + } + q := r.URL.Query() + win := usage.Window{} + win.From = parseTimeParam(q.Get("from")) + win.To = parseTimeParam(q.Get("to")) + if win.From.IsZero() && win.To.IsZero() { + win.Hours = 72 + if v := q.Get("hours"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + win.Hours = n + } + } + if win.Hours > 1440 { + win.Hours = 1440 + } + } + // 昵称仅用于展示,取自池快照(不含任何凭证)。 + nicks := map[string]string{} + for _, s := range p.cfg.Pool.List() { + if s.Nickname != "" { + nicks[s.UID] = s.Nickname + } + } + var currentRate func(realm, model string) string + if p.cfg.Upstream != nil { + currentRate = p.cfg.Upstream.ModelRate + } + writeJSON(w, http.StatusOK, p.cfg.Usage.SnapshotWindow(win, nicks, currentRate)) +} + +// parseTimeParam 解析时间查询参数:unix 秒(前端默认)或 RFC3339(便于手工调 +// 接口/写脚本)。空串与非法值都返回零值 = 该侧不设界,不报错——区间参数是可选 +// 增强,拼错一个 from 不该让整页用量打不开。 +func parseTimeParam(v string) time.Time { + v = strings.TrimSpace(v) + if v == "" { + return time.Time{} + } + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + if n <= 0 { + return time.Time{} + } + // 兼容秒与毫秒(前端可能直接把 Date.now() 传上来)。 + if n > 1e12 { + return time.UnixMilli(n) + } + return time.Unix(n, 0) + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + if t, err := time.ParseInLocation("2006-01-02T15:04", v, time.Local); err == nil { + return t + } + return time.Time{} +} + +// usageSave 立即把内存中的用量桶落盘(正常由后台 30s 防抖刷新负责)。 +func (p *Panel) usageSave(w http.ResponseWriter, r *http.Request) { + if p.cfg.Usage == nil { + writeErr(w, http.StatusNotImplemented, "usage recorder not available") + return + } + p.cfg.Usage.Save() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// packages 返回全部账号的积分包构成,供「积分构成」视图对比。 +// +// 逐个账号向上游查(并发有上限,避免瞬时打满上游限流),失败只在对应账号上 +// 标 error,不影响其它账号——一个号 token 失效不该让整页空白。 +func (p *Panel) packages(w http.ResponseWriter, r *http.Request) { + accts := p.cfg.Pool.List() + type row struct { + UID string `json:"uid"` + Nickname string `json:"nickname"` + Realm string `json:"realm"` + Remain int64 `json:"remain"` + Size int64 `json:"size"` + Packages []upstream.CreditPackage `json:"packages"` + Error string `json:"error,omitempty"` + } + out := make([]row, len(accts)) + + sem := make(chan struct{}, 3) + var wg sync.WaitGroup + for i, s := range accts { + wg.Add(1) + go func(i int, s pool.Status) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + it := row{UID: s.UID, Nickname: s.Nickname, Realm: s.Realm} + a := p.cfg.Pool.AuthByUID(s.UID) + if a == nil { + it.Error = "account not loaded" + out[i] = it + return + } + packs, remain, size, err := p.cfg.Upstream.CreditPackages(a) + if err != nil { + it.Error = err.Error() + out[i] = it + return + } + it.Packages = packs + it.Remain = remain + it.Size = size + out[i] = it + }(i, s) + } + wg.Wait() + + // 余额降序:多的在前,便于和少的对比。 + sort.SliceStable(out, func(i, j int) bool { return out[i].Remain > out[j].Remain }) + writeJSON(w, http.StatusOK, map[string]any{"accounts": out}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + raw, _ := json.Marshal(v) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(raw) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"ok": false, "error": msg}) +} diff --git a/internal/panel/ring.go b/internal/panel/ring.go new file mode 100644 index 0000000000000000000000000000000000000000..37a74f53c02d7e1ea17c3edb3bdccaa16448464d --- /dev/null +++ b/internal/panel/ring.go @@ -0,0 +1,94 @@ +// ring.go 固定容量的结构化日志环形缓冲(并发安全,实现 io.Writer)。 +// main 把 log 包输出与 chat 表格日志经 MultiWriter 镜像进来,面板 +// /panel/api/logs 读取快照;超出容量的旧行按 FIFO 淘汰。 +// +// 每行入环时按前缀规则归类频道(chat=对话请求表格行 / task=任务动作 / +// sys=系统与其它),面板日志视图按频道筛选——对话流量大时任务结果不被冲掉。 +package panel + +import ( + "regexp" + "strings" + "sync" + "time" +) + +// 日志频道。 +const ( + ChChat = "chat" + ChTask = "task" + ChSys = "sys" +) + +// LogEntry 单条日志(时间戳取写入时刻;log 包行的行首日期时间已被剥离)。 +type LogEntry struct { + TS time.Time `json:"ts"` + Ch string `json:"ch"` + Text string `json:"text"` +} + +// taskPrefixes 任务动作日志的行首标识(scheduler 与 panel 的既有口径)。 +var taskPrefixes = []string{ + "school ", "streak-bonus ", "travel ", "blackcat ", "lottery ", + "checkin ", "activity ", "keepalive ", "balance ", "user-resource ", + "panel: 任务", "panel: 一键", "panel: checkin", "panel: 手动", + "panel: 队列", "panel: 券码", +} + +// tsPrefixRe log 包默认 flags(日期 时间)产生的行首时间戳。 +var tsPrefixRe = regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `) + +// classifyLine 按行首特征归类频道。 +func classifyLine(line string) string { + if strings.HasPrefix(line, "| #") { // chat 表格日志(server/logging.go logChatRow) + return ChChat + } + for _, p := range taskPrefixes { + if strings.HasPrefix(line, p) { + return ChTask + } + } + return ChSys +} + +// Ring 日志环形缓冲。 +type Ring struct { + mu sync.Mutex + entries []LogEntry + cap int +} + +// NewRing 构建容量为 capacity 的日志环(非正值回退 500)。 +func NewRing(capacity int) *Ring { + if capacity <= 0 { + capacity = 500 + } + return &Ring{cap: capacity} +} + +// Write 按 \n 切分入环(实现 io.Writer)。空行丢弃;超容量淘汰最旧行。 +func (r *Ring) Write(p []byte) (int, error) { + now := time.Now() + r.mu.Lock() + defer r.mu.Unlock() + for _, line := range strings.Split(strings.TrimRight(string(p), "\r\n"), "\n") { + if line == "" { + continue + } + text := tsPrefixRe.ReplaceAllString(line, "") + r.entries = append(r.entries, LogEntry{TS: now, Ch: classifyLine(text), Text: text}) + if overflow := len(r.entries) - r.cap; overflow > 0 { + r.entries = r.entries[overflow:] + } + } + return len(p), nil +} + +// Snapshot 按写入顺序返回缓冲内全部条目(拷贝,调用方可安全持有)。 +func (r *Ring) Snapshot() []LogEntry { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]LogEntry, len(r.entries)) + copy(out, r.entries) + return out +} diff --git a/internal/panel/ring_test.go b/internal/panel/ring_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7976c03039ed1488b0f3b0257f30927f95b74633 --- /dev/null +++ b/internal/panel/ring_test.go @@ -0,0 +1,50 @@ +package panel + +import ( + "strings" + "testing" + "time" +) + +func TestClassifyLine(t *testing.T) { + cases := map[string]string{ + "| #001 | glm-5.2 | stream | 200 | uid=c8a3e793 | TTFB=120ms |": ChChat, + "school c8a3e793: ★ 分享任务完成": ChTask, + "streak-bonus 5c162cc9: 🎊 新手礼包 +100c": ChTask, + "blackcat c8a3e793: 完成 3 次夜间对话": ChTask, + "checkin 5c162cc9: 已签到": ChTask, + "panel: 任务动作 uid=x code=chat_5": ChTask, + "panel: 队列启动:6 项(并发 2)": ChTask, + "panel: revive uid=x": ChSys, + "workbuddy2api listening on :7863": ChSys, + "scheduler: 余额后台刷新每 5m0s": ChSys, + } + for line, want := range cases { + if got := classifyLine(line); got != want { + t.Errorf("classifyLine(%q)=%q want %q", line, got, want) + } + } +} + +func TestRingWriteStripsTimestamp(t *testing.T) { + r := NewRing(4) + if _, err := r.Write([]byte("2026/09/14 00:12:34 school x: done\n")); err != nil { + t.Fatal(err) + } + if _, err := r.Write([]byte("| #002 | glm | stream | 200 | ok |")); err != nil { + t.Fatal(err) + } + es := r.Snapshot() + if len(es) != 2 { + t.Fatalf("entries=%d want 2", len(es)) + } + if strings.HasPrefix(es[0].Text, "2026/") { + t.Errorf("timestamp not stripped: %q", es[0].Text) + } + if es[0].Ch != ChTask || es[1].Ch != ChChat { + t.Errorf("channels: %q %q", es[0].Ch, es[1].Ch) + } + if time.Since(es[0].TS) > 5*time.Second { + t.Errorf("stale ts: %v", es[0].TS) + } +} diff --git a/internal/panel/security_test.go b/internal/panel/security_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0ae27341fc9e8738480ba77d919d423a4c2b09e1 --- /dev/null +++ b/internal/panel/security_test.go @@ -0,0 +1,146 @@ +package panel + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newTestPanel() *Panel { + // 启用鉴权:未带 key 的请求一律 401,不进入依赖 Pool/Upstream 的 handler。 + return New(Config{Version: "test", APIKey: "test-key"}) +} + +// 面板安全响应头必须覆盖:页面、静态脚本、鉴权失败响应。 +func TestSecurityHeadersOnAllPanelResponses(t *testing.T) { + p := newTestPanel() + paths := []struct{ method, path string }{ + {"GET", "/panel/"}, + {"GET", "/panel/app.js"}, + {"GET", "/panel/api/overview"}, // 401(未提供 key) + {"POST", "/panel/api/config"}, // 401 + {"GET", "/panel/api/nonexistent"}, + } + for _, c := range paths { + rec := httptest.NewRecorder() + p.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) + h := rec.Header() + if got := h.Get("Content-Security-Policy"); got == "" { + t.Errorf("%s %s: missing CSP", c.method, c.path) + } + if h.Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("%s %s: X-Content-Type-Options=%q", c.method, c.path, h.Get("X-Content-Type-Options")) + } + if h.Get("X-Frame-Options") != "DENY" { + t.Errorf("%s %s: X-Frame-Options=%q", c.method, c.path, h.Get("X-Frame-Options")) + } + if h.Get("Referrer-Policy") != "no-referrer" { + t.Errorf("%s %s: Referrer-Policy=%q", c.method, c.path, h.Get("Referrer-Policy")) + } + } +} + +// CSP 必须禁止内联脚本与 iframe 嵌套(严格策略的核心约束)。 +func TestCSPDisallowsInlineScriptAndFraming(t *testing.T) { + p := newTestPanel() + rec := httptest.NewRecorder() + p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil)) + csp := rec.Header().Get("Content-Security-Policy") + + for _, must := range []string{ + "script-src 'self'", + "frame-ancestors 'none'", + "base-uri 'none'", + "default-src 'none'", + } { + if !strings.Contains(csp, must) { + t.Errorf("CSP missing %q; got: %s", must, csp) + } + } + if strings.Contains(csp, "script-src 'self' 'unsafe-inline'") || strings.Contains(csp, "script-src 'unsafe-inline'") { + t.Errorf("CSP must not allow unsafe-inline scripts; got: %s", csp) + } +} + +// 页面必须引用外部脚本(内联脚本会被上面的 CSP 拦掉,页面将完全不可用)。 +func TestIndexReferencesExternalScript(t *testing.T) { + p := newTestPanel() + rec := httptest.NewRecorder() + p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil)) + body := rec.Body.String() + + if !strings.Contains(body, ``) { + t.Error("index.html must load app.js externally (inline script is blocked by CSP)") + } + // 反例保护:出现内联 内容块即为回归 + if strings.Contains(body, "blocked`, ErrWafBlock}, + // 403 带业务信封的仍走既有分类(P0-1 约束:不劫持业务 403)。 + {403, `{"code":11128,"msg":"blocked by security policy"}`, ErrContentBlocked}, + {403, `{"code":60001,"msg":"quota exceeded"}`, ErrHardCredit}, + {403, `{"code":1,"msg":"unknown business error"}`, ErrClient}, + // 非 403 的无信封错误体不进 WAF 分类(WAF 判定绑定 403 形态)。 + {400, `bad request`, ErrClient}, + {429, ``, ErrSoftRate}, + } + for _, c := range cases { + if got := Classify(c.status, c.body); got != c.want { + t.Errorf("Classify(%d,%q)=%v want %v", c.status, c.body, got, c.want) + } + } +} + +// TestIsModelRateLimit 判断 429 body 是否明确指向模型级限流(code 6004)。 +func TestIsModelRateLimit(t *testing.T) { + cases := []struct { + body string + want bool + }{ + // 6004:模型级限流(issue #31 的核心场景)。 + {`{"code":6004,"msg":"将在 2026-09-11 18:33:27 UTC+8 重置"}`, true}, + {`{"code": 6004,"msg":"x"}`, true}, + // 其他 code(非模型级限流)→ 不算。 + {`{"code":11140,"msg":"The model provider is rate-limiting requests."}`, false}, + {`{"code":1,"msg":"429 rate limit"}`, false}, + } + for _, c := range cases { + if got := IsModelRateLimit(c.body); got != c.want { + t.Errorf("IsModelRateLimit(%q)=%v want %v", c.body, got, c.want) + } + } +} + +// TestParseSoftRateReset 解析上游 429 6004 msg 里的「将在 … 重置」时间(## UTC+8)。 +func TestParseSoftRateReset(t *testing.T) { + future := time.Now().Add(35 * time.Minute) + ts := future.In(softRateResetLoc).Format("2006-01-02 15:04:05") + cases := []struct { + name string + body string + ok bool + }{ + {"6004 带时间+UTC+8 后缀", `{"code":6004,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, + {"6004 带时间无后缀", `{"code":6004,"msg":"将在 ` + ts + ` 重置"}`, true}, + {"6004 无时间文案", `{"code":6004,"msg":"model usage limit exceeded"}`, false}, + {"非 6004 但带时间(ParseRateReset 统一解析;模型级豁免由调用侧按 6004 判定)", `{"code":11140,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, + {"非法时间格式", `{"code":6004,"msg":"将在 明天 重置"}`, false}, + {"空 body", ``, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := ParseRateReset(c.body) + if ok != c.ok { + t.Fatalf("ok=%v want %v (body=%s)", ok, c.ok, c.body) + } + if ok { + // 解析结果 = ts 在 UTC+8 解释下的墙钟(截断到分钟),应与 future 相差 ±2 分钟。 + if d := got.Sub(future); d < -2*time.Minute || d > 2*time.Minute { + t.Errorf("parsed=%v want ~%v (diff %v)", got, future, d) + } + if got.Location() != time.UTC { + // 不同指针的 FixedZone 实例相等性按 offset 判,这里只断言 offset。 + if _, off := got.Zone(); off != 8*60*60 { + t.Errorf("zone offset=%d want +08:00", off) + } + } + } + }) + } +} + +type rtFunc func(*http.Request) (*http.Response, error) + +func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func jsonResp(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func testClient(fn rtFunc) *Client { + return &Client{ + HTTP: &http.Client{Transport: fn}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + } +} + +func TestRefreshSuccess(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/plugin/auth/token/refresh") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + if r.Header.Get("X-Refresh-Token") != "oldrt" { + return nil, errors.New("missing X-Refresh-Token") + } + return jsonResp(200, `{"code":0,"msg":"ok","data":{"accessToken":"newat","refreshToken":"newrt","expiresIn":3600}}`), nil + }) + a := &auth.Auth{AccessToken: "at", RefreshToken: "oldrt", ExpiresAt: 1} + if err := c.RefreshToken(a); err != nil { + t.Fatalf("refresh: %v", err) + } + if a.AccessToken != "newat" || a.RefreshToken != "newrt" { + t.Errorf("tokens not updated: %+v", a) + } + if a.ExpiresAt <= 1 { + t.Errorf("expiresAt not advanced: %d", a.ExpiresAt) + } +} + +func TestRefreshPreservesExpiryWhenOmitted(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"accessToken":"newat"}}`), nil + }) + a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000} + if err := c.RefreshToken(a); err != nil { + t.Fatalf("refresh: %v", err) + } + if a.ExpiresAt != 1753600000 { + t.Errorf("expiresAt should be preserved, got %d", a.ExpiresAt) + } + if a.RefreshToken != "rt" { + t.Errorf("refreshToken should be preserved, got %s", a.RefreshToken) + } +} + +func TestRefreshSessionDead(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 401, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"Offline user session not found"}`)), + }, nil + }) + a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1} + err := c.RefreshToken(a) + if err == nil { + t.Fatal("want error") + } + var ue *Error + if !errors.As(err, &ue) { + t.Fatalf("want *Error, got %T %v", err, err) + } + if ue.Kind != ErrSessionDead { + t.Errorf("kind=%v want ErrSessionDead", ue.Kind) + } +} + +func TestChatStreamSendsHeadersAndStreamTrue(t *testing.T) { + var gotAuth, gotUID, gotProduct string + var gotBody []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + gotAuth = r.Header.Get("Authorization") + gotUID = r.Header.Get("X-User-Id") + gotProduct = r.Header.Get("X-Product") + gotBody, _ = io.ReadAll(r.Body) + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1", EnterpriseID: "e1"} + rc, status, respBody, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + if respBody != nil { + t.Errorf("200 response should carry nil body, got %q", respBody) + } + rc.Close() + if gotAuth != "Bearer at" || gotUID != "u1" || gotProduct != "WorkBuddy" { + t.Errorf("headers: auth=%q uid=%q product=%q", gotAuth, gotUID, gotProduct) + } + if !bytes.Contains(gotBody, []byte(`"stream":true`)) { + t.Errorf("stream not forced: %s", gotBody) + } +} + +func TestFetchModelsEffortsDriveBodyDowngrade(t *testing.T) { + var outbound []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"glm-5.2","name":"GLM-5.2","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":["low","high"]}} + ],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil + case strings.HasSuffix(r.URL.Path, "/v3/config"): + return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil + default: + outbound, _ = io.ReadAll(r.Body) + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + } + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + infos, err := c.FetchModels(a) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + if len(infos) != 1 { + t.Fatalf("infos=%+v", infos) + } + // ModelInfo.Efforts 应携带 supportedEfforts,DefaultEffort 应携带 reasoning.effort + if len(infos[0].Efforts) != 2 || infos[0].Efforts[0] != "low" { + t.Errorf("infos[0].Efforts=%v", infos[0].Efforts) + } + if infos[0].DefaultEffort != "high" { + t.Errorf("infos[0].DefaultEffort=%q want high", infos[0].DefaultEffort) + } + // glm-5.2 只支持 low/high,请求 max → 降级为 high + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","reasoning_effort":"max","messages":[]}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + rc.Close() + var m map[string]any + if err := json.Unmarshal(outbound, &m); err != nil { + t.Fatalf("outbound unmarshal: %v (%s)", err, outbound) + } + if got, _ := m["reasoning_effort"].(string); got != "high" { + t.Errorf("reasoning_effort=%v want high (outbound=%s)", m["reasoning_effort"], outbound) + } +} + +func TestChatStreamHardCreditError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(402, `{"code":1,"msg":"余额不足"}`), nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + _, status, respBody, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) + if status != 402 { + t.Errorf("status=%d", status) + } + // 错误信封一次成型:≥400 返回已分类的 *Error(Kind + body 全量仍经 respBody 透出) + var ue *Error + if !errors.As(err, &ue) || ue.Kind != ErrHardCredit { + t.Fatalf("hard credit should return classified *Error envelope, got %v", err) + } + if len(respBody) == 0 { + t.Errorf("body should still be returned for passthrough") + } +} + +// TestChatStreamReadsMultipleChunksOverRealTransport 走真实 net/http 传输层, +// 回归 defer cancel() 导致第二块起 body Read 返回 context canceled 的断流 bug。 +func TestChatStreamReadsMultipleChunksOverRealTransport(t *testing.T) { + const frames = 6 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Error("http.ResponseWriter does not implement http.Flusher") + return + } + for i := 1; i <= frames; i++ { + if _, err := fmt.Fprintf(w, "data: chunk-%d\n\n", i); err != nil { + return + } + flusher.Flush() + time.Sleep(20 * time.Millisecond) + } + })) + defer srv.Close() + + c := New() + c.ChatBaseCN = srv.URL + c.IdleTimeout = 5 * time.Second + + a := &auth.Auth{AccessToken: "at", UID: "u1"} + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + defer rc.Close() + + buf := make([]byte, 1) + var got string + for i := 0; i < frames; i++ { + if _, err := io.ReadFull(rc, buf); err != nil { + t.Fatalf("read %d: %v (real transport body must not be cut)", i, err) + } + got += string(buf) + } + if strings.Contains(got, "context canceled") { + t.Fatalf("body read hit context canceled, got %q", got) + } +} + +func TestUserResourceAggregation(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + body, _ := io.ReadAll(r.Body) + if !bytes.Contains(body, []byte(`"ProductCode":"p_tcaca"`)) { + return nil, errors.New("missing ProductCode: " + string(body)) + } + return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"TotalCount":2,"TotalDosage":3000,"Accounts":[ + {"PackageName":"签到包","CapacitySize":2000,"CapacityRemain":1200,"CapacityUsed":800,"CycleCapacitySize":2000,"CycleCapacityRemain":1200,"CycleCapacityUsed":800}, + {"PackageName":"体验包","CapacitySize":1000,"CapacityRemain":300,"CapacityUsed":700,"CycleCapacitySize":1000,"CycleCapacityRemain":300,"CycleCapacityUsed":700} + ]}}}}`), nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + remain, total, err := c.UserResource(a) + if err != nil { + t.Fatalf("resource: %v", err) + } + if remain != 1500 { + t.Errorf("remain=%d want 1500", remain) + } + if total != 3000 { + t.Errorf("total=%d want 3000", total) + } +} + +func TestUserResourceNegativeClamped(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[ + {"PackageName":"p","CycleCapacitySize":100,"CycleCapacityRemain":-50,"CycleCapacityUsed":150} + ]}}}}`), nil + }) + remain, total, err := c.UserResource(&auth.Auth{AccessToken: "at"}) + if err != nil || remain != 0 { + t.Errorf("remain=%d err=%v, want 0 (clamped)", remain, err) + } + if total != 100 { + t.Errorf("total=%d want 100", total) + } +} + +func TestDailyCheckinAlready(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/daily-checkin") { + return nil, errors.New("wrong path") + } + return jsonResp(200, `{"code":14001,"msg":"今日已签到"}`), nil + }) + err := c.DailyCheckin(&auth.Auth{AccessToken: "at"}) + if err == nil || !strings.Contains(err.Error(), "已签到") { + t.Errorf("err=%v", err) + } +} + +func TestBasesAlwaysCN(t *testing.T) { + c := testClient(nil) + cn := &auth.Auth{Domain: ""} + other := &auth.Auth{Domain: "example.com"} + if c.chatBase(cn) != "https://chat.example" || c.billingBase(cn) != "https://billing.example" { + t.Error("cn bases wrong") + } + // 恒 CN:domain 不同不改变上游 host。 + if c.chatBase(other) != c.chatBase(cn) || c.billingBase(other) != c.billingBase(cn) { + t.Error("bases must be CN regardless of domain") + } +} + +func TestNewChatClientNoTotalTimeoutAndSharedTransport(t *testing.T) { + c := New() + if c.ChatHTTP == nil { + t.Fatal("ChatHTTP should be initialized") + } + if c.ChatHTTP.Timeout != 0 { + t.Errorf("ChatHTTP.Timeout=%v want 0 (no total cap)", c.ChatHTTP.Timeout) + } + // 共享同一个 Transport 实例,连接池不重复。 + if c.ChatHTTP.Transport != c.HTTP.Transport { + t.Errorf("ChatHTTP and HTTP must share the same *http.Transport") + } + htr, ok := c.ChatHTTP.Transport.(*http.Transport) + if !ok { + t.Fatalf("Transport type=%T", c.ChatHTTP.Transport) + } + if htr.ResponseHeaderTimeout != 60*time.Second { // 连接层加固:响应头上限从 120s 收到 60s(慢冷启动留 3.75× 余量) + t.Errorf("ResponseHeaderTimeout=%v want 60s", htr.ResponseHeaderTimeout) + } +} + +func TestChatStreamRoutesToChatHTTP(t *testing.T) { + // 显式注入 ChatHTTP(可辨识标记),验证 ChatStream 走它而非 HTTP。 + chatHit, httpHit := false, false + c := testClient(func(*http.Request) (*http.Response, error) { + httpHit = true + return jsonResp(200, `{}`), nil + }) + c.ChatHTTP = &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) { + chatHit = true + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + })} + a := &auth.Auth{AccessToken: "at", UID: "u1"} + rc, status, _, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + rc.Close() + if !chatHit { + t.Error("ChatStream should use ChatHTTP") + } + if httpHit { + t.Error("ChatStream must not use HTTP") + } +} + +func TestChatHTTPNilFallsBackToHTTP(t *testing.T) { + c := testClient(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + }) + if c.chatHTTP() != c.HTTP { + t.Error("chatHTTP() should fall back to HTTP when ChatHTTP is nil") + } +} + +func TestFetchModelsDefaultEffortDualKeyAndSizes(t *testing.T) { + // 上游双键:老模型 reasoning.effort(auto),新模型(glm-5.3 系)只有 reasoning.defaultEffort; + // credits/maxAllowedSize/canDisableThinking/supportsReasoning 等尺寸与能力字段应一并透出。 + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"glm-5.3","maxInputTokens":1000000,"maxOutputTokens":48000,"maxAllowedSize":1000000,"credits":"x0.79","supportsReasoning":true,"reasoning":{"defaultEffort":"high","canDisableThinking":true,"supportedEfforts":["low","high","max"]}}, + {"id":"auto","maxInputTokens":168000,"maxOutputTokens":32000,"supportsReasoning":true,"reasoning":{"effort":"high"}} + ],"agents":[{"name":"cli","models":["glm-5.3","auto"]}]}}`), nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + infos, err := c.FetchModels(a) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + byID := map[string]ModelInfo{} + for _, mi := range infos { + byID[mi.ID] = mi + } + g := byID["glm-5.3"] + if g.DefaultEffort != "high" { + t.Errorf("glm-5.3 DefaultEffort=%q want high (from defaultEffort key)", g.DefaultEffort) + } + if !g.CanDisableThinking || !g.SupportsReasoning { + t.Errorf("glm-5.3 capability flags: canDisable=%v supportsReasoning=%v want true/true", g.CanDisableThinking, g.SupportsReasoning) + } + if g.MaxAllowedSize != 1000000 || g.MaxTokens != 48000 || g.Credits != "x0.79" { + t.Errorf("glm-5.3 sizes: maxAllowed=%d maxOut=%d credits=%q", g.MaxAllowedSize, g.MaxTokens, g.Credits) + } + if got := c.ModelRate("cn", "glm-5.3"); got != "0.79" { + t.Errorf("glm-5.3 ModelRate=%q want 0.79", got) + } + if au := byID["auto"]; au.DefaultEffort != "high" { + t.Errorf("auto DefaultEffort=%q want high (from legacy effort key)", au.DefaultEffort) + } +} + +func TestModelRateCacheEffectiveAndNormalized(t *testing.T) { + c := New() + factor := 0.5 + c.storeModelRates("cn", []ModelInfo{ + {ID: "base", Credits: "x0.50 credits"}, + {ID: "promo", Credits: "x0.80", PromoFactor: &factor, PromoCredits: "0.50x"}, + }) + if got := c.ModelRate("cn", "base"); got != "0.5" { + t.Fatalf("base rate=%q want 0.5", got) + } + if got := c.ModelRate("cn", "promo"); got != "0.5" { + t.Fatalf("promo rate=%q want 0.5", got) + } + if got := normalizeModelRate("x0.05 credits"); got != "0.05" { + t.Fatalf("normalizeModelRate=%q want 0.05", got) + } + c.storeModelRates("cn", []ModelInfo{{ID: "base", Credits: "x0.79"}}) + if got := c.ModelRate("cn", "base"); got != "0.79" { + t.Fatalf("refreshed base rate=%q want 0.79", got) + } + if got := c.ModelRate("cn", "promo"); got != "" { + t.Fatalf("stale promo rate=%q want empty after full refresh", got) + } +} + +func TestFetchModelsOverlaysV3ConfigCapabilities(t *testing.T) { + // CLI 目录给 flash 精简字段(128K / 固定 high);IDE /v3/config 给完整能力。 + var sawIDE bool + c := testClient(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":128000,"credits":"x0.03 credits","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"effort":"high","summary":"auto"}} + ],"agents":[{"name":"cli","models":["deepseek-v4.1-flash"]}]}}`), nil + case strings.HasSuffix(r.URL.Path, "/v3/config"): + sawIDE = true + if r.Header.Get("User-Agent") != codeBuddyIDEUA { + t.Errorf("v3/config UA=%q want %s", r.Header.Get("User-Agent"), codeBuddyIDEUA) + } + if r.Header.Get("X-Product") != "SaaS" { + t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) + } + if r.Header.Get("X-User-Id") != "u1" { + t.Errorf("X-User-Id=%q want u1", r.Header.Get("X-User-Id")) + } + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":393216,"credits":"x0.03","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"canDisableThinking":true,"defaultEffort":"high","summary":"auto","supportedEfforts":["low","high","max"]}} + ]}}`), nil + default: + t.Errorf("unexpected path %s", r.URL.Path) + return jsonResp(404, `{}`), nil + } + }) + a := &auth.Auth{AccessToken: "at", UID: "u1", Domain: "copilot.tencent.com"} + infos, err := c.FetchModels(a) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + if !sawIDE { + t.Fatal("expected /v3/config request") + } + if len(infos) != 1 { + t.Fatalf("infos=%+v", infos) + } + mi := infos[0] + if mi.MaxTokens != 393216 { + t.Errorf("MaxTokens=%d want 393216", mi.MaxTokens) + } + if mi.ContextWindow != 1000000 { + t.Errorf("ContextWindow=%d want 1000000", mi.ContextWindow) + } + if !mi.CanDisableThinking || !mi.SupportsReasoning { + t.Errorf("flags canDisable=%v supportsReasoning=%v", mi.CanDisableThinking, mi.SupportsReasoning) + } + if mi.DefaultEffort != "high" { + t.Errorf("DefaultEffort=%q want high", mi.DefaultEffort) + } + if got := strings.Join(mi.Efforts, ","); got != "low,high,max" { + t.Errorf("Efforts=%v want low,high,max", mi.Efforts) + } +} diff --git a/internal/upstream/context_catalog.go b/internal/upstream/context_catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..0985bfb8323871aad95d42674cd8e11d4f17c3b1 --- /dev/null +++ b/internal/upstream/context_catalog.go @@ -0,0 +1,106 @@ +// context_catalog.go context_length / max_output_tokens 字段级静态兜底知识表。 +// +// 数据来源四级(model-json-dynamic 任务书;查找链入口在 model_catalog.go 的 +// ContextWindowListingV4 / MaxOutputTokensListingV4,本文件是第 2 级): +// - 上游动态值(ModelInfo.ContextWindow/MaxTokens,即 maxInputTokens/maxOutputTokens)权威,优先; +// - 本文件静态知识表(远端零值时补齐;model.json 缺失/损坏时的编译期兜底); +// - model.json 本地缓存(数据目录,含运行时 models.dev 按需补值,见 model_catalog.go); +// - models.dev 按需拉取(异步不阻塞;仍未知 → context_length 1M 兜底——宁可高估 +// 不低估:高估代价是客户端不截断、上游报错可重试;低估代价是下游客户端 +// (Codex/ZCode/Claude Code 按 context_length 提前截断)白白丢上下文; +// max_output_tokens 未知 → 省略字段(输出上限无合估算据,不编造))。 +// +// 知识表**一处定义、CN/global 两域共用**:context_length 是模型固有属性——fork +// 706412584 实测结论「两区是同一套 API 的两次部署」,同 id 上下文一致,无按 realm +// 分表必要(与 effort 档位的 realm 分表刻意不同)。 +// +// 值来源两类,逐条注释标注: +// - fork 实测:706412584 直连上游 /console/enterprises/personal/models 的 +// maxInputTokens(/tmp/fork_diffs/70_927f57f9.diff,2026-09-13 实测; +// global 侧无法直测、按同 id 外推); +// - models.dev:https://models.dev/ 收录值(2026-09-16 查询,取多 provider 共识值; +// 官方源如 moonshotai/zai 优先)。未收录或歧义大者不编造。 +package upstream + +// DefaultContextWindow 知识表也未收录的模型的 context_length 兜底:1M。 +// 上游多数大窗口模型的实际量级;高估优于低估(见文件头)。 +const DefaultContextWindow int64 = 1000000 + +// contextCap 一个模型的上下文能力(字段级兜底条目)。 +// context 必为正(否则条目无意义,直接走 1M 兜底); +// maxOutput 为 0 表示输出上限未知 → max_output_tokens 字段省略(不编造)。 +type contextCap struct { + context int64 + maxOutput int64 +} + +// contextCapFallback context_length / max_output_tokens 知识表(CN/global 共用)。 +// 每条注释标注来源:实测 = fork 706412584 直连 CN /console 实测 maxInputTokens +// (global 侧为同 id 外推);models.dev = 2026-09-16 收录共识值;估算 = 同族外推。 +var contextCapFallback = map[string]contextCap{ + // ---- GLM 家族(z-ai)---- + "glm-5.2": {context: 1000000, maxOutput: 131072}, // 实测(CN 1M;models.dev 共识 1M/131072) + "glm-5.1": {context: 200000, maxOutput: 131072}, // 实测(CN 200K;models.dev 共识 200K/131072) + "glm-5.3": {context: 1000000, maxOutput: 131072}, // 实测外推 + models.dev 共识 1M/131072 + "glm-5.3-flash": {context: 1000000, maxOutput: 131072}, // models.dev 共识 1M/131072 + "glm-5v-turbo": {context: 200000, maxOutput: 131072}, // 实测(CN 200K;models.dev 共识 200K/131072) + + // ---- Kimi 家族(moonshot)---- + "kimi-k2.7": {context: 256000, maxOutput: 65536}, // 实测(CN 256K);输出 65536 为 models.dev kimi-k2.7-code 同族估算 + "kimi-k2.6": {context: 256000, maxOutput: 262144}, // 实测(CN 256K);输出 models.dev 官方 262144 + "kimi-k2.5": {context: 164000, maxOutput: 262144}, // 实测(global 侧同 id 外推 164K);输出 models.dev 共识 262144 + "kimi-k3": {context: 1048576, maxOutput: 131072}, // models.dev 官方(moonshotai 1M/128K) + "kimi-k2.8-preview": {context: 1048576, maxOutput: 0}, // models.dev(Kimi K2.8 Preview 1M;输出上限未收录,省略) + + // ---- MiniMax / 混元(tencent)---- + "minimax-m3": {context: 512000, maxOutput: 512000}, // 实测(CN 512K);输出 models.dev 共识 512000 + "hy3": {context: 192000, maxOutput: 64000}, // 实测(CN 192K/64K,repo hy3 抓取样本同值) + "hy3-preview": {context: 262144, maxOutput: 64000}, // models.dev(共识 262144/64000) + "hy4-preview": {context: 1000000, maxOutput: 64000}, // 实测外推 + models.dev(~1M/64000) + "hy4-preview-x": {context: 1000000, maxOutput: 64000}, // 实测外推(1M);输出同族 hy4-preview 估算 + + // ---- DeepSeek 家族 ---- + "deepseek-v4-pro": {context: 1000000, maxOutput: 384000}, // 实测(CN 1M;models.dev 共识 1M/384000) + "deepseek-v4-flash": {context: 1000000, maxOutput: 384000}, // 实测(CN 1M;models.dev 共识 1M/384000) + "deepseek-v4.1-flash": {context: 1000000, maxOutput: 384000}, // 实测外推 + models.dev 共识 1M/384000 + + // ---- OpenAI / Google(global 域家族)---- + "gpt-6-astra": {context: 1050000, maxOutput: 128000}, // models.dev(全 provider 一致 1050000/128000) + "gpt-5.6-sol": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.6-terra": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.6-luna": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.5": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.4": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.3-codex": {context: 400000, maxOutput: 128000}, // models.dev(全 provider 一致 400000/128000) + "gemini-3.5-flash": {context: 1048576, maxOutput: 65536}, // models.dev 共识 + + // ---- global 域路由别名/别名模型 ---- + "auto": {context: 168000, maxOutput: 0}, // 实测外推(fork global 静态表 168K);输出上限未知,省略 +} + +// ContextWindowListing 模型在 /v1/models 的 context_length(三级查找): +// remote(上游 maxInputTokens)>0 时权威;否则查知识表;仍未收录 → DefaultContextWindow +// (1M,宁可高估不低估)。绝不再透出假 131072。 +func ContextWindowListing(model string, remote int64) int64 { + if remote > 0 { + return remote + } + if cap, ok := contextCapFallback[model]; ok && cap.context > 0 { + return cap.context + } + return DefaultContextWindow +} + +// MaxOutputTokensListing 模型在 /v1/models 的 max_output_tokens(三级查找): +// remote(上游 maxOutputTokens)>0 时权威;否则查知识表;仍未收录 → ok=false +// (调用方省略字段,不编造输出上限)。与 ContextWindowListing 的 1M 兜底刻意不同: +// 输出上限无「宁可高估」的安全侧,未知即省略。 +func MaxOutputTokensListing(model string, remote int64) (int64, bool) { + if remote > 0 { + return remote, true + } + if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 { + return cap.maxOutput, true + } + return 0, false +} diff --git a/internal/upstream/desktop.go b/internal/upstream/desktop.go new file mode 100644 index 0000000000000000000000000000000000000000..0271b4b80e589767c18a814f6614e2c4178b0713 --- /dev/null +++ b/internal/upstream/desktop.go @@ -0,0 +1,588 @@ +// desktop.go 桌面客户端(WorkBuddy Desktop 5.5.6)行为指纹上报。 +// +// 来源:2026-09-12 Sunny 抓包实测(data/desktop-task-protocol.md)。桌面端点亮 +// 「需电脑端」类任务的关键不是独立端点,而是同一 POST /v2/report 通道上 +// **不同的客户端指纹**: +// +// POST https://copilot.tencent.com/v2/report ← chatBase(CLI 上报走 billingBase) +// User-Agent: WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1 +// X-Domain: copilot.tencent.com, X-Product: SaaS, X-User-Id: +// Body: [ {...event...} ] ← 数组 +// +// 每个事件除业务字段外必带桌面指纹(ideName/ideType=WorkBuddy、 +// extName=workbuddy-desktop 等)。实测点亮记录: +// - RichMeow_Chat(桌面端对话1次):agent_task_created + 成功的 +// chat_message_response(isSuccessful=true) → 1/1 + UR Buddy。 +// - Hp_Appearance(主题任务):独立 API +// POST /v2/user-asset/appearance/set {kind:"theme",resource_key} → SetAppearanceTheme。 +// +// 注意:service 端对事件链有一定真实性校验倾向(RichMeow 需要消息成功回执), +// 本模块按实测事件形状发送,不保证所有任务都能 API 侧点亮——autotask 侧 +// 仍按 attempt 语义处理结果。 +package upstream + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + desktopReportPath = "/v2/report" + desktopAppearanceSet = "/v2/user-asset/appearance/set" + // desktopUA 实测桌面客户端 UA(5.5.6 内嵌 CLI 2.137.1)。 + desktopUA = "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1" +) + +// desktopBase 桌面端 /v2/report 与 user-asset 走 chatBase(copilot.tencent.com)。 +func (c *Client) desktopBase(a *auth.Auth) string { return c.chatBase(a) } + +// deriveID 由 uid 稳定派生一个 36 位 hex 设备标识(machineId/qimei36 复用), +// 幂等:同一账号每次生成相同值,模拟固定设备。 +func deriveID(a *auth.Auth, salt string) string { + sum := sha256.Sum256([]byte(salt + ":" + a.UID)) + return hex.EncodeToString(sum[:18]) // 36 hex chars +} + +// DesktopEvent 桌面端事件:业务字段任意(map),公共指纹由 ReportDesktopEvent 注入。 +type DesktopEvent map[string]any + +// desktopFingerprint 公共桌面指纹字段(注入每个事件,覆盖同名业务键)。 +func desktopFingerprint(a *auth.Auth) map[string]any { + now := time.Now().UnixMilli() + return map[string]any{ + "timezone": "Asia/Shanghai", + "reportDelay": 2000, + "userId": a.UID, + "username": a.Nickname, + "userNickname": a.Nickname, + "product": "SaaS", + "releaseDate": int64(1789036585355), + "commit": "5f9692923c93033111c51ad7b003eb80204a9b75", + "ideName": "WorkBuddy", + "ideType": "WorkBuddy", + "ideVersion": "5.5.6", + "machineId": deriveID(a, "machine"), + "sessionId": deriveID(a, "session"), + "extName": "workbuddy-desktop", + "extVersion": "5.5.6", + "os": "win32", + "arch": "x64", + "osVersion": "10.0.26220", + "cpuCores": 20, + "memorySize": 24, + "timestamp": now, + "presentAt": now, + } +} + +// ReportDesktopEvent 以桌面客户端指纹向 copilot.tencent.com/v2/report 批量上报事件。 +// events 为业务载荷(eventCode 等字段由调用方给出);公共指纹自动注入, +// 业务字段优先(可用于覆盖 qimei36/machineId 等设备标识做真实设备对齐)。 +func (c *Client) ReportDesktopEvent(a *auth.Auth, events ...DesktopEvent) error { + if len(events) == 0 { + return fmt.Errorf("desktop report: no events") + } + fp := desktopFingerprint(a) + arr := make([]map[string]any, 0, len(events)) + for _, ev := range events { + m := map[string]any{} + for k, v := range fp { + m[k] = v + } + for k, v := range ev { + m[k] = v + } + arr = append(arr, m) + } + raw, err := json.Marshal(arr) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.desktopBase(a)+desktopReportPath, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("User-Agent", desktopUA) + req.Header.Set("X-Domain", c.desktopBase(a)) + req.Header.Set("X-Product", "SaaS") + req.Header.Set("X-Request-ID", deriveID(a, "req")+fmt.Sprintf("%d", time.Now().UnixNano()%1e6)) + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + _, err = c.doJSON(req) + return err +} + +// DesktopChatSequence 构造一次「桌面端成功对话」的完整事件链 +// (agent_task_created → chat_message_send → chat_request_send → +// chat_message_response(isSuccessful) → chat_message_status → chat_request_response)。 +// 实测该链点亮 RichMeow_Chat。conversationID/requestID/messageID 由调用方生成。 +func DesktopChatSequence(conversationID, requestID, messageID, modelID, modelName string) []DesktopEvent { + uuid := func() string { return requestID } + mk := func(code string, extra map[string]any) DesktopEvent { + ev := DesktopEvent{"eventCode": code} + for k, v := range extra { + ev[k] = v + } + return ev + } + return []DesktopEvent{ + mk("agent_task_created", map[string]any{ + "source": "LOCAL", "name": "working", "task_target": "local", "mode": "craft", + "requestModelId": modelID, "requestModelName": modelName, + "has_repo": false, "repo_type": "none", "workspace_type": "empty", + "has_connector": false, "connector_types": []any{}, + "has_mention": false, "mention_types": []any{}, + "has_template": false, "action": "", "template_name": "", + "has_expert": false, "expert_id": "", "expert_name": "", "expert_industry_id": "", + "has_skill": false, "skill_names": []any{}, + "conversationId": conversationID, "messageId": messageID, + "buddyId": "", "buddyName": "", + }), + mk("chat_message_send", map[string]any{ + "messageId": messageID + "-assistant", "historyCount": 0, + "isContextTruncated": false, "currentStepCount": 1, + "traceId": uuid(), "rootRequestId": requestID, + "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + }), + mk("chat_request_send", map[string]any{ + "inputLength": 24, "isPlan": false, "isAutoExecuteTerminal": false, + "isAutoModify": false, "codebaseEnable": false, "maxToken": 0, + "maxSteps": 500, "temperature": 0, "maxRetries": 0, + "mentionContexts": []any{}, "knowledgeId": []any{}, "knowledgeName": []any{}, + "codebaseId": "", "mentionContextCount": 0, "command": "", + "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, + "traceId": uuid(), "rootRequestId": requestID, + "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + "codebuddy.session_id": conversationID, + "codebuddy.conversation_request_id": requestID, + }), + mk("chat_message_response", map[string]any{ + "messageId": messageID + "-assistant", "responseModelId": modelID, + "inputToken": 120, "outputToken": 80, "totalToken": 200, + "cachedTokens": 0, "cachedWriteTokens": 0, "cachedMissTokens": 0, + "isSuccessful": true, "messageErrorCode": "", "finishReason": "stop", + "firstTokenAt": time.Now().UnixMilli(), "traceId": uuid(), + "conversationId": conversationID, + "rootRequestId": requestID, "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + "codebuddy.session_id": conversationID, + "codebuddy.conversation_request_id": requestID, + }), + mk("chat_message_status", map[string]any{ + "messageId": messageID + "-assistant", "messageErrorCode": "0", + "traceId": uuid(), "rootRequestId": requestID, + "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + }), + mk("chat_request_response", map[string]any{ + "mode": "craft", "toolCallCount": 0, + "inputToken": 120, "outputToken": 80, "totalToken": 200, + "cachedTokens": 0, "cachedWriteTokens": 0, "cachedMissTokens": 0, + "isSuccessful": true, "messageErrorCode": "", "finishReason": "stop", + "rootRequestId": requestID, "parentConversationId": conversationID, + }), + } +} + +// SetAppearanceTheme 应用外观主题(实测:POST copilot.tencent.com/v2/user-asset/appearance/set, +// 和品主题 resource_key 为 "theme-tkmw7j",浅色 "light"、深色 "dark")。纯 API set 不计 +// Hp_Appearance 分(需客户端切主题后真实活跃),保留供调色/还原与后续验证用。 +func (c *Client) SetAppearanceTheme(a *auth.Auth, resourceKey string) error { + body := map[string]string{"kind": "theme", "resource_key": resourceKey} + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.desktopBase(a)+desktopAppearanceSet, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("User-Agent", desktopUA) + req.Header.Set("X-Product", "SaaS") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + _, err = c.doJSON(req) + return err +} + +// DesktopBuddyAppSequence 构造「进入 Buddy 应用」五连事件(实测两账号纯 API 点亮 +// Buddy_App 与 Buddy_App_QQ):discover → show → enter_click → auth_confirm → +// bindaccount_skip。buddyID 固定用企鹅教师助手 cb_y5Dy46tPQGGWtueMxXbe +// (Buddy_App_QQ 的判据应用),同时满足 Buddy_App「进入任一应用」。 +func DesktopBuddyAppSequence(buddyID, buddyName string) []DesktopEvent { + mk := func(code string, extra map[string]any) DesktopEvent { + ev := DesktopEvent{ + "eventCode": code, "mode": "LOCAL", + "buddyId": buddyID, "buddyName": buddyName, + } + for k, v := range extra { + ev[k] = v + } + return ev + } + return []DesktopEvent{ + mk("buddyapp_discover_click", nil), + mk("buddyapp_show", map[string]any{"elementId": buddyID, "elementName": buddyName, "position": 2}), + mk("buddyapp_enter_click", map[string]any{"elementId": buddyID, "elementName": buddyName, "position": 2, "isFirstPage": "1"}), + mk("buddyapp_auth_confirm_click", map[string]any{"elementId": buddyID, "elementName": buddyName}), + mk("buddyapp_bindaccount_skip_click", map[string]any{"elementId": buddyID, "elementName": buddyName}), + } +} + +// DesktopAutomationCreateEvent 构造「定时任务创建成功」事件(实测两账号纯 API +// 点亮 automation_1)。name 为任务名,可与真实创建语义对齐。 +func DesktopAutomationCreateEvent(name string) DesktopEvent { + return DesktopEvent{ + "eventCode": "automated_task_create_suc", "name": name, + "source": "manually", "modelId": "fast-model", "modelIsThinking": true, + "connectorCount": 0, "skills": "", "skillCount": 0, + "scheduleType": "once", "mode": "LOCAL", + } +} + +// ReportWebEvent 以 Web 端指纹向 www.workbuddy.cn/v2/report 上报单事件。 +// 与桌面指纹(copilot 域)不同:web 域事件是浏览器形状(os/machineId/userAgent), +// 用于 Library_read 等页面行为类任务(实测 library_doc_intro_click 4 秒点亮)。 +func (c *Client) ReportWebEvent(a *auth.Auth, eventCode, pageURL, elementID, elementName string) error { + ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" + ev := map[string]any{ + "eventCode": eventCode, "timestamp": time.Now().UnixMilli(), "reportDelay": 0, + "pageURL": pageURL, "elementId": elementID, "elementName": elementName, + "os": "Win32", "arch": "", "osVersion": "10.0", "userAgent": ua, + "machineId": deriveID(a, "webmachine"), "userId": a.UID, + "userNickname": a.Nickname, "enterpriseId": a.EnterpriseID, + } + raw, err := json.Marshal([]map[string]any{ev}) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.webBase(a)+"/v2/report", bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-client-platform", "web") + req.Header.Set("Origin", c.webBase(a)) + req.Header.Set("Referer", pageURL) + req.Header.Set("User-Agent", ua) + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + _, err = c.doJSON(req) + return err +} + +// --------------------------------------------------------------------------- +// 2026-09-12 第五轮:客户端 asar 逆向 + Sunny 真实样本驱动的新判据(三账号实测点亮)。 +// 来源:WorkBuddy.exe 5.5.6 app.asar 渲染层事件枚举/载荷 + 抓包真实专家召唤样本 +// (data/desktop-task-protocol.md §7.3)。 +// --------------------------------------------------------------------------- + +// DesktopTemplateUseSequence 构造「使用模板创建任务」事件组(实测 template_5 计数): +// agent_task_created_with_template {mode,isCustomModel,id,name,requestId} + +// template_used {template_id,task_mode},JOIN 一条完整 chat 链。 +// 三账号实测:5 组(不同模板)一次上报 → 5/5 点亮。 +func DesktopTemplateUseSequence(conversationID, requestID, templateID, templateName string) []DesktopEvent { + events := DesktopChatSequence(conversationID, requestID, "msg-"+templateID, "fast-model", "fast-model") + events = append(events, + DesktopEvent{ + "eventCode": "agent_task_created_with_template", "mode": "working", + "isCustomModel": false, "id": templateID, "name": templateName, "requestId": requestID, + }, + DesktopEvent{"eventCode": "template_used", "template_id": templateID, "task_mode": "working"}, + ) + return events +} + +// DesktopPlaybookPromptSequence 构造「灵感案例做同款」事件组(实测 playbook_prompt 计数): +// web_element_click(playbook_ctaClick) + playbook_cta_click + playbook_prompt_send +// (Dialog 发送 Prompt,带 conversationId/requestId JOIN chat 链)。三账号实测 1/1 点亮。 +func DesktopPlaybookPromptSequence(conversationID, requestID, caseID, caseName string) []DesktopEvent { + events := DesktopChatSequence(conversationID, requestID, "msg-pb", "fast-model", "fast-model") + payload := map[string]any{ + "id": caseID, "name": caseName, "type": "document", + "categoryId": "", "categoryName": "", + } + events = append(events, + DesktopEvent{ + "eventCode": "web_element_click", "pageName": "playbook_detail", + "elementId": "playbook_ctaClick", "elementName": caseName, "source": "discover", + }, + DesktopEvent(func() map[string]any { + m := map[string]any{"eventCode": "playbook_cta_click", "source": "discover", "position": 0} + for k, v := range payload { + m[k] = v + } + return m + }()), + DesktopEvent(func() map[string]any { + m := map[string]any{"eventCode": "playbook_prompt_send", "conversationId": conversationID, "requestId": requestID} + for k, v := range payload { + m[k] = v + } + return m + }()), + ) + return events +} + +// DesktopDesignCanvasSequence 构造「设计创意画布」事件组(实测 create_canvas 计数): +// wbx_design_canvas_task_create + wbx_design_canvas_open(Ardot create_design 工具 +// 完成时客户端经 metrics 通道上报,同一 /v2/report 端点)。三账号实测 1/1 点亮。 +func DesktopDesignCanvasSequence(conversationID, requestID string) []DesktopEvent { + events := DesktopChatSequence(conversationID, requestID, "msg-canvas", "fast-model", "fast-model") + return append(events, + DesktopEvent{ + "eventCode": "wbx_design_canvas_task_create", "conversationId": conversationID, + "requestId": requestID, "source": "summon_keyword", "cost": 12000, "isSuccessful": true, + }, + DesktopEvent{ + "eventCode": "wbx_design_canvas_open", "conversationId": conversationID, + "requestId": requestID, "id": "ardot-file-" + requestID[len(requestID)-8:], + "source": "summon_keyword", "type": "page", "cost": 13000, "isSuccessful": true, + }, + ) +} + +// MarketExpert 专家市场的单个专家(/portal/operation-platform/market/expert/list 响应子集)。 +type MarketExpert struct { + ExpertID string `json:"expert_id"` + ExpertType string `json:"expert_type"` + DisplayNameZH string `json:"display_name_zh"` + ProfessionZH string `json:"profession_zh"` + Version string `json:"version"` + Categories []any `json:"categories"` +} + +// MarketExpertList 拉取专家市场真实专家列表(expertType: "agent" 单专家 / "team" 专家团)。 +// expert_actual_use 的判据校验要求 id 是平台上真实存在的专家(编造 id 不计数)。 +func (c *Client) MarketExpertList(a *auth.Auth, expertType string) ([]MarketExpert, error) { + body := map[string]any{"page": 1, "page_size": 20, "sort_by": "reco_rank", "sort_order": "desc"} + if expertType != "" { + body["expert_type"] = expertType + } + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, c.chatBase(a)+"/portal/operation-platform/market/expert/list", bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", desktopUA) + req.Header.Set("X-Domain", c.chatBase(a)) + req.Header.Set("X-Product", "SaaS") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + var out struct { + Experts []MarketExpert `json:"experts"` + } + data, err := c.doJSON(req) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("expert list parse: %w", err) + } + return out.Experts, nil +} + +// DesktopChatWithExpert 发一条真实桌面指纹 chat 请求(可带 X-Expert-Id),从 SSE 流 +// 解析**服务端返回的 requestId**(data.id,如 cmb-xxxx / 32hex)并返回。 +// expert_actual_use 等 JOIN 事件的 requestId 必须是该服务端 id——自造 UUID 不计数 +// (客户端 resolveRealRequestId 同款语义,Sunny row 2113 实证)。 +func (c *Client) DesktopChatWithExpert(a *auth.Auth, expertID string) (conversationID, requestID string, err error) { + conversationID = fmt.Sprintf("wb2api-conv-%d", time.Now().UnixNano()) + body := map[string]any{ + "model": "fast-model", + "messages": []any{ + map[string]any{"role": "system", "content": "You are a helpful assistant. 当前处于中文环境,使用简体中文回答。"}, + map[string]any{"role": "user", "content": "1+1等于几?直接回答。"}, + }, + "agent": "cli", + "temperature": 1, + "stream": true, + "stream_options": map[string]any{"include_usage": true}, + } + raw, err := json.Marshal(body) + if err != nil { + return "", "", err + } + req, err := http.NewRequest(http.MethodPost, c.chatBase(a)+"/v2/chat/completions", bytes.NewReader(raw)) + if err != nil { + return "", "", err + } + h := req.Header + h.Set("Authorization", "Bearer "+a.AccessTokenValue()) + h.Set("Content-Type", "application/json") + h.Set("Accept", "text/event-stream") + h.Set("User-Agent", desktopUA) + h.Set("X-Domain", c.chatBase(a)) + h.Set("X-Product", "SaaS") + h.Set("X-User-Id", a.UID) + h.Set("X-Conversation-ID", conversationID) + h.Set("X-Request-ID", fmt.Sprintf("%d", time.Now().UnixNano())) + h.Set("X-Agent-Intent", "craft") + h.Set("X-Agent-Type", "main") + h.Set("X-IDE-Name", "WorkBuddy") + h.Set("X-IDE-Type", "WorkBuddy") + h.Set("X-IDE-Version", "5.5.6") + h.Set("x-codebuddy-request", "1") + if expertID != "" { + h.Set("X-Expert-Id", expertID) + } + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + fmt.Printf("[dbg] URL=%s\n", req.URL) + for k := range req.Header { + fmt.Printf("[dbg] %s: %s\n", k, req.Header.Get(k)) + } + } + resp, err := c.HTTP.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + fmt.Printf("[dbg] status=%d enc=%q\n", resp.StatusCode, resp.Header.Get("Content-Encoding")) + } + if resp.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return "", "", fmt.Errorf("chat http %d: %s", resp.StatusCode, b) + } + // 从 SSE 流抓第一个 data.id 作为服务端 requestId(读干流避免残留连接)。 + buf := make([]byte, 0, 1<<20) + tmp := make([]byte, 8192) + searchFrom := 0 + for { + n, rerr := resp.Body.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + dbg := string(tmp[:n]) + if len(dbg) > 120 { + dbg = dbg[:120] + } + fmt.Printf("[dbg-rd %d] %q\n", n, dbg) + } + // 从上次搜索位置继续:SSE 里 `"id":"` 可能先出现在消息 id 等字段, + // 若不推进偏移,首个不匹配的 id 会让循环永远重复命中同一位置, + // 读满 1MB 后误报"未找到 requestId"。 + if i := bytes.Index(buf[searchFrom:], []byte(`"id":"`)); i >= 0 { + abs := searchFrom + i + rest := buf[abs+6:] + if end := bytes.IndexByte(rest, '"'); end > 0 { + id := string(rest[:end]) + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + fmt.Printf("[dbg-id] %q match=%v\n", id, idRegex.MatchString(id)) + } + if idRegex.MatchString(id) { + return conversationID, id, nil + } + searchFrom = abs + 1 + } + } + } + if rerr != nil || len(buf) > 1<<20 { + break + } + } + return "", "", fmt.Errorf("SSE 中未找到服务端 requestId") +} + +// idRegex 服务端 requestId 形状(cmb- 前缀 32hex 或裸 32hex)。 +var idRegex = regexp.MustCompile(`^(cmb-)?[0-9a-f]{32}$`) + +// DesktopExpertSummonSequence 构造「召唤平台专家」事件组(expert_summon_click 等), +// 载荷对齐真实抓包样本(Sunny row 2644)。需配合 DesktopChatWithExpert + +// DesktopExpertActualUseEvent 完成一次完整「召唤+使用」。 +func DesktopExpertSummonSequence(e MarketExpert) []DesktopEvent { + cat := "expert-all" + if len(e.Categories) > 0 { + if s, ok := e.Categories[0].(string); ok { + cat = s + } + } + ver := e.Version + if ver == "" { + ver = "1.0.0" + } + return []DesktopEvent{ + { + "eventCode": "web_element_click", "source": e.ExpertID, "type": cat, "version": ver, + "elementId": "expert_summon_click", "elementName": "立即召唤", + "pageURL": "/C:/Program%20Files/WorkBuddy/resources/app.asar/renderer/index.html", + }, + { + "eventCode": "expert_summon_click", "id": e.ExpertID, "name": e.DisplayNameZH, + "expertTitle": e.ProfessionZH, "type": "expert-all", "position": 0, + "expertType": e.ExpertType, "version": ver, "mode": "LOCAL", + }, + { + "eventCode": "expert_summoned", "id": e.ExpertID, "name": e.DisplayNameZH, + "expertTitle": e.ProfessionZH, "type": "expert-all", + }, + } +} + +// DesktopExpertActualUseEvent 构造「专家真实使用」事件(expert_5/Expert_team_use_3 计数)。 +// requestID 必须是 DesktopChatWithExpert 返回的服务端 requestId。 +func DesktopExpertActualUseEvent(e MarketExpert, conversationID, requestID string) DesktopEvent { + ev := desktopExpertActualUse(e, conversationID, requestID) + ev["mode"] = "craft" + return ev +} + +// DesktopExpertActualUseLocal mode:"LOCAL" 变体(Expert_lighthouse 判据要求 LOCAL, +// 对齐真实样本 Sunny row 868:轻量云专家使用时 mode=LOCAL、type 为空、cost=0)。 +func DesktopExpertActualUseLocal(e MarketExpert, conversationID, requestID string) DesktopEvent { + ev := desktopExpertActualUse(e, conversationID, requestID) + ev["mode"] = "LOCAL" + return ev +} + +// desktopExpertActualUse expert_actual_use 公共载荷。 +func desktopExpertActualUse(e MarketExpert, conversationID, requestID string) DesktopEvent { + cat := "expert-all" + if len(e.Categories) > 0 { + if s, ok := e.Categories[0].(string); ok { + cat = s + } + } + ver := e.Version + if ver == "" { + ver = "1.0.0" + } + return DesktopEvent{ + "eventCode": "expert_actual_use", + "id": e.ExpertID, "name": e.DisplayNameZH, "expertTitle": e.ProfessionZH, + "type": cat, "expertType": e.ExpertType, "source": "builtin", "version": ver, + "cost": 9000, "characterCount": 14, + "conversationId": conversationID, "requestId": requestID, "messageId": "msg-" + requestID[len(requestID)-8:], + "requestModelId": "fast-model", "requestModelName": "fast-model", + } +} diff --git a/internal/upstream/desktop_test.go b/internal/upstream/desktop_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8b654685d3db6cf5c09b83bdc0cece182d50b0be --- /dev/null +++ b/internal/upstream/desktop_test.go @@ -0,0 +1,121 @@ +package upstream + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestReportDesktopEventFingerprint 断言桌面指纹上报:走 chatBase、UA 为桌面形状、 +// 事件数组自动注入 workbuddy-desktop 公共指纹(覆盖同名业务键)。 +func TestReportDesktopEventFingerprint(t *testing.T) { + var got []map[string]any + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/report" { + t.Errorf("path=%s want /v2/report", r.URL.Path) + } + gotUA = r.Header.Get("User-Agent") + if r.Header.Get("X-User-Id") != "u-dt" { + t.Errorf("X-User-Id=%q want u-dt", r.Header.Get("X-User-Id")) + } + if r.Header.Get("X-Product") != "SaaS" { + t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) + } + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("body must be array: %v", err) + } + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL} + err := c.ReportDesktopEvent(&auth.Auth{AccessToken: "at", UID: "u-dt", Nickname: "RenJie"}, + DesktopEvent{"eventCode": "agent_task_created", "mode": "craft"}) + if err != nil { + t.Fatalf("report desktop: %v", err) + } + if gotUA != desktopUA { + t.Errorf("UA=%q want %q", gotUA, desktopUA) + } + if len(got) != 1 { + t.Fatalf("events=%d want 1", len(got)) + } + ev := got[0] + if ev["extName"] != "workbuddy-desktop" { + t.Errorf("extName=%v want workbuddy-desktop", ev["extName"]) + } + if ev["ideType"] != "WorkBuddy" || ev["ideName"] != "WorkBuddy" { + t.Errorf("ideType/ideName=%v/%v want WorkBuddy", ev["ideType"], ev["ideName"]) + } + if ev["userId"] != "u-dt" { + t.Errorf("userId=%v want u-dt", ev["userId"]) + } + if ev["userNickname"] != "RenJie" { + t.Errorf("userNickname=%v want RenJie", ev["userNickname"]) + } + if ev["mode"] != "craft" { + t.Errorf("业务字段 mode=%v 被指纹覆盖或缺失", ev["mode"]) + } + // machineId 应为 uid 稳定派生(36 hex)。 + if len(ev["machineId"].(string)) != 36 { + t.Errorf("machineId 长度=%d want 36", len(ev["machineId"].(string))) + } +} + +// TestDesktopChatSequenceShape 断言完整对话事件链的事件码顺序与关键字段。 +func TestDesktopChatSequenceShape(t *testing.T) { + events := DesktopChatSequence("conv-1", "req-1", "msg-1", "fast-model", "fast-model") + wantCodes := []string{ + "agent_task_created", "chat_message_send", "chat_request_send", + "chat_message_response", "chat_message_status", "chat_request_response", + } + if len(events) != len(wantCodes) { + t.Fatalf("events=%d want %d", len(events), len(wantCodes)) + } + for i, want := range wantCodes { + if events[i]["eventCode"] != want { + t.Errorf("events[%d].eventCode=%v want %v", i, events[i]["eventCode"], want) + } + } + resp := events[3] + if resp["isSuccessful"] != true { + t.Errorf("chat_message_response.isSuccessful=%v want true(RichMeow 判据)", resp["isSuccessful"]) + } + if resp["finishReason"] != "stop" { + t.Errorf("finishReason=%v want stop", resp["finishReason"]) + } + if resp["conversationId"] != "conv-1" { + t.Errorf("conversationId=%v want conv-1", resp["conversationId"]) + } +} + +// TestSetAppearanceTheme 断言主题设置端点形状(点亮 Hp_Appearance 的 API)。 +func TestSetAppearanceTheme(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/user-asset/appearance/set" { + t.Errorf("path=%s want /v2/user-asset/appearance/set", r.URL.Path) + } + if r.Header.Get("User-Agent") != desktopUA { + t.Errorf("UA=%q want desktop UA", r.Header.Get("User-Agent")) + } + raw, _ := io.ReadAll(r.Body) + json.Unmarshal(raw, &got) + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL} + if err := c.SetAppearanceTheme(&auth.Auth{AccessToken: "at", UID: "u1"}, "theme-tkmw7j"); err != nil { + t.Fatalf("set theme: %v", err) + } + if got["kind"] != "theme" || got["resource_key"] != "theme-tkmw7j" { + t.Errorf("body=%v want kind=theme resource_key=theme-tkmw7j", got) + } +} diff --git a/internal/upstream/device_token.go b/internal/upstream/device_token.go new file mode 100644 index 0000000000000000000000000000000000000000..fe87421b26912cf09680e7382b593f85cdfa34c7 --- /dev/null +++ b/internal/upstream/device_token.go @@ -0,0 +1,83 @@ +// device_token.go X-Device-Token 的文件兜底读取(与桌面端共用状态文件)。 +// +// 容器内无桌面端 Turing Shield SDK,无法像 Python fork(xiaofan6ya/converter.py)那样 +// 现取 token。这里提供另一条路径:宿主把桌面端生成的 device token 落 /app/data/device_token +// (或任意挂载路径),网关定期读取注入。读取频率限 5 分钟一次缓存,>1KB 或读失败则忽略 +// (优雅降级不注入,不影响主流程)。 +package upstream + +import ( + "errors" + "os" + "strings" + "sync" + "time" +) + +var errDeviceTokenTooLarge = errors.New("device token file too large") + +// deviceTokenFile 文件读取缓存 TTL(秒)。桌面端 SDK 自身也有缓存,这里再兜一层 +// 避免每次出站请求都 stat+read 文件。 +const deviceTokenFileTTL = 5 * time.Minute + +// deviceTokenFileMaxLen token 文件最大字节数。token 通常几百字节;超过 1KB +// 视为异常(非 token 内容 / 文件被误用),忽略不注入。 +const deviceTokenFileMaxLen = 1024 + +// deviceTokenFileCache 缓存 device token 文件读取结果(path → token+读取时刻)。 +type deviceTokenFileCache struct { + mu sync.Mutex + path string + token string + readAt time.Time + lastErr error +} + +var dtFileCache = &deviceTokenFileCache{} + +// readDeviceTokenFile 读取并缓存 device token 文件;5 分钟内复用上次结果。 +// 返回空串表示无可用 token(文件未配置 / 读失败 / 内容过长 / 空白)。 +func readDeviceTokenFile(path string) string { + if path == "" { + return "" + } + // 快路径:缓存命中且未过期,直接返回缓存值。注意全程持锁(defer Unlock)—— + // 含 5 分钟一次的过期重读(锁内读文件)。调用频率极低(每 5min 最多一次 + // 文件 IO,文件上限 1KB),锁内 IO 可接受;若未来出现 NFS 挂载 + 高并发的 + // 部署形态,再上 singleflight 包住重读段(YAGNI,现在不做)。 + dtFileCache.mu.Lock() + defer dtFileCache.mu.Unlock() + if path == dtFileCache.path && time.Since(dtFileCache.readAt) < deviceTokenFileTTL { + return dtFileCache.token + } + // 缓存未命中或过期:重新读文件。 + dtFileCache.path = path + tok, err := readTrimmedFile(path, deviceTokenFileMaxLen) + if err != nil { + // 读失败:清空缓存 token,避免注入过期/错误的值。 + dtFileCache.token = "" + dtFileCache.lastErr = err + dtFileCache.readAt = time.Now() + return "" + } + dtFileCache.token = tok + dtFileCache.lastErr = nil + dtFileCache.readAt = time.Now() + return tok +} + +// readTrimmedFile 读文件并 trim 首尾空白,超过 maxLen 返回错误(拒绝过长内容)。 +func readTrimmedFile(path string, maxLen int) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Size() > int64(maxLen) { + return "", errDeviceTokenTooLarge + } + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/internal/upstream/device_token_test.go b/internal/upstream/device_token_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d906c23e2319ca35114d9e4d65e87f4c4c64ecc7 --- /dev/null +++ b/internal/upstream/device_token_test.go @@ -0,0 +1,180 @@ +// device_token_test.go X-Device-Token 注入 + 文件兜底读取单测。 +package upstream + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestDeviceTokenInjected_WhenSet auth.Auth.DeviceToken 非空时 chat/billing 请求均注入。 +func TestDeviceTokenInjected_WhenSet(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: "tok-from-auth"} + // 用 server 端验证而非 RoundTripper 捕获:更贴近真实注入路径。 + for _, tc := range []struct { + name string + apply func(c *Client, req *http.Request) + wantPath string + }{ + {"chat", func(c *Client, req *http.Request) { c.ChatHeaders(req, a, "", ChatMeta{}) }, "/v2/chat/completions"}, + {"billing", func(c *Client, req *http.Request) { c.BillingHeaders(req, a) }, "/v2/report"}, + } { + t.Run(tc.name, func(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("X-Device-Token") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + c := &Client{ + HTTP: srv.Client(), + ChatHTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+tc.wantPath, nil) + tc.apply(c, req) + resp, err := c.HTTP.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if got != "tok-from-auth" { + t.Errorf("X-Device-Token = %q want %q", got, "tok-from-auth") + } + }) + } +} + +// TestDeviceTokenNotInjected_WhenEmpty 所有来源皆空时不注入该头。 +func TestDeviceTokenNotInjected_WhenEmpty(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} // DeviceToken 空 + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("X-Device-Token") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + c := &Client{ + HTTP: srv.Client(), + ChatHTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + // DeviceToken / DeviceTokenFile 皆空 + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil) + c.ChatHeaders(req, a, "", ChatMeta{}) + resp, err := c.HTTP.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if got != "" { + t.Errorf("X-Device-Token = %q want empty (not injected)", got) + } +} + +// TestDeviceTokenFromConfigOrFile_Overrides 优先级:auth > config > 文件。 +// auth 有值时覆盖 config;auth 空时 config 兜底;auth 与 config 皆空时读文件。 +func TestDeviceTokenFromConfigOrFile_Overrides(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "device_token") + if err := os.WriteFile(fp, []byte("tok-from-file\n"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + cases := []struct { + name string + auth string + cfg string + want string + }{ + {"auth_over_config", "tok-auth", "tok-config", "tok-auth"}, + {"config_when_auth_empty", "", "tok-config", "tok-config"}, + {"file_when_auth_and_config_empty", "", "", "tok-from-file"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 每个用例用独立缓存:device token 文件缓存 5 分钟,case 间会串扰。 + save := resetDeviceTokenFileCache(fp) + defer save() + a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: tc.auth} + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("X-Device-Token") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + c := &Client{ + HTTP: srv.Client(), + ChatHTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + DeviceToken: tc.cfg, + DeviceTokenFile: fp, + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil) + c.ChatHeaders(req, a, "", ChatMeta{}) + resp, err := c.HTTP.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if got != tc.want { + t.Errorf("X-Device-Token = %q want %q", got, tc.want) + } + }) + } +} + +// TestDeviceTokenFileTooLarge 文件超过 1KB 时忽略不注入。 +func TestDeviceTokenFileTooLarge(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "device_token") + if err := os.WriteFile(fp, []byte(strings.Repeat("x", 2048)), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + save := resetDeviceTokenFileCache(fp) + defer save() + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{DeviceTokenFile: fp} + if tok := c.resolveDeviceToken(a); tok != "" { + t.Errorf("resolveDeviceToken() = %q want empty (file too large)", tok) + } +} + +// resetDeviceTokenFileCache 替换全局 device token 文件缓存并返回恢复函数。 +// 文件缓存 5 分钟 TTL,测试间需清空避免串扰。 +func resetDeviceTokenFileCache(path string) (restore func()) { + dtFileCache.mu.Lock() + origPath := dtFileCache.path + origTok := dtFileCache.token + origRead := dtFileCache.readAt + origErr := dtFileCache.lastErr + dtFileCache.path = path + dtFileCache.token = "" + dtFileCache.readAt = time.Time{} + dtFileCache.lastErr = nil + dtFileCache.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + dtFileCache.mu.Lock() + dtFileCache.path = origPath + dtFileCache.token = origTok + dtFileCache.readAt = origRead + dtFileCache.lastErr = origErr + dtFileCache.mu.Unlock() + }) + } +} diff --git a/internal/upstream/effort_catalog.go b/internal/upstream/effort_catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..b814fe176f9a7e10b4eb960701af6eff1bd4f2bc --- /dev/null +++ b/internal/upstream/effort_catalog.go @@ -0,0 +1,145 @@ +// effort_catalog.go 推理档位(reasoning effort)产品级静态兜底表。 +// +// 数据来源三级(吸收参考仓库 reconcileWithFallback/buddy-adapter.ts:499-523 语义): +// - 远端 FetchModels / global 探测已解析的 supportedEfforts/defaultEffort 桶(权威,优先); +// - 本文件按 realm 分开的产品级静态兜底表(远端缺失时补齐); +// - 两者皆无 → 不在 /v1/models 输出 effort 字段(omitted,不是空数组)。 +// +// 档位值照抄参考仓库 dsh-codearts:CN/CodeBuddy 面取 src/product.ts CODEBUDDY_FALLBACK_MODELS +// ∪ src/buddy-adapter.ts:126-137 REASONING_EFFORTS;global/WorkBuddy 面取 src/product.ts +// WORKBUDDY_FALLBACK_MODELS。两个 realm 对同一模型给出不同档位(如 deepseek-v4.1-flash: +// CN 三档 ['low','high','max']、global 仅 ['high']),故**按 realm 分表**,绝不混用。 +// +// 只列**可枚举**档位的模型;仅有固定默认档(glm-5.1/kimi-* 的 medium)同样入表, +// 但其档位是「可枚举单档」而非「无选择器」,照抄参考仓库如实暴露。 +package upstream + +// effortCap 一个模型的档位能力(对齐产品兜底表条目 reasoningEfforts + defaultReasoningEffort)。 +type effortCap struct { + efforts []string + defaultEffort string +} + +// cnEffortFallback CN / CodeBuddy 面静态兜底表。 +// 三档模型 defaultEffort 均为 high(product.ts CODEBUDDY_FALLBACK_MODELS 逐条 defaultReasoningEffort)。 +var cnEffortFallback = map[string]effortCap{ + "deepseek-v4-flash": {efforts: []string{"low", "high", "max"}}, + "deepseek-v4.1-flash": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "deepseek-v4-pro": {efforts: []string{"low", "high", "xhigh"}, defaultEffort: "high"}, + "hy4-preview": {efforts: []string{"high"}, defaultEffort: "high"}, + "hy4-preview-x": {efforts: []string{"high"}}, + "hy3": {efforts: []string{"low", "high"}, defaultEffort: "high"}, + "hy3-x": {efforts: []string{"low", "high"}, defaultEffort: "high"}, + "glm-5.3": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "glm-5.3-flash": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "glm-5.2": {efforts: []string{"high", "xhigh"}, defaultEffort: "high"}, + "glm-5.1": {efforts: []string{"medium"}}, + "glm-5v-turbo": {efforts: []string{"medium"}}, + "kimi-k3-1": {efforts: []string{"medium"}}, + "kimi-k2.7": {efforts: []string{"medium"}}, + "kimi-k2.6": {efforts: []string{"medium"}}, + "minimax-m3": {efforts: []string{"medium"}}, +} + +// globalEffortFallback global / WorkBuddy 国际版面静态兜底表。 +// 注意 deepseek-v4.1-flash 在国际版**只有 ['high']**(product.ts:190 实测 IDE 缓存), +// 与 CN 面的三档刻意不同——往 WorkBuddy 上游发 low/max 是非法参数 400。 +var globalEffortFallback = map[string]effortCap{ + "fast-model": {efforts: []string{"medium"}}, + "balanced-model": {efforts: []string{"medium"}}, + "primary-model": {efforts: []string{"high"}}, + "hy4-preview-f": {efforts: []string{"high"}, defaultEffort: "high"}, + "hy3": {efforts: []string{"low", "high"}, defaultEffort: "high"}, + "deepseek-v4.1-flash": {efforts: []string{"high"}}, + "gpt-6-astra": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.6-sol": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.6-terra": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.6-luna": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.5": {efforts: []string{"low", "medium", "high", "xhigh"}, defaultEffort: "high"}, + "gpt-5.4": {efforts: []string{"low", "medium", "high", "xhigh"}, defaultEffort: "high"}, + "gpt-5.3-codex": {efforts: []string{"medium"}}, + "gemini-3.5-flash": {efforts: []string{"medium"}}, + "glm-5.3": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "glm-5.2": {efforts: []string{"high", "xhigh"}, defaultEffort: "high"}, + "kimi-k3": {efforts: []string{"medium"}}, + "kimi-k2.6": {efforts: []string{"medium"}}, +} + +// staticEffortCap 按 realm 取静态兜底条目;未命中返回 zero effortCap(efforts=nil)。 +// realm 经 realmKey 归一化(空 → "cn"),与 efforts 缓存桶同口径。 +func staticEffortCap(realm, model string) effortCap { + table := cnEffortFallback + if realmKey(realm) == "global" { + table = globalEffortFallback + } + return table[model] +} + +// EffortListing 计算模型在 /v1/models 应暴露的 effort 能力(三级查找 + 默认档防御)。 +// +// remoteEfforts/remoteDefault 为远端(FetchModels / global 探测)已解析值; +// remoteEfforts 非空时以其为权威(不回落到静态表),否则落到产品级静态兜底表; +// 两者皆无 → efforts 返回 nil(调用方省略字段,不输出空数组)。 +// +// defaultEffort 仅在「efforts 非空且 default 命中 efforts」时才返回 +// (对齐参考仓库 resolveModel 的 `defaultEffort ∈ efforts` 防御:不宣称不支持的默认档)。 +// remoteDefault 空串不回落到静态默认档——默认档随档位表同源:remote 有档位就用 remote 默认档, +// 静态兜底档位就用静态默认档,避免跨源拼接出「档位是静态、默认档是 remote」的矛盾组合。 +func EffortListing(realm, model string, remoteEfforts []string, remoteDefault string) (efforts []string, defaultEffort string) { + var src effortCap + switch { + case len(remoteEfforts) > 0: + src = effortCap{efforts: remoteEfforts, defaultEffort: remoteDefault} + default: + src = staticEffortCap(realm, model) + } + if len(src.efforts) == 0 { + return nil, "" + } + efforts = append([]string(nil), src.efforts...) + if src.defaultEffort != "" && containsEffort(efforts, src.defaultEffort) { + defaultEffort = src.defaultEffort + } + return efforts, defaultEffort +} + +// containsEffort 档位成员判定(精确匹配,对齐参考仓库 `efforts.includes(defaultEffort)`)。 +func containsEffort(efforts []string, want string) bool { + for _, e := range efforts { + if e == want { + return true + } + } + return false +} + +// globalEffortMap global 域降级用的 effort 能力表:静态兜底表为基,远端桶覆盖(权威优先)。 +// +// prepareBody 对 global 请求调此函数(而非直接用远端桶),因为 global 上游可能不下发 +// supportedEfforts——此时也必须按产品静态表降级(issue #84:deepseek-v4.1-flash 国际版 +// 只认 high,客户端传 low/max 必降级到 high,否则上游 400 毁掉请求)。 +// 语义对齐参考仓库 effortsFor(remoteMeta → productFallback → 静态表),只取前两级: +// 远端桶(探测已解析)→ 本产品静态表(本文件),缺档位即无(不再到通用静态表)。 +func globalEffortMap(remoteEfforts map[string][]string, remoteDefaults map[string]string) (map[string][]string, map[string]string) { + efforts := make(map[string][]string, len(globalEffortFallback)+len(remoteEfforts)) + defs := make(map[string]string, len(globalEffortFallback)+len(remoteDefaults)) + // 静态兜底为基。 + for id, cap := range globalEffortFallback { + efforts[id] = append([]string(nil), cap.efforts...) + if cap.defaultEffort != "" { + defs[id] = cap.defaultEffort + } + } + // 远端权威覆盖(仅当远端确实下发了该模型档位)。 + for id, v := range remoteEfforts { + if len(v) > 0 { + efforts[id] = v + } + } + for id, v := range remoteDefaults { + if v != "" { + defs[id] = v + } + } + return efforts, defs +} diff --git a/internal/upstream/global_models.go b/internal/upstream/global_models.go new file mode 100644 index 0000000000000000000000000000000000000000..05c2da1fee4593f821ad7578826ec59ca856ce91 --- /dev/null +++ b/internal/upstream/global_models.go @@ -0,0 +1,620 @@ +// global 模型目录探测:纯动态产出模型名及其窗口 / 能力元数据(v3-config-merge)。 +// +// 探测两路并发:/v3/config(主路,IDE UA 完整能力版)+ 企业端点家族 +// (/v2 → /console 补缺),并集 = v3 条目为主、企业端点补 v3 缺失的 id +// (如 gpt-5.3-codex 只在 /v2 下发)。倍率字段(credits)虽随目录下发,但 +// 只透出展示,不注入 costTier、不参与选号。 +// +// 纯动态:不再回落任何静态名单——拉不出目录即意味着该域上游不可用, +// 假名单只会让客户端选到 11102 的模型(产品决策:无兜底)。 +package upstream + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// GlobalModelNames 国际版(global realm)历史静态名单(PLAN §7.2 附录 21 名)。 +// 纯动态化后**不再作为模型目录的基底/兜底**:/v1/models 只透出上游实际下发的模型。 +// 保留仅作历史对照(global e2e 观测日志差集参照)。 +var GlobalModelNames = []string{ + "default-model", + "fast-model", + "balanced-model", + "primary-model", + "hy4-preview", + "gpt-5.6-sol", + "gpt-5.6-terra", + "deep-model", + "deepseek-v4.1-flash", + "gpt-6-astra", + "hy4-preview-f", + "hy3", + "glm-5.2", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "gemini-3.5-flash", + "glm-5.3", + "kimi-k3", + "kimi-k2.6", +} + +// fetchGlobalModelsCache 探测结果缓存(语义参照 CN 侧 handler.dynamicModelsCache:1h TTL + +// 5min 失败负缓存)。按 Client 实例持有(effortsMu 同模式),测试新建 Client 即隔离。 +// Mutex 内嵌,与 modelList 无并发读路径竞争(唯一读写点本文件内)。 +type fetchGlobalModelsCache struct { + sync.Mutex + names []string // 成功缓存:并集模型名(已去重);nil = 未探测/失败 + infos []ModelInfo // 成功缓存:对象形态的全字段条目(窄表/失败形态为 nil) + fetched time.Time + lastFail time.Time +} + +// globalModelsTTL / globalModelsFailCooldown 探测缓存时长:成功 1h,失败 5min 负缓存。 +const ( + globalModelsTTL = time.Hour + globalModelsFailCooldown = 5 * time.Minute +) + +// globalModelsProbePaths global 企业模型目录端点候选序列(按 realm 切 base,路径"家族"): +// /v2 家族优先(实测 /v2/enterprises/personal/models 200 含完整模型表), +// /console 作 fallback(同域旧路径,或 500)。v3-config-merge 后该家族降为企业补充路 +// (/v3/config 为主路,与家族并发探测;gpt-5.3-codex 等家族独有模型经此进并集)。 +var globalModelsProbePaths = []string{ + "/v2/enterprises/personal/models", + "/console/enterprises/personal/models", +} + +// FetchGlobalModels 探测 global 账号的模型名目录并返回**模型名列表**(无元数据)。 +// +// 纯动态:成功返回并集结果(去重),缓存 1h;失败(两路全非 2xx / 解析失败 / +// 空列表)记 5min 负缓存,返回 nil(无静态回落)。缓存/负缓存命中:直接返回,零上游调用。 +// +// 调用方负责:仅在有 global 账号时调用(无则不探测);GlobalEnabled 关闭时(逃生门) +// 不得调用——本方法由 globalOn(a) 内部兜底,若账号因开关回落 cn 则返回 nil。 +func (c *Client) FetchGlobalModels(a *auth.Auth) []string { + names, _ := c.fetchGlobalModelsOnce(a) + return names +} + +// FetchGlobalModelInfos 探测 global 账号的模型目录并返回全字段 ModelInfo 列表。 +// 与 FetchGlobalModels 共享同一次探测与缓存(names + infos 一体落缓存): +// 对象形态 200 → 全字段条目;窄表形态 / 探测失败 / 负缓存 / 非 global 路由账号 +// → nil(调用方按 ID 名单输出裸条目,不编造字段)。 +// 账号因 GlobalEnabled 开关回落 cn 时不探测(globalOn 兜底,零上游调用)。 +func (c *Client) FetchGlobalModelInfos(a *auth.Auth) []ModelInfo { + _, infos := c.fetchGlobalModelsOnce(a) + return infos +} + +// fetchGlobalModelsOnce 单次探测决策(缓存命中/负缓存/触发探测),返回 (names, infos)。 +// 纯动态:成功 = 并集结果去重;一切失败 = nil(不回落静态)。 +// infos 仅对象形态成功探测时非 nil。 +func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []ModelInfo) { + if !c.globalOn(a) { + // 逃生门兜底:账号不路由 global 上游 → 不探测(零上游调用)。 + return nil, nil + } + + c.globalModels.Lock() + if len(c.globalModels.names) > 0 && time.Since(c.globalModels.fetched) < globalModelsTTL { + names, infos := c.globalModels.names, c.globalModels.infos + c.globalModels.Unlock() + return names, infos + } + if !c.globalModels.lastFail.IsZero() && time.Since(c.globalModels.lastFail) < globalModelsFailCooldown { + // 负缓存冷却期内:避免反复打上游,直接按失败处理(无静态回落)。 + c.globalModels.Unlock() + return nil, nil + } + c.globalModels.Unlock() + + names, infos, efforts, defaults, err := c.probeGlobalModels(a) + if err != nil || len(names) == 0 { + // 探测失败:负缓存 + 返回 nil(effort 桶不写,prepareBody 走 globalEffortMap 静态兜底)。 + c.globalModels.Lock() + c.globalModels.lastFail = time.Now() + c.globalModels.names = nil + c.globalModels.infos = nil + c.globalModels.Unlock() + return nil, nil + } + // global 域 effort 能力:探测下发的 supportedEfforts/defaultEffort 权威写入 global 桶 + // (raw remote,不并入静态表——静态兜底在 prepareBody 的 globalEffortMap 与 + // /v1/models 的 EffortListing 里按需 fallback)。空探测不写(防清既有桶)。 + if len(efforts) > 0 || len(defaults) > 0 { + c.storeEfforts("global", efforts, defaults) + } + c.storeModelRates("global", infos) + + // 成功:探测结果去重。names/infos 均落缓存;倍率等选号敏感字段只透出展示, + // 不注入 costTier。 + seen := make(map[string]bool, len(names)) + merged := make([]string, 0, len(names)) + for _, id := range names { + if id == "" || seen[id] { + continue + } + seen[id] = true + merged = append(merged, id) + } + + c.globalModels.Lock() + c.globalModels.names = merged + c.globalModels.infos = infos + c.globalModels.fetched = time.Now() + c.globalModels.lastFail = time.Time{} + c.globalModels.Unlock() + return merged, infos +} + +// probeGlobalModels 发起一次 global 模型目录探测(v3-config-merge): +// /v3/config(主,IDE UA 完整能力版)与企业端点家族(/v2 → /console 兜底,补缺) +// **并发**探测后并集合并。返回模型名列表(已合并、未再去重——去重在 +// fetchGlobalModelsOnce)、全字段 ModelInfo(对象形态;窄表为 nil)及 effort +// 能力桶(supportedEfforts/defaultEffort,可为空)。合并口径:v3 条目为主 +// (credits 等字段以 v3 为准),企业端点只补 v3 缺失的模型 id;去重 key = +// 模型 id,输出顺序稳定。两路全失败才返回错误(等价原「家族端点全非 2xx」 +// 负缓存语义);单路失败降级为另一路结果 + warn 日志,互不拖累。 +func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { + type probeResult struct { + names []string + infos []ModelInfo + err error + } + // probeV3 单次 /v3/config 探测(UA 参数化)。该端点对不同 UA 下发**不同模型集合**: + // IDE UA 与 CLI UA 各有独有模型(见 codeBuddyCLIUA 注释),故并发两路取并集。 + probeV3 := func(ua string) chan probeResult { + ch := make(chan probeResult, 1) + go func() { + // chatBase 已按 realm 切 global base。 + byID, perr := c.fetchV3ConfigModelMap(a, ua) + if perr != nil { + ch <- probeResult{err: perr} + return + } + ids := make([]string, 0, len(byID)) + outInfos := make([]ModelInfo, 0, len(byID)) + for _, mi := range byID { + if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) { + continue + } + ids = append(ids, mi.ID) + outInfos = append(outInfos, mi) + } + sort.Strings(ids) // map 迭代序随机,排序保输出稳定 + ch <- probeResult{names: ids, infos: outInfos} + }() + return ch + } + v3IDECh := probeV3(codeBuddyIDEUA) + v3CLICh := probeV3(codeBuddyCLIUA) + enterpriseCh := make(chan probeResult, 1) + go func() { + // 企业端点家族:/v2 首选 → /console 兜底(既有探活序,零回归)。 + var lastErr error + for _, path := range globalModelsProbePaths { + names, infos, perr := c.globalModelsOnce(a, path) + if perr != nil { + lastErr = perr + continue + } + enterpriseCh <- probeResult{names: names, infos: infos} + return + } + enterpriseCh <- probeResult{err: lastErr} + }() + v3IDE := <-v3IDECh + v3CLI := <-v3CLICh + enterprise := <-enterpriseCh + // v3 两路自合并:IDE 路字段权威(响应更大、单条字段更全),CLI 路只补缺失的模型 id。 + // 单路成功即用该路;两路全失败才带 err 进入下游降级判断。 + var v3 probeResult + switch { + case v3IDE.err != nil && v3CLI.err != nil: + v3 = probeResult{err: v3IDE.err} + case v3IDE.err != nil: + log.Printf("WARN: [upstream] global models: v3/config IDE-UA probe failed (CLI-UA only): %v", v3IDE.err) + v3 = v3CLI + case v3CLI.err != nil: + log.Printf("WARN: [upstream] global models: v3/config CLI-UA probe failed (IDE-UA only): %v", v3CLI.err) + v3 = v3IDE + default: + vn, vi := mergeGlobalCatalog(v3IDE.names, v3IDE.infos, v3CLI.names, v3CLI.infos) + v3 = probeResult{names: vn, infos: vi} + } + + if v3.err != nil && enterprise.err != nil { + // 两路全失败 → 负缓存语义(等价原家族端点全非 2xx)。 + return nil, nil, nil, nil, v3.err + } + if v3.err != nil { + // /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。 + log.Printf("WARN: [upstream] global models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err) + names, infos, efforts, defaults = extractEfforts(enterprise.infos) + return names, infos, efforts, defaults, nil + } + if enterprise.err != nil { + log.Printf("WARN: [upstream] global models: enterprise endpoint failed (v3/config only): %v", enterprise.err) + names, infos, efforts, defaults = extractEfforts(v3.infos) + return names, infos, efforts, defaults, nil + } + // 两路皆成功:v3 为主、企业端点补缺合并(含 effort 桶合并,v3 权威)。 + v3Names, v3Infos, v3Efforts, v3Defaults := extractEfforts(v3.infos) + if len(v3Names) == 0 { + v3Names = v3.names + } + entNames, entInfos, entEfforts, entDefaults := extractEfforts(enterprise.infos) + if len(entNames) == 0 { + entNames = enterprise.names + } + names, infos = mergeGlobalCatalog(v3Names, v3Infos, entNames, entInfos) + efforts = mergeEffortBuckets(v3Efforts, entEfforts) + defaults = mergeEffortDefaults(v3Defaults, entDefaults) + return names, infos, efforts, defaults, nil +} + +// extractEfforts 从条目列表抽取 effort 能力桶(supportedEfforts 数组优先; +// 缺数组但 defaultEffort 单档非空也入 defaults 桶)并顺带返回有序 names。 +func extractEfforts(infos []ModelInfo) (names []string, out []ModelInfo, efforts map[string][]string, defaults map[string]string) { + names = make([]string, 0, len(infos)) + for _, mi := range infos { + if mi.ID == "" { + continue + } + names = append(names, mi.ID) + out = append(out, mi) + if len(mi.Efforts) > 0 { + if efforts == nil { + efforts = make(map[string][]string) + } + efforts[mi.ID] = mi.Efforts + } + if mi.DefaultEffort != "" { + if defaults == nil { + defaults = make(map[string]string) + } + defaults[mi.ID] = mi.DefaultEffort + } + } + return names, out, efforts, defaults +} + +// mergeGlobalCatalog 两路合并(v3 主、企业补缺):names 按 id 去重(v3 原序在前、 +// 企业端点补充项在其原序后追加——稳定输出);infos 同步合并(v3 条目字段权威, +// 企业端点条目只在 id 缺失时进并集)。 +// 窄表形态(infos nil)时保持 nil——无对象字段不编造。 +func mergeGlobalCatalog(primaryNames []string, primaryInfos []ModelInfo, secondaryNames []string, secondaryInfos []ModelInfo) (names []string, infos []ModelInfo) { + if len(secondaryNames) == 0 { + return primaryNames, primaryInfos + } + seen := make(map[string]bool, len(primaryNames)+len(secondaryNames)) + out := make([]string, 0, len(primaryNames)+len(secondaryNames)) + for _, id := range primaryNames { + if id == "" || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + var outInfos []ModelInfo + if primaryInfos != nil { + outInfos = make([]ModelInfo, 0, len(primaryInfos)+len(secondaryInfos)) + outInfos = append(outInfos, primaryInfos...) + } + for _, id := range secondaryNames { + if id == "" || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + // 窄表企业响应(secondaryInfos nil / 超出条目数)时该 id 无对象字段, + // infos 保持原样(调用方按 id 名单输出裸条目,不编造字段)。 + for _, mi := range secondaryInfos { + if mi.ID == id { + outInfos = append(outInfos, mi) + break + } + } + } + return out, outInfos +} + +// mergeEffortBuckets 合并两路 effort 桶:主路(v3)权威,企业端点只补主路缺失的模型档位。 +func mergeEffortBuckets(primary, secondary map[string][]string) map[string][]string { + if len(secondary) == 0 { + return primary + } + out := primary + if out == nil { + out = make(map[string][]string, len(secondary)) + } + for id, v := range secondary { + if _, ok := out[id]; !ok { + out[id] = v + } + } + return out +} + +// mergeEffortDefaults 合并两路 defaultEffort:主路(v3)权威,企业端点只补缺失。 +func mergeEffortDefaults(primary, secondary map[string]string) map[string]string { + if len(secondary) == 0 { + return primary + } + out := primary + if out == nil { + out = make(map[string]string, len(secondary)) + } + for id, v := range secondary { + if _, ok := out[id]; !ok { + out[id] = v + } + } + return out +} + +// globalModelsOnce 单端点探测。2xx + 解析出非空名单 → (names, infos, nil);否则 (nil, nil, err)。 +func (c *Client) globalModelsOnce(a *auth.Auth, path string) ([]string, []ModelInfo, error) { + url := c.chatBase(a) + path // 按 realm 切 base:global 账号 → global base + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + c.CommonHeaders(req, a) // 共享请求头(Origin/Referer/UA),与 FetchModels 同款 + // AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。 + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + // 读失败 → 传输层错误:半截 body 不进解析(探测负缓存走 lastFail,不罚号)。 + return nil, nil, fmt.Errorf("read body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("global models status %d: %s", resp.StatusCode, truncate(string(raw), 120)) + } + names, infos, _, _, err := parseGlobalModelNames(raw) + return names, infos, err +} + +// parseGlobalModelNames 多信封兼容解析模型目录(国际站曾在多种 envelope 间切换, +// 只认单一形态会把登录成功的账号误判为"无模型"): +// - 信封:code 存在且非 0 才拒(无 code 字段也放行);payload = data(非空非 +// null 时)否则整包; +// - 模型数组定位:payload 本身是数组,或是 map 下 models/items/list/data/result +// 任一键(递归一层层往下找第一个非空数组)——覆盖 data.models / data.items / +// data.list / 顶层 models 等变体; +// - 数组主形态:对象数组按 dynModelEntry 全字段解析(与 CN 目录同构,字段零 +// 漂移):maxInputTokens/maxOutputTokens/maxAllowedSize/supportsReasoning/ +// supportsImages/reasoning.*;id 缺省时回退 name;disabled 剔除; +// - 窄表:字符串数组 → 仅 ID,元数据留空(窗口由调用方四级查找链兜底); +// - 动态兜底:主形态解析不出任何可用条目时(上游换了键名),逐对象宽松解析—— +// id 依次回退 id/modelId/model/name,窗口键回退 contextWindow/maxTokens, +// reasoning 档位(defaultEffort 新键优先、effort 老键兜底)保留。 +// +// 同时产出 effort 能力桶(supportedEfforts/defaultEffort)。 +// 解析成功但名单为空 → 返回错误(等价"该端点没给全")。 +func parseGlobalModelNames(raw []byte) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { + fail := func(e error) ([]string, []ModelInfo, map[string][]string, map[string]string, error) { + return nil, nil, nil, nil, e + } + // 信封层:code 拒绝非 0 业务码(字段缺失 = 放行,兼容无信封直出的目录端点)。 + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err != nil { + return fail(fmt.Errorf("global models parse: %w", err)) + } + if codeRaw, ok := envelope["code"]; ok { + var code int + if json.Unmarshal(codeRaw, &code) == nil && code != 0 { + return fail(fmt.Errorf("global models code=%d", code)) + } + } + payload := json.RawMessage(raw) + if data, ok := envelope["data"]; ok { + if t := strings.TrimSpace(string(data)); t != "" && t != "null" { + payload = data + } + } + arr, ok := resolveGlobalModelsArray(payload) + if !ok { + return fail(fmt.Errorf("global models empty list")) + } + + // 主形态:对象数组 → dynModelEntry 全字段(与 CN FetchModels 共用解析,零口径漂移)。 + var entries []dynModelEntry + if json.Unmarshal(arr, &entries) == nil { + out := make([]string, 0, len(entries)) + objInfos := make([]ModelInfo, 0, len(entries)) + for _, m := range entries { + // id 回退链 id→modelId→model→name(宽松键仅出现在 global 目录变体里)。 + id := m.ID + if id == "" { + id = m.ModelID + } + if id == "" { + id = m.Model + } + if id == "" { + id = m.Name + } + if id == "" || m.Disabled { + continue + } + out = append(out, id) + mi := m.modelInfo() + mi.ID = id // name 兜底形态下 id 取自 name,对齐 names 输出 + objInfos = append(objInfos, mi) + if len(m.Reasoning.SupportedEfforts) > 0 { + if efforts == nil { + efforts = make(map[string][]string) + } + efforts[id] = m.Reasoning.SupportedEfforts + } + if d := m.Reasoning.DefaultEffort; d != "" { + if defaults == nil { + defaults = make(map[string]string) + } + defaults[id] = d + } + } + if len(out) > 0 { + return out, objInfos, efforts, defaults, nil + } + // 可用条目为 0(键名全对不上):落入下方动态兜底,不在这里报空。 + efforts, defaults = nil, nil + } + + // 窄表:字符串数组 → 仅 ID(无 effort 元数据、无对象字段 → infos nil)。 + var strs []string + if json.Unmarshal(arr, &strs) == nil { + out := make([]string, 0, len(strs)) + for _, id := range strs { + if id = strings.TrimSpace(id); id != "" { + out = append(out, id) + } + } + if len(out) > 0 { + return out, nil, nil, nil, nil + } + return fail(fmt.Errorf("global models empty list")) + } + + // 动态兜底:逐对象宽松解析(上游换键名时不至于整域空列表)。 + var items []any + if json.Unmarshal(arr, &items) != nil { + return fail(fmt.Errorf("global models parse: unsupported payload shape")) + } + out := make([]string, 0, len(items)) + infos = make([]ModelInfo, 0, len(items)) + for _, item := range items { + switch v := item.(type) { + case string: // 混合数组里的裸 ID:按窄表条目处理 + if id := strings.TrimSpace(v); id != "" { + out = append(out, id) + infos = append(infos, ModelInfo{ID: id}) + } + case map[string]any: + mi, ok := parseGlobalModelLoose(v) + if !ok { + continue + } + out = append(out, mi.ID) + infos = append(infos, mi) + if len(mi.Efforts) > 0 { + if efforts == nil { + efforts = make(map[string][]string) + } + efforts[mi.ID] = mi.Efforts + } + if mi.DefaultEffort != "" { + if defaults == nil { + defaults = make(map[string]string) + } + defaults[mi.ID] = mi.DefaultEffort + } + } + } + if len(out) == 0 { + return fail(fmt.Errorf("global models empty list")) + } + return out, infos, efforts, defaults, nil +} + +// resolveGlobalModelsArray 从 payload 定位模型数组:payload 本身是数组直接用; +// 是 map 则依次尝试 models/items/list/data/result 键(递归下钻,取第一个能解析出 +// 数组的分支)。找不到数组 → false。 +func resolveGlobalModelsArray(payload json.RawMessage) (json.RawMessage, bool) { + trimmed := strings.TrimSpace(string(payload)) + if strings.HasPrefix(trimmed, "[") { + return payload, true + } + var obj map[string]json.RawMessage + if json.Unmarshal(payload, &obj) != nil { + return nil, false + } + for _, key := range []string{"models", "items", "list", "data", "result"} { + if nested, ok := obj[key]; ok { + if arr, ok2 := resolveGlobalModelsArray(nested); ok2 { + return arr, true + } + } + } + return nil, false +} + +// parseGlobalModelLoose 单模型对象的宽松解析(多信封兜底路径):id 依次回退 +// id/modelId/model/name;窗口/上限键回退 contextWindow/maxTokens;reasoning 档位 +// defaultEffort 新键优先、effort 老键兜底。disabled 剔除。Credits 恒不解析 +// (PLAN §3.D2:倍率不进 global 路径)。 +func parseGlobalModelLoose(obj map[string]any) (ModelInfo, bool) { + str := func(key string) string { + s, _ := obj[key].(string) + return strings.TrimSpace(s) + } + id := str("id") + for _, k := range []string{"modelId", "model", "name"} { + if id != "" { + break + } + id = str(k) + } + if id == "" { + return ModelInfo{}, false + } + if disabled, ok := obj["disabled"].(bool); ok && disabled { + return ModelInfo{}, false + } + num := func(keys ...string) int64 { + for _, k := range keys { + if n, ok := obj[k].(float64); ok && n > 0 { + return int64(n) + } + } + return 0 + } + mi := ModelInfo{ + ID: id, + Name: str("name"), + ContextWindow: num("maxInputTokens", "contextWindow"), + MaxTokens: num("maxOutputTokens", "maxTokens"), + MaxAllowedSize: num("maxAllowedSize"), + } + mi.SupportsReasoning, _ = obj["supportsReasoning"].(bool) + mi.SupportsImages, _ = obj["supportsImages"].(bool) + if r, ok := obj["reasoning"].(map[string]any); ok { + reasonStr := func(key string) string { + s, _ := r[key].(string) + return strings.TrimSpace(s) + } + mi.DefaultEffort = reasonStr("defaultEffort") + if mi.DefaultEffort == "" { + mi.DefaultEffort = reasonStr("effort") // 老模型键兜底,与 CN 侧同款 + } + mi.CanDisableThinking, _ = r["canDisableThinking"].(bool) + if arr, ok := r["supportedEfforts"].([]any); ok { + for _, item := range arr { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + mi.Efforts = append(mi.Efforts, s) + } + } + } + } + } + return mi, true +} diff --git a/internal/upstream/global_register.go b/internal/upstream/global_register.go new file mode 100644 index 0000000000000000000000000000000000000000..0bf2373d5dce6e2948a25bc2d911585ddf984992 --- /dev/null +++ b/internal/upstream/global_register.go @@ -0,0 +1,232 @@ +// global_register.go 国际版(global realm)新账号注册激活与地区完善。 +// +// 背景(ANALYSIS-workbuddy-client-reverse.md):新 global 账号需先完成注册地区 +// (/login/register/user/complete 补地区)再调 register 接口激活 Trial,chat 才不报 +// 14017 trial not activated。链路(逆向自 web 注册完善页 RegisterRegion-*.js): +// +// POST /billing/area/get-country-code {filterForbidden:1} → 可取国家列表 +// POST /billing/area/get-user-area-info {action:getUserAreaInfo} → 检测当前地区 +// POST /console/login/account {attributes:{countryCode,countryFullName,countryName}} → 提交地区(幂等) +// GET /auth/realms/copilot/overseas/user/register?userId= → 注册激活(code:200 成功;code:500 "region required" 需补地区) +// POST /billing/ide/trial → 一次性加油包(幂等码 14051,见 trial.go) +// +// 响应体注意:get-country-code / get-user-area-info 的 data 是 JSON 字符串 +// (双层信封),需二次解析。 +package upstream + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// globalWebUA 国际版 web 端 UA(注册完善页走 web 指纹,非桌面端 CLI 指纹)。 +const globalWebUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +// GlobalCountry 可选注册地区(对应 get-country-code 的 list 元素)。 +type GlobalCountry struct { + EnName string `json:"EnName"` // 英文全名(countryFullName) + Name string `json:"Name"` // 显示名 + IOS2 string `json:"IOS2"` // 二字码(countryName) + IOS3 string `json:"IOS3"` + Code string `json:"Code"` // 数字码(countryCode) +} + +// globalRegisterBase 注册激活端点的 base(注册链路在 www.workbuddy.ai,与 globalBillingBase 同域)。 +// 独立成方法便于测试替换。 +func (c *Client) globalRegisterBase() string { + if c.BillingBaseGlobal != "" { + return c.BillingBaseGlobal + } + return defaultGlobalBase +} + +// globalRegisterReq 注册链路通用请求构造:web 指纹 UA + Origin/Referer 同域 + Bearer。 +func (c *Client) globalRegisterReq(method, url, token string, body any) (*http.Request, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + return nil, err + } + base := c.globalRegisterBase() + req.Header.Set("User-Agent", globalWebUA) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", base) + req.Header.Set("Referer", base+"/") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, nil +} + +// globalRegisterJSON 发注册链路请求并解外层信封(code/msg)。 +func (c *Client) globalRegisterJSON(req *http.Request) (code int, msg string, raw json.RawMessage, err error) { + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, "", nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &env); err != nil { + return 0, "", nil, fmt.Errorf("global register parse: %w", err) + } + return env.Code, env.Msg, env.Data, nil +} + +// GlobalFetchCountries 拉取可选注册地区列表(global 账号登录后调用)。 +// intlOnly=true 时按国际版 web 白名单过滤(HK/MO/SG/TH/PH/MY/ID,对齐 web 展示集)。 +func (c *Client) GlobalFetchCountries(a *auth.Auth, intlOnly bool) ([]GlobalCountry, error) { + if a == nil || a.Realm() != "global" { + return nil, fmt.Errorf("fetch countries: only global accounts") + } + req, err := c.globalRegisterReq(http.MethodPost, c.globalRegisterBase()+"/billing/area/get-country-code", a.AccessTokenValue(), map[string]any{"filterForbidden": 1}) + if err != nil { + return nil, err + } + code, msg, raw, err := c.globalRegisterJSON(req) + if err != nil { + return nil, err + } + if code != 0 { + return nil, fmt.Errorf("get-country-code: %s (code=%d)", msg, code) + } + // data 是 JSON 字符串(双层信封)或对象,需二次解析。 + var inner struct { + Data struct { + List []GlobalCountry `json:"list"` + } `json:"data"` + } + if s := strings.TrimSpace(string(raw)); strings.HasPrefix(s, "\"") { + var s2 string + if err := json.Unmarshal(raw, &s2); err != nil { + return nil, fmt.Errorf("country list unwrap: %w", err) + } + raw = json.RawMessage(s2) + } + if err := json.Unmarshal(raw, &inner); err != nil { + return nil, fmt.Errorf("country list parse: %w", err) + } + list := inner.Data.List + if !intlOnly { + return list, nil + } + // 国际版 web 白名单过滤(HK, MO, SG, TH, PH, MY, ID,顺序对齐 web 展示)。 + whitelist := []string{"HK", "MO", "SG", "TH", "PH", "MY", "ID"} + byCode := make(map[string]GlobalCountry, len(list)) + for _, ctry := range list { + byCode[ctry.IOS2] = ctry + } + out := make([]GlobalCountry, 0, len(whitelist)) + for _, code := range whitelist { + if ctry, ok := byCode[code]; ok { + out = append(out, ctry) + } + } + return out, nil +} + +// GlobalRegisterStatus 报告 global 账号注册激活状态:是否需要补地区、是否已激活。 +func (c *Client) GlobalRegisterStatus(a *auth.Auth) (activated bool, needsRegion bool, msg string, err error) { + if a == nil || a.Realm() != "global" { + return false, false, "", fmt.Errorf("register status: only global accounts") + } + req, err := c.globalRegisterReq(http.MethodGet, + c.globalRegisterBase()+"/auth/realms/copilot/overseas/user/register?userId="+a.UID, + a.AccessTokenValue(), nil) + if err != nil { + return false, false, "", err + } + req.Header.Set("X-User-Id", a.UID) + code, m, _, err := c.globalRegisterJSON(req) + if err != nil { + return false, false, "", err + } + switch { + case code == 200: + return true, false, "register success", nil + case code == 500 || strings.Contains(strings.ToLower(m), "region required"): + return false, true, m, nil + default: + return false, false, m, nil + } +} + +// GlobalSubmitRegion 提交注册地区(幂等)。country 来自 GlobalFetchCountries。 +func (c *Client) GlobalSubmitRegion(a *auth.Auth, country GlobalCountry) error { + if a == nil || a.Realm() != "global" { + return fmt.Errorf("submit region: only global accounts") + } + attrs := map[string]any{ + "countryCode": []string{country.Code}, + "countryFullName": []string{country.EnName}, + "countryName": []string{country.IOS2}, + } + req, err := c.globalRegisterReq(http.MethodPost, c.globalRegisterBase()+"/console/login/account", + a.AccessTokenValue(), map[string]any{"attributes": attrs}) + if err != nil { + return err + } + code, msg, _, err := c.globalRegisterJSON(req) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("submit region: %s (code=%d)", msg, code) + } + return nil +} + +// GlobalCompleteRegistration 一键注册激活:先查状态,需补地区则按默认地区(白名单首个, +// 通常 HK)提交后重新激活。activated 表示调用后账号已激活可用。幂等(已激活直接返回)。 +// 失败不阻断调用方(panel login 已落盘),仅返回错误供日志。 +func (c *Client) GlobalCompleteRegistration(a *auth.Auth) (activated bool, err error) { + activated, needsRegion, msg, err := c.GlobalRegisterStatus(a) + if err != nil { + return false, err + } + if activated { + return true, nil + } + if !needsRegion { + return false, fmt.Errorf("register not activated: %s", msg) + } + // 需补地区:拉白名单,取首个(HK)提交。 + countries, err := c.GlobalFetchCountries(a, true) + if err != nil { + return false, fmt.Errorf("fetch countries: %w", err) + } + if len(countries) == 0 { + return false, fmt.Errorf("no countries available") + } + if err := c.GlobalSubmitRegion(a, countries[0]); err != nil { + return false, fmt.Errorf("submit region: %w", err) + } + // 重新激活验证。 + activated, needsRegion, msg, err = c.GlobalRegisterStatus(a) + if err != nil { + return false, err + } + if !activated { + return false, fmt.Errorf("register still not activated after region submit: %s", msg) + } + return true, nil +} diff --git a/internal/upstream/headers.go b/internal/upstream/headers.go new file mode 100644 index 0000000000000000000000000000000000000000..a121a8ea86f631d7b6f984c35c51359b034715f1 --- /dev/null +++ b/internal/upstream/headers.go @@ -0,0 +1,411 @@ +// Package headers 构造三类上游请求头(common / chat / billing / refresh)。 +// 规则来自 docs/api-reference.md §0/§4/§6。 +package upstream + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/session" +) + +const ( + // defaultClientVersion 出站 WorkBuddy 客户端版本段(UA 的 `WorkBuddy/` 与 + // 白名单头组的 X-IDE-Version)。对齐官方 WorkBuddy Desktop 分发包版本(5.5.4)。 + // config upstream.client_version 可覆盖(空 = 内置默认)。 + defaultClientVersion = "5.5.4" + // defaultCliVersion 出站 UA 中 `CLI/` 段版本。对齐官方内置 CLI(2.137.1)。 + // config upstream.cli_version 可覆盖(空 = 内置默认)。 + defaultCliVersion = "2.137.1" + + originRefererCN = "https://www.codebuddy.cn" + originRefererGlobal = "https://www.workbuddy.ai" +) + +// originRefererFor 按账号 realm 返回 Origin/Referer 基础域: +// global → https://www.workbuddy.ai;cn(含全局开关未开)→ https://www.codebuddy.cn。 +func originRefererFor(a *auth.Auth) string { + if a != nil && a.IsGlobal() { + return originRefererGlobal + } + return originRefererCN +} + +// clientVersion 生效的 WorkBuddy 客户端版本:Client.ClientVersion 非空则取之, +// 否则内置默认 defaultClientVersion。 +func (c *Client) clientVersion() string { + if c != nil && c.ClientVersion != "" { + return c.ClientVersion + } + return defaultClientVersion +} + +// cliVersion 生效的 CLI 版本:Client.CliVersion 非空则取之,否则内置默认 defaultCliVersion。 +func (c *Client) cliVersion() string { + if c != nil && c.CliVersion != "" { + return c.CliVersion + } + return defaultCliVersion +} + +// defaultWorkBuddyUAFor 组装默认客户端出站 UA(官方桌面端 RestOperations 层形状): +// `WorkBuddy/ / CLI/`。 +// 平台段(第二段)品牌按 realm 切换——CN 用 applicationName 同值 `WorkBuddy`, +// global 用官方国际版 productName `WorkBuddy AI`(intl 项目逆向证据: +// `WorkBuddy/5.5.2 WorkBuddy AI/5.5.2 CLI/5.5.2`)。 +// global 账号送错平台段(`WorkBuddy` 非 `WorkBuddy AI`)可能触发上游 403 code 11140 +// "request illegal" 风控。官方无任何 UA 随机化,故默认确定性。 +func (c *Client) defaultWorkBuddyUAFor(a *auth.Auth) string { + platform := "WorkBuddy" + if a != nil && a.IsGlobal() { + platform = "WorkBuddy AI" + } + return "WorkBuddy/" + c.clientVersion() + " " + platform + "/" + c.clientVersion() + " CLI/" + c.cliVersion() +} + +// defaultWorkBuddyUA 返回 CN 形态的默认 UA(默认账号形态即 CN,零回归兼容既有调用/测试)。 +func (c *Client) defaultWorkBuddyUA() string { + return c.defaultWorkBuddyUAFor(nil) +} + +// userAgent 返回当前出站 UA(客户端出站路径:chat/refresh/FetchModels)。 +// 优先级:Client.UserAgent(config user_agent)显式覆盖 > 按账号 realm 的默认 WorkBuddy 三段式。 +// 显式覆盖兼容既有覆盖逻辑:用户配了即以用户值为准(自定义品牌/版本), +// 未配则走官方桌面端默认形态(global 换 `WorkBuddy AI` 平台段)。 +func (c *Client) userAgent(a *auth.Auth) string { + if c != nil && c.UserAgent != "" { + return c.UserAgent + } + return c.defaultWorkBuddyUAFor(a) +} + +// billingUA 白名单类(billing/checkin/banner)出站 UA:单段 `WorkBuddy/` +// (官方 banner 显式覆写形态,不带 CLI 段)。默认生效(伪造官方桌面端指纹); +// 显式 client_name="SaaS" 才不设 UA(还原旧行为,Go 默认 UA)。 +func (c *Client) billingUA() string { + if c == nil || c.attributionClientName() == "SaaS" { + return "" + } + return "WorkBuddy/" + c.clientVersion() +} + +// resolveDeviceToken 解析本次请求的 X-Device-Token 取值。 +// 优先级:auth.Auth.DeviceToken(每号)> Client.DeviceToken(config 全局)> 文件兜底。 +// 三者皆空/读失败则返回空串(调用方不注入该头,优雅降级)。 +func (c *Client) resolveDeviceToken(a *auth.Auth) string { + if a != nil && a.DeviceToken != "" { + return a.DeviceToken + } + if c != nil && c.DeviceToken != "" { + return c.DeviceToken + } + if c != nil && c.DeviceTokenFile != "" { + return readDeviceTokenFile(c.DeviceTokenFile) + } + return "" +} + +// injectDeviceToken 在 req 注入 X-Device-Token 头(仅当取到非空 token)。 +func (c *Client) injectDeviceToken(req *http.Request, a *auth.Auth) { + if tok := c.resolveDeviceToken(a); tok != "" { + req.Header.Set("X-Device-Token", tok) + } +} + +// deriveAccountStableID 按 uid + 用途盐稳定派生 36 hex 设备/会话标识。 +// 跨重启稳定(固定盐 "wb2a:",不随进程换——这是与 session 包派生盐的本质差异: +// 那是会话键维度的进程级随机盐,重启换新;本函数是账号维度,必须跨重启恒定)、 +// 账号间互异(uid 不同则不同)、同 uid 同用途恒同值(幂等)。用 sha256 与项目 +// 既有派生(session/ids.go、cache_key.go)保持一致;截 36 hex 提供更长熵。 +// +// 两个用途: +// - purpose="machine" → X-Machine-ID(设备级,跨会话稳定) +// - purpose="session" → X-Session-ID(账号固定会话,跨重启稳定) +// +// 与 injectDeviceToken 的 X-Device-Token 并存不冲突:那是登录时上游签发的 +// 真实设备令牌(有则发,权威);本对头是「每账号一台固定虚拟设备」的稳定指纹, +// 防多号被上游按设备指纹缺失/漂移关联风控。两者是不同头族,官方桌面端都发。 +func deriveAccountStableID(uid, purpose string) string { + sum := sha256.Sum256([]byte("wb2a:" + purpose + ":" + uid)) + return hex.EncodeToString(sum[:18]) // 36 hex chars +} + +// injectAccountStableHeaders 在 req 注入 X-Machine-ID / X-Session-ID:按 uid 稳定 +// 派生,跨重启固定、账号间互异。uid 为空时不注入(匿名请求无设备标识,上游不要求)。 +func (c *Client) injectAccountStableHeaders(req *http.Request, a *auth.Auth) { + if a == nil || a.UID == "" { + return + } + req.Header.Set("X-Machine-ID", deriveAccountStableID(a.UID, "machine")) + req.Header.Set("X-Session-ID", deriveAccountStableID(a.UID, "session")) +} + +// CommonHeaders 设置所有 API 共享的请求头。 +func (c *Client) CommonHeaders(req *http.Request, a *auth.Auth) { + req.Header.Set("Content-Type", "application/json") + // Accept 非流式默认 application/json(D6:去掉宽松的 text/plain, */*)。 + // chat 流式路径在 ChatHeaders 覆盖为 event-stream。 + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + origin := originRefererFor(a) + req.Header.Set("Origin", origin) + req.Header.Set("Referer", origin+"/") + req.Header.Set("User-Agent", c.userAgent(a)) + // X-CodeBuddy-Request: 1(官方客户端风控闸门头,所有 API 请求必带,D1)。 + req.Header.Set("X-CodeBuddy-Request", "1") + // Accept-Language 按 realm 切(D5):CN zh-CN,global en-US。官方客户端按账号域 + // 发对应语言标识,对齐避免上游风控按语言缺失误判。 + req.Header.Set("Accept-Language", acceptLanguageFor(a)) + // X-Machine-ID / X-Session-ID:按 uid 稳定派生的账号级设备头(见 + // injectAccountStableHeaders)。注入在 CommonHeaders——chat 经 ChatHeaders + // 叠加 CommonHeaders 天然继承;billing 域另行注入,全出站覆盖。 + c.injectAccountStableHeaders(req, a) +} + +// acceptLanguageFor 按账号 realm 返回 Accept-Language:global → en-US,cn → zh-CN。 +func acceptLanguageFor(a *auth.Auth) string { + if a != nil && a.IsGlobal() { + return "en-US" + } + return "zh-CN" +} + +// injectCodeBuddyRequest 在 req 注入 X-CodeBuddy-Request: 1。 +// billing 域未走 CommonHeaders,单独注入保证全出站覆盖(D1)。 +func (c *Client) injectCodeBuddyRequest(req *http.Request) { + req.Header.Set("X-CodeBuddy-Request", "1") +} + +// ChatMeta 一次 chat 出站的会话头族元数据(issue #35:后台按 X-Conversation-Request-ID +// 聚合请求,官方客户端一次 user send 内所有 tool call/重试/换号复用同一个 ID)。 +// handler 在轮转循环外生成 conversationID / conversationRequestID,循环内每次出站 +// 复用同值;TraceID 透传入站值(空 = 回落 conversationRequestID)。 +// messageID(消息级,每条独立)由 ChatHeaders 内部生成,无需外部可见。 +type ChatMeta struct { + ConversationID string // X-Conversation-ID:body 提取的入站值,空则不发(透传优先,不伪造) + ConversationRequestID string // X-Conversation-Request-ID / X-Root-Request-ID:聚合主键,必发 + TraceID string // X-Trace-ID:入站透传值,空则回落 conversationRequestID +} + +// ChatHeaders 在 common 之上加 chat 专属的账号头。 +// 缺省字段用 X-No-* 约定(与 CodeBuddy 官方 CLI 一致)。 +// clientIP 为本次请求的客户端 IP(按参数传递,不读共享字段——避免并发串扰); +// PassthroughIP=false 或 clientIP 为空时不注入 IP 头。 +// meta 为会话头族元数据(纯新增,不改既有头),见 injectConversationHeaders。 +func (c *Client) ChatHeaders(req *http.Request, a *auth.Auth, clientIP string, meta ChatMeta) { + c.CommonHeaders(req, a) + // chat 流式 Accept 覆盖 CommonHeaders 的非流式默认(D6)。 + req.Header.Set("Accept", "application/json, text/event-stream") + // AccessToken 加锁快照:keepalive 定时刷新会在 a.mu 内改写它,锁外直读构成数据竞争 + // (见 auth.AccessTokenValue 注释)。 + if at := a.AccessTokenValue(); at != "" { + req.Header.Set("Authorization", "Bearer "+at) + } else { + req.Header.Set("X-No-Authorization", "1") + } + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } else { + req.Header.Set("X-No-User-Id", "1") + } + // 安全红线:绝不在 chat 请求里携带 X-Refresh-Token。 + // 企业与域头按 realm 分发:CN 走既有分支(EnterpriseID/Domain 原样透传,缺省 X-No-*); + // global 账号由 injectGlobalChatHeaders 统一覆写为国际客户端形态 + // (X-No-Enterprise-Id=1 声明无企业 + X-Domain=www.workbuddy.ai 声明国际版域), + // 且不回退 X-Domain 到登录会话原值——对齐 intl 项目出站头。 + if a != nil && !a.IsGlobal() { + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + } else { + req.Header.Set("X-No-Enterprise-Id", "1") + } + if d := a.DomainValue(); d != "" { + req.Header.Set("X-Domain", d) + } else { + req.Header.Set("X-No-Department-Info", "1") + } + } else { + c.injectGlobalChatHeaders(req, a) + } + // 用量归属头:默认伪造 WorkBuddy 桌面端指纹(client_name="SaaS" 还原旧行为)。 + c.injectAttribution(req) + // 客户端 IP 透传(仅 PassthroughIP=true 且本次请求带 IP)。 + c.injectClientIP(req, clientIP) + // 设备风控头:auth 每号 > config 全局 > 文件兜底;空则不注入。 + c.injectDeviceToken(req, a) + // 会话头族(对话/请求/消息/B3 链路),见 injectConversationHeaders。 + c.injectConversationHeaders(req, meta) +} + +// injectGlobalChatHeaders global 账号(无企业 ID)的 chat 专属声明头,对齐 intl 项目 +// (ANALYSIS-global-chat-solutions.md): +// - X-No-Enterprise-Id: 1 个人账号无企业 ID,显式声明(避免上游按缺省/可疑判定) +// - X-Domain: www.workbuddy.ai 显式声明国际版域(与 Origin/Referer 同域) +// +// 仅 global realm 注入;CN 账号走既有 X-No-Department-Info 等分支,零回归。 +func (c *Client) injectGlobalChatHeaders(req *http.Request, a *auth.Auth) { + if a == nil || !a.IsGlobal() { + return + } + req.Header.Set("X-No-Enterprise-Id", "1") + req.Header.Set("X-Domain", "www.workbuddy.ai") +} + +// injectConversationHeaders 注入官方客户端会话头族(issue #35 后台聚合)。 +// 头族分四层,各司其职: +// - X-Conversation-ID:会话级,多轮稳定(body 的 conversationId)。空则不发—— +// 透传客户端原值优先,客户端没给就不伪造,避免误导后台建错会话。 +// - X-Conversation-Request-ID:**对话轮级聚合主键**,必发。一次 user send 内的 +// 所有 tool call/重试/换号/降级复用同一个 → 后台按它聚合成一条(不再碎片化)。 +// - X-Conversation-Message-ID = X-Request-ID:消息级,每条独立(32 位 hex)。 +// - X-Root-Request-ID:= conversationRequestID(根请求追踪)。 +// - X-Trace-ID:入站透传或 = conversationRequestID。 +// - X-B3-TraceId / X-B3-SpanId / X-B3-Sampled:链路族。B3 规范只认 16/32 hex +// TraceId 与 16 hex SpanId;入站 conversationRequestID 非法时 TraceId 回落 +// messageID(恒 32 hex),SpanId 取 messageID[:16](每消息新)。 +func (c *Client) injectConversationHeaders(req *http.Request, meta ChatMeta) { + convReqID := meta.ConversationRequestID + if convReqID == "" { + // 零值 meta(直接调 ChatHeaders 的调用方/测试)也要保证聚合主键必发: + // 本级补一个 32 hex,调用方(handler)已生成稳定的不走到这里。 + convReqID = session.NewMessageID() + } + messageID := session.NewMessageID() + if meta.ConversationID != "" { + req.Header.Set("X-Conversation-ID", meta.ConversationID) + } + req.Header.Set("X-Conversation-Request-ID", convReqID) + req.Header.Set("X-Conversation-Message-ID", messageID) + req.Header.Set("X-Request-ID", messageID) + req.Header.Set("X-Root-Request-ID", convReqID) + traceID := meta.TraceID + if traceID == "" { + traceID = convReqID + } + req.Header.Set("X-Trace-ID", traceID) + b3Trace := convReqID + if !validTraceID(b3Trace) { + b3Trace = messageID // 非法 B3 TraceId → 回落恒 32 hex 的消息级 ID + } + req.Header.Set("X-B3-TraceId", b3Trace) + req.Header.Set("X-B3-SpanId", messageID[:16]) + req.Header.Set("X-B3-Sampled", "1") +} + +// validTraceID 判断 B3 TraceId 是否合法:16 或 32 位 hex(大小写均可)。 +// 官方客户端生成的 conversationRequestId 是 32 位 hex(UUID 去横线),入站透传值 +// 可能是任意形状(含横线/超长/非 hex),直接塞进 B3 头会破坏链路关联(issue #35)。 +func validTraceID(s string) bool { + if len(s) != 16 && len(s) != 32 { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { + return false + } + } + return true +} + +// attributionClientName 生效的用量归属名:ClientName 非空取之; +// 空默认 "WorkBuddy"(伪造官方桌面端指纹;显式配 "SaaS" 可还原旧行为)。 +func (c *Client) attributionClientName() string { + if c != nil && c.ClientName != "" { + return c.ClientName + } + return "WorkBuddy" +} + +// injectAttribution 注入用量归属头(X-Agent-Purpose / X-IDE-* / X-Product)。 +// 仅在 chat/completions 路径生效(ChatHeaders 调用)。 +// +// 默认(ClientName 空)即对齐官方 WorkBuddy 桌面端指纹:X-Agent-Purpose="conversation" +// + X-IDE-Name/Type/Product="WorkBuddy" + X-IDE-Version=client_version,上游用量归因 +// 不再出现 client/agentPurpose 为空的「网关特征」。显式 ClientName="SaaS" 还原旧行为 +// (仅 X-Product="SaaS",不设 X-IDE-*);配其他值则四头跟随该值。 +func (c *Client) injectAttribution(req *http.Request) { + name := c.attributionClientName() + if name == "SaaS" { + req.Header.Set("X-Product", "SaaS") + return + } + req.Header.Set("X-Agent-Purpose", "conversation") + req.Header.Set("X-IDE-Name", name) + req.Header.Set("X-IDE-Type", name) + req.Header.Set("X-IDE-Version", c.clientVersion()) + req.Header.Set("X-Product", name) +} + +// injectClientIP 在 PassthroughIP 开启时把 clientIP 参数透传给上游(三等价头)。 +func (c *Client) injectClientIP(req *http.Request, clientIP string) { + if c == nil || !c.PassthroughIP || clientIP == "" { + return + } + req.Header.Set("X-Forwarded-For", clientIP) + req.Header.Set("X-Real-IP", clientIP) + req.Header.Set("X-Client-IP", clientIP) +} + +// ExtractClientIP 从入站请求提取客户端 IP 首段(X-Forwarded-For 首段,回落 X-Real-IP)。 +func ExtractClientIP(r *http.Request) string { + if r == nil { + return "" + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + for i := 0; i < len(xff); i++ { + if xff[i] == ',' { + return strings.TrimSpace(xff[:i]) + } + } + return strings.TrimSpace(xff) + } + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + return real + } + return "" +} + +// BillingHeaders billing 接口请求头。 +// UA 语义:默认**不设置**(保持现状,Go 客户端自带默认 UA);仅当显式配置 +// c.UserAgent 非空才覆盖——避免默认路径给 billing 引入新的 UA 指纹。 +func (c *Client) BillingHeaders(req *http.Request, a *auth.Auth) { + // AccessToken 加锁快照(同 ChatHeaders:keepalive 可在 a.mu 内改写)。 + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + c.injectCodeBuddyRequest(req) + // Accept-Language 按 realm 切(D5,billing 域未走 CommonHeaders,单独注入)。 + req.Header.Set("Accept-Language", acceptLanguageFor(a)) + if c != nil && c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } else if ua := c.billingUA(); ua != "" { + req.Header.Set("User-Agent", ua) + } + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + req.Header.Set("X-Tenant-Id", a.EnterpriseID) + } + if d := a.DomainValue(); d != "" { + req.Header.Set("X-Domain", d) + } + // 设备风控头:billing 域(report/travel/balance/checkin)同样注入。 + c.injectDeviceToken(req, a) +} + +// RefreshHeaders refresh 端点专属头(X-Refresh-Token 只允许出现在这里)。 +func (c *Client) RefreshHeaders(req *http.Request, a *auth.Auth) { + c.CommonHeaders(req, a) + req.Header.Set("X-Refresh-Token", a.RefreshToken) + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + } + // X-Auth-Refresh-Source 对齐官方客户端 refresh 渠道标识 "plugin"(D3)。 + req.Header.Set("X-Auth-Refresh-Source", "plugin") +} diff --git a/internal/upstream/hint.go b/internal/upstream/hint.go new file mode 100644 index 0000000000000000000000000000000000000000..55bfa248032f30b95501d7aa7b4a1c290bebb4ab --- /dev/null +++ b/internal/upstream/hint.go @@ -0,0 +1,149 @@ +// hint.go 网关错误附加说明字段(error.gateway_hint)的单一事实来源。 +// +// 纪律(任务书 gateway-hint): +// - error.message 永远是上游 body 原文透传(5755fe3 透传原则不动); +// gateway_hint 只做与 message **并列**的网关视角补充说明,绝不替换/包装 message。 +// - 文案集中在本文件(一张 Kind 表 + 11133/11135 形态判定),按 ErrKind + 上下文 +// (请求带图/模型目录能力)映射,不散落 handler 的 if-else。 +// - 未覆盖形态返回空串 → 响应不带该字段(不编造)。 +// - hint 措辞是英文(错误响应面向客户端工具链,英文是通用口径)。 +package upstream + +import ( + "encoding/json" + "net/http" + "strings" +) + +// GatewayHint 按错误形态返回网关视角的补充说明(error.gateway_hint 字段值)。 +// msg 是上游错误 body 原文(或 SSE error 帧 payload);kind 是权威分类 +// (Classify / *Error 信封)。返回空串 = 未覆盖形态,调用方不带字段。 +// +// ctx 携带判定 hint 所需的请求侧上下文(零值合法,信息缺失时相关形态退为中性 +// hint 或无 hint): +// - HasImage:请求体是否携带 image_url part(11133 的「模型不支持图片」指向前提); +// - Model / ModelInCatalog / ModelSupportsImages:模型目录对该模型的 +// supports_images 声明(目录未收录 → 不做「不支持」判定,防查不到误判成不支持)。 +// +// 判定次序:11133/11135 上游业务码**先于** Kind 表——实测这两族归 ErrClient/ +// ErrBadParams 皆有可能(Classify 词表不含 11133),hint 层自带判定(hint 是补充 +// 说明非权威分类,误判代价只是多一条中性补充说明);其余走 Kind 一对一映射。 +func GatewayHint(kind ErrKind, msg string, ctx HintContext) string { + // 11133 model_param_invalid 家族(图片回归实测:不支持图片的模型传图,或任意 + // 参数被模型供应商拒绝)。只有请求确实带图、且目录能对该模型做出「不支持图片」 + // 的判定时才给「换模型」指向,否则退中性参数形态(可能是任意参数问题,不点名图片)。 + if isModelParamInvalid(msg) { + if ctx.HasImage && ctx.ModelInCatalog && !ctx.ModelSupportsImages { + return "model " + ctx.Model + " does not support images; pick one with supports_images=true from /v1/models" + } + return "request parameters were rejected by the model provider; check message format and model capabilities" + } + // 11135 invalid_image_data 家族(图片数据无效,Discussion #77 实测形态)。 + if isInvalidImageData(msg) { + return "image data rejected by upstream; use a real/valid image, may need a new conversation" + } + switch kind { + case ErrPromptTooLong: + return "request context exceeds the model's limit; reduce history/message size" + case ErrImageInvalid: + return "image request was rejected by upstream; check image_url format and image data" + case ErrWafBlock: + // 账号级 WAF 403 与 IP 级 fail-fast 同 hint:两者对客户端的动作一致 + // (等待窗口过去再试,换号/立刻重试无意义)。 + return "upstream WAF blocked the gateway; retry after the block window" + case ErrSoftRate: + return "rate limited by upstream; retry after reset" + case ErrAccountFault: + return "account-level fault at upstream (auth/quota state); the gateway will rotate or disable this account" + case ErrSessionDead: + return "account session expired at upstream; the account is disabled until re-login" + case ErrHardCredit: + return "account credits exhausted at upstream; waiting for daily check-in to restore" + case ErrModelBlocked: + return "upstream has no such model on this backend; switch model or retry on another account" + case ErrContentBlocked: + // 措辞不含 "upstream":content_blocked 响应有不含上游字样的既有口径 + // (handler_test 的泄漏守卫),hint 遵守同一口径。 + return "request content was rejected by content policy; adjust the prompt and retry" + default: + // ErrNone/ErrNotFound/ErrServer/ErrBadParams/ErrClient 等未覆盖形态:无 hint。 + return "" + } +} + +// HintContext gateway_hint 判定所需的请求侧上下文(handler 侧组装,见 +// Handler.hintContext)。零值合法。 +type HintContext struct { + Model string // 请求裸模型名(可空) + HasImage bool // 请求体是否携带 image_url part + ModelSupportsImages bool // 模型目录 supports_images 声明(仅 ModelInCatalog 时有意义) + ModelInCatalog bool // 模型目录是否收录该模型(「不支持」判定的前提) +} + +// noHealthyHint 本地调度类错误(池中无健康号可用/传输层抖动,无上游原文可透传) +// 的固定 hint。不进 GatewayHint:它没有 ErrKind,是网关自己的调度事实。 +const noHealthyHint = "no healthy account available in pool; check /status or retry later" + +// NoHealthyAccountHint 本地调度错误的 gateway_hint(与 no_healthy_account code 配套)。 +func NoHealthyAccountHint() string { return noHealthyHint } + +// FrameHintFunc 返回 SSE error 帧的 gateway_hint 判定函数(Stream 的可选参数)。 +// ctxFn 惰性求值:仅在实际撞到 error 帧才调用(正常流零开销,模型目录查询 +// 不会为每个成功请求触发)。 +func FrameHintFunc(ctxFn func() HintContext) func(string) string { + return func(payload string) string { + if payload == "" || payload == "[DONE]" { + return "" + } + return GatewayHint(FrameKind(payload), payload, ctxFn()) + } +} + +// FrameKind 从 SSE error 帧 payload 判定 ErrKind:6004 模型级限流的流式形态 +// (IsModelRateLimit 对帧 JSON 直接命中)优先;其余取帧内 error.message 走 +// Classify(请求级 400 口径)。判不出 → ErrNone(无 hint)。 +func FrameKind(payload string) ErrKind { + if IsModelRateLimit(payload) { + return ErrSoftRate + } + var f struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(payload), &f) != nil || f.Error.Message == "" { + return ErrNone + } + return Classify(http.StatusBadRequest, f.Error.Message) +} + +// isModelParamInvalid 上游 11133 body 判定(code 11133 / extError.code= +// model_param_invalid / msg 文案家族)。子串口径:hint 是补充说明非权威分类, +// 宁宽勿漏。 +func isModelParamInvalid(body string) bool { + lower := strings.ToLower(body) + return codeMarker(lower, "11133") || + strings.Contains(lower, "model_param_invalid") || + strings.Contains(lower, "invalid request parameters") || + strings.Contains(lower, "request parameters do not meet the current model requirements") +} + +// isInvalidImageData 上游 11135 body 判定(code 11135 / invalid_image_data / +// "replace the image" msg 家族)。 +func isInvalidImageData(body string) bool { + lower := strings.ToLower(body) + return codeMarker(lower, "11135") || + strings.Contains(lower, "invalid_image_data") || + strings.Contains(lower, "replace the image") +} + +// codeMarker JSON code 字段命中(`"code":N` / `"code": N` / `"code":"N"` 形态, +// 与 IsModelBlocked 的 code 判定同容差口径)。lower 须为小写 body。 +func codeMarker(lower, code string) bool { + for _, v := range []string{`"code":` + code, `"code": ` + code, `"code":"` + code + `"`, `"code": "` + code + `"`, `"code":" ` + code + `"`, `"code": '` + code + `'`} { + if strings.Contains(lower, v) { + return true + } + } + return false +} diff --git a/internal/upstream/idle.go b/internal/upstream/idle.go new file mode 100644 index 0000000000000000000000000000000000000000..ae3283b48b9da31237d6b1bd8ac4e5e7916de43e --- /dev/null +++ b/internal/upstream/idle.go @@ -0,0 +1,87 @@ +// idle.go 聊天 SSE 流中空闲监控:活跃吐数据续命不掐,静默超过阈值才断流(释放租约)。 +package upstream + +import ( + "context" + "io" + "sync" + "time" +) + +// idleMonitoringBody 包在聊天 SSE body 外层: +// 每次读到底层数据(n>0)就刷新 lastRead;后台 goroutine 周期检查, +// 静默超过 idle 就 cancel 请求 context,中断阻塞中的 Read。 +type idleMonitoringBody struct { + rc io.ReadCloser + mu sync.Mutex + lastRead time.Time + stopOnce sync.Once + stopCh chan struct{} + cancel context.CancelFunc +} + +func (b *idleMonitoringBody) Read(p []byte) (int, error) { + n, err := b.rc.Read(p) + if n > 0 { + b.mu.Lock() + b.lastRead = time.Now() + b.mu.Unlock() + } + return n, err +} + +// Close 停掉后台 goroutine、取消请求 context、关闭底流,保证无泄漏。 +func (b *idleMonitoringBody) Close() error { + b.stopOnce.Do(func() { close(b.stopCh) }) + b.cancel() + return b.rc.Close() +} + +func (b *idleMonitoringBody) idleFor() time.Duration { + b.mu.Lock() + defer b.mu.Unlock() + return time.Since(b.lastRead) +} + +// monitorBody 若 idle<=0 直接返回原底流(禁用空闲监控); +// 否则包上流中空闲监控。计时从返回 body 之后开始——首字节阶段由 +// Transport.ResponseHeaderTimeout 管,这里不抢跑。 +func monitorBody(rc io.ReadCloser, idle time.Duration, cancel context.CancelFunc) io.ReadCloser { + if idle <= 0 { + return rc + } + b := &idleMonitoringBody{ + rc: rc, + lastRead: time.Now(), + stopCh: make(chan struct{}), + cancel: cancel, + } + go func() { + t := time.NewTicker(idleTick(idle)) + defer t.Stop() + for { + select { + case <-b.stopCh: + return + case <-t.C: + if b.idleFor() > idle { + cancel() + return + } + } + } + }() + return b +} + +// idleTick 返回监控周期:idle/4,钳在 [10ms, 1s]。小 idle 也能快速发现,大小值避免空转。 +func idleTick(idle time.Duration) time.Duration { + d := idle / 4 + if d > time.Second { + d = time.Second + } + if d < 10*time.Millisecond { + d = 10 * time.Millisecond + } + return d +} diff --git a/internal/upstream/idle_test.go b/internal/upstream/idle_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e3aab5f6223dd78d8b3badb3851451a1501680ed --- /dev/null +++ b/internal/upstream/idle_test.go @@ -0,0 +1,115 @@ +package upstream + +import ( + "context" + "io" + "testing" + "time" +) + +// TestMonitorBodyIdleCutoff 静默超过 idle(配小值)→ Read 返回错误(context canceled)。 +func TestMonitorBodyIdleCutoff(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // 模拟真实 resp.Body:阻塞直到请求 context 被 cancel 才返回 error。 + body := monitorBody(&ctxBoundReader{ctx: ctx}, 50*time.Millisecond, cancel) + defer body.Close() + if _, err := body.Read(make([]byte, 16)); err == nil { + t.Fatal("expect read error after idle cutoff") + } +} + +// TestMonitorBodyRenewsOnActivity 持续活跃(周期吐数据,总时长 > idle)→ 流不被掐。 +func TestMonitorBodyRenewsOnActivity(t *testing.T) { + _, cancel := context.WithCancel(context.Background()) + defer cancel() + // 每 30ms 吐 1 字节;idle=80ms。总读 10 字节耗时 300ms > idle: + // 若空闲续命失效(绝对 deadline 语义)早在 80ms 就被掐断。 + body := monitorBody(nopCloserBody{Reader: periodicReader{period: 30 * time.Millisecond}}, 80*time.Millisecond, cancel) + defer body.Close() + buf := make([]byte, 1) + for i := 0; i < 10; i++ { + if _, err := io.ReadFull(body, buf); err != nil { + t.Fatalf("read %d: %v (active stream must not be cut)", i, err) + } + } +} + +// TestMonitorBodyDisabledWhenIdleZero idle<=0 直接返回原底流。 +func TestMonitorBodyDisabledWhenIdleZero(t *testing.T) { + _, cancel := context.WithCancel(context.Background()) + defer cancel() + rc := nopCloserBody{Reader: &oneByteReader{}} + out := monitorBody(rc, 0, cancel) + if _, ok := out.(nopCloserBody); !ok { + t.Fatalf("idle<=0 should return underlying body verbatim, got %T", out) + } + buf := make([]byte, 1) + if _, err := out.Read(buf); err != nil || buf[0] != 'a' { + t.Fatalf("read=%q err=%v", buf, err) + } +} + +// TestMonitorBodyCloseStopsGoroutine Close 停掉 goroutine,无泄漏(等效监控循环 + 短周期)。 +func TestMonitorBodyCloseStopsGoroutine(t *testing.T) { + m := &idleMonitoringBody{ + rc: &ctxBoundReader{ctx: context.Background()}, + lastRead: time.Now(), + stopCh: make(chan struct{}), + cancel: func() {}, + } + stopped := make(chan struct{}) + go func() { + tk := time.NewTicker(idleTick(40 * time.Millisecond)) + defer tk.Stop() + for { + select { + case <-m.stopCh: + close(stopped) + return + case <-tk.C: + } + } + }() + m.Close() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("goroutine not stopped after Close") + } +} + +// —— helpers —— + +// ctxBoundReader 模拟 net/http resp.Body:Read 阻塞在 ctx.Done() 上,ctx cancel 后返回错误。 +type ctxBoundReader struct{ ctx context.Context } + +func (r *ctxBoundReader) Read([]byte) (int, error) { + <-r.ctx.Done() + return 0, r.ctx.Err() +} + +func (r *ctxBoundReader) Close() error { return nil } + +type periodicReader struct{ period time.Duration } + +func (r periodicReader) Read(p []byte) (int, error) { + time.Sleep(r.period) + p[0] = 'x' + return 1, nil +} + +type oneByteReader struct{ done bool } + +func (r *oneByteReader) Read(p []byte) (int, error) { + if r.done { + return 0, io.EOF + } + r.done = true + p[0] = 'a' + return 1, nil +} + +type nopCloserBody struct{ io.Reader } + +func (nopCloserBody) Close() error { return nil } diff --git a/internal/upstream/model.json b/internal/upstream/model.json new file mode 100644 index 0000000000000000000000000000000000000000..68e8c3b54b4d9cbb1d92b81fe8341afb50d1a652 --- /dev/null +++ b/internal/upstream/model.json @@ -0,0 +1,135 @@ +{ + "glm-5.2": { + "context_length": 1000000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5.1": { + "context_length": 200000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5.3": { + "context_length": 1000000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5.3-flash": { + "context_length": 1000000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5v-turbo": { + "context_length": 200000, + "max_output_tokens": 131072, + "source": "seed" + }, + "kimi-k2.7": { + "context_length": 256000, + "max_output_tokens": 65536, + "source": "seed" + }, + "kimi-k2.6": { + "context_length": 256000, + "max_output_tokens": 262144, + "source": "seed" + }, + "kimi-k2.5": { + "context_length": 164000, + "max_output_tokens": 262144, + "source": "seed" + }, + "kimi-k3": { + "context_length": 1048576, + "max_output_tokens": 131072, + "source": "seed" + }, + "kimi-k2.8-preview": { + "context_length": 1048576, + "source": "seed" + }, + "minimax-m3": { + "context_length": 512000, + "max_output_tokens": 512000, + "source": "seed" + }, + "hy3": { + "context_length": 192000, + "max_output_tokens": 64000, + "source": "seed" + }, + "hy3-preview": { + "context_length": 262144, + "max_output_tokens": 64000, + "source": "seed" + }, + "hy4-preview": { + "context_length": 1000000, + "max_output_tokens": 64000, + "source": "seed" + }, + "hy4-preview-x": { + "context_length": 1000000, + "max_output_tokens": 64000, + "source": "seed" + }, + "deepseek-v4-pro": { + "context_length": 1000000, + "max_output_tokens": 384000, + "source": "seed" + }, + "deepseek-v4-flash": { + "context_length": 1000000, + "max_output_tokens": 384000, + "source": "seed" + }, + "deepseek-v4.1-flash": { + "context_length": 1000000, + "max_output_tokens": 384000, + "source": "seed" + }, + "gpt-6-astra": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.6-sol": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.6-terra": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.6-luna": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.5": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.4": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.3-codex": { + "context_length": 400000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gemini-3.5-flash": { + "context_length": 1048576, + "max_output_tokens": 65536, + "source": "seed" + }, + "auto": { + "context_length": 168000, + "source": "seed" + } +} diff --git a/internal/upstream/model_catalog.go b/internal/upstream/model_catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..fc117002f42136ab2c468f66c798462eda596955 --- /dev/null +++ b/internal/upstream/model_catalog.go @@ -0,0 +1,320 @@ +// model_catalog.go context_length / max_output_tokens 四级查找链 + model.json +// 本地缓存(任务书 model-json-dynamic)。 +// +// 查找链(32a3c13 三级 → 四级,上游动态值永远权威不变): +// 1. 上游动态值(ModelInfo.ContextWindow/MaxTokens)——权威,永远压过 model.json +// (即使后者更新:上游才是权威,任务书 §清理); +// 2. 静态种子表(context_catalog.go 的 contextCapFallback,编译期兜底); +// 3. model.json 本地缓存(数据目录,含运行时从 models.dev 补的值 + 仓库种子 +// embed 的初值);损坏 → 降级种子并 WARN 不崩溃(手动维护入口的容错); +// 4. 触发 models.dev 按需拉取(异步,不阻塞本次响应)→ 值写入 model.json → +// 本次先落 1M/省略,下次命中缓存;负缓存 24h。 +// +// model.json 读写并发安全(单 sync.Mutex 全程持锁 + 原子落盘 tmp+rename, +// 多请求同时 miss 同一模型只写一次);文件损坏/不可写均静默降级(种子表→1M)。 +// +// 状态归属:包级 catalogState 单例(与 modelsDev fetcher 同模式,进程一份)。 +// 测试用 resetModelCatalog / loadModelCatalogAt 隔离。 +package upstream + +import ( + "embed" + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// modelSeedFS 仓库种子版 model.json(context_catalog 静态表 27 值迁移,source=seed)。 +// 只作 model.json 缺失时的初值来源;运行目录的 model.json 一旦存在则以它为准 +// (用户手动维护入口——直接编辑文件,格式容错由 loadModelCatalog 的校验兜底)。 +// +//go:embed model.json +var modelSeedFS embed.FS + +// ModelCapEntry model.json 单条目(与静态表同字段口径 + 来源与抓取时间)。 +// Source:seed(仓库种子迁移)/ modelsdev(运行时按需拉取)/ manual(用户手编 +// ——无法区分手编与 seed,手编条目保留其原 source 字符串,语义等同「非拉取」)。 +// Context 必须 >0(零/负条目校验拒绝);MaxOutput 0 = 输出上限未知 → 省略字段。 +type ModelCapEntry struct { + ContextLength int64 `json:"context_length"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` + FetchedAt string `json:"fetched_at,omitempty"` // RFC3339;seed 条目为空 + Source string `json:"source"` +} + +// modelCatalog model.json 缓存状态机(包级 catalogState 单例的字段载体)。 +type modelCatalog struct { + mu sync.Mutex + + path string // 落盘路径(空 = 禁用持久化:纯内存 + 种子) + entries map[string]ModelCapEntry + loaded bool // entries 已初始化(含损坏降级形态) +} + +// catalogState 包级单例:全进程一份 model.json 状态。 +var catalogState = &modelCatalog{} + +// initModelCatalogLocked 确保 entries 已加载(持锁调用): +// - 运行目录 model.json 存在且合法 → 全量加载(校验失败的条目剔除并 WARN); +// - 文件不存在 / 整体损坏(非法 JSON)→ 种子 embed 初值(不落盘:保持「用户 +// 尚无缓存」状态,首次拉取成功后再落盘); +// - path 为空(未接线,如测试/工具进程)→ 种子初值。 +func (c *modelCatalog) initLocked() { + if c.loaded { + return + } + c.entries = map[string]ModelCapEntry{} + c.loaded = true + if c.path == "" { + c.loadSeedLocked() + return + } + raw, err := os.ReadFile(c.path) + if err != nil { + // 不存在 / 不可读 → 种子(首次启动形态)。 + c.loadSeedLocked() + return + } + var file map[string]ModelCapEntry + if err := json.Unmarshal(raw, &file); err != nil { + // 整体损坏:降级种子 + WARN 不崩溃(任务书 §手动维护入口容错)。 + log.Printf("WARN: [upstream] model.json 损坏(降级内置种子表): path=%s err=%v", c.path, err) + c.loadSeedLocked() + return + } + for id, e := range file { + if !validCapEntry(e) { + log.Printf("WARN: [upstream] model.json 条目非法剔除: model=%s entry=%+v", id, e) + continue + } + c.entries[id] = e + } +} + +// loadSeedLocked 把仓库种子 model.json 灌入 entries(持锁调用)。 +func (c *modelCatalog) loadSeedLocked() { + raw, err := modelSeedFS.ReadFile("model.json") + if err != nil { + // embed 编译期保证存在,理论不可达;防御性兜底走静态表(initLocked 调用方 + // 查找链第 2 级本来就会兜,这里只需保持 entries 为空)。 + return + } + var seed map[string]ModelCapEntry + if err := json.Unmarshal(raw, &seed); err != nil { + return + } + for id, e := range seed { + if validCapEntry(e) { + c.entries[id] = e + } + } +} + +// validCapEntry 条目级校验:context 正数 + 输出非负(任务书 §值校验的写入侧)。 +func validCapEntry(e ModelCapEntry) bool { + return e.ContextLength > 0 && e.MaxOutputTokens >= 0 +} + +// get 查 model.json 缓存(第 3 级)。返回条目与是否命中。 +// 只读内存,不发网络;加载与网络动作由 ensure/触发侧负责。 +func (c *modelCatalog) get(model string) (ModelCapEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.initLocked() + e, ok := c.entries[model] + if !ok || !validCapEntry(e) { + return ModelCapEntry{}, false + } + return e, true +} + +// put 写一条缓存(第 4 级拉取成功后调用)并落盘。同模型已存在(用户手动维护过) +// 仍覆盖——运行时拉取值采信 models.dev 官方源优先口径,比手编更可信; +// 若不希望覆盖,删掉 model.json 里对应条目即可(加载后手编值只在本次进程生效)。 +func (c *modelCatalog) put(model string, e ModelCapEntry) { + if model == "" || !validCapEntry(e) { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.initLocked() + c.entries[model] = e + c.saveLocked() +} + +// saveLocked 原子落盘(tmp + rename,pool state.json 同模式)。持锁调用。 +// path 为空 / 目录不可写 / 序列化失败 → 静默(内存缓存仍生效,下次进程重拉)。 +func (c *modelCatalog) saveLocked() { + if c.path == "" { + return + } + raw, err := json.MarshalIndent(c.entries, "", " ") + if err != nil { + return + } + if dir := filepath.Dir(c.path); dir != "" { + _ = os.MkdirAll(dir, 0o755) + } + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + log.Printf("WARN: [upstream] model.json 落盘失败(内存缓存仍生效): path=%s err=%v", c.path, err) + return + } + if err := os.Rename(tmp, c.path); err != nil { + log.Printf("WARN: [upstream] model.json 落盘改名失败: path=%s err=%v", c.path, err) + } +} + +// ---- 包级 API(查找链 3/4 级 + 接线)---- + +// SetModelCatalogPath 接线 model.json 落盘路径(cmd/server 启动时调用, +// 数据目录与 state.json 同风格)。首次调用生效;后续调用在已加载后仅更新路径 +// (不重载——进程内以内存 entries 为准)。 +func SetModelCatalogPath(path string) { + c := catalogState + c.mu.Lock() + defer c.mu.Unlock() + c.path = path +} + +// modelCatalogGet 第 3 级:model.json 缓存命中(内含种子初值与损坏降级)。 +func modelCatalogGet(model string) (ModelCapEntry, bool) { + return catalogState.get(model) +} + +// modelCatalogPut 第 4 级写入:models.dev 拉到的值落缓存(含落盘)。 +func modelCatalogPut(model string, context, maxOutput int64) { + modelCatalogPutSourced(model, context, maxOutput, "modelsdev") +} + +// modelCatalogPutSourced 写入指定来源的条目(测试可注入 fetched_at 检查落盘格式)。 +func modelCatalogPutSourced(model string, context, maxOutput int64, source string) { + catalogState.put(model, ModelCapEntry{ + ContextLength: context, + MaxOutputTokens: maxOutput, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + Source: source, + }) +} + +// resetModelCatalog 测试隔离:清空单例状态(entries/path/loaded)。 +func resetModelCatalog() { + c := catalogState + c.mu.Lock() + defer c.mu.Unlock() + c.entries = nil + c.path = "" + c.loaded = false +} + +// loadModelCatalogAt 测试接线:指向指定路径后立即触发一次加载(同步,可断言文件 +// 解析行为)。生产路径用 SetModelCatalogPath(惰性首次 get 触发加载)。 +func loadModelCatalogAt(path string) { + SetModelCatalogPath(path) + catalogState.mu.Lock() + defer catalogState.mu.Unlock() + catalogState.initLocked() +} + +// ResetLookupChainForTest 跨包测试钩子:清空 model.json 缓存与 models.dev fetcher +// 的全部包级单例状态(含在途拉取冷却——防 server 包测试末尾的异步 goroutine 打 +// 真网、防跨测试缓存污染)。仅测试引用(upstream 包内用 resetModelsDev / +// resetModelCatalog 等价内联)。 +func ResetLookupChainForTest() { + resetModelCatalog() + resetModelsDev() +} + +// ---- 四级查找链(对 handler 暴露的入口,签名与 32a3c13 三级版兼容)---- + +// ContextWindowListingV4 四级查找链的 context_length 决策: +// 1. remote>0 权威透出(上游动态值永远压过 model.json,任务书 §清理); +// 2. 静态种子表(contextCapFallback); +// 3. model.json 缓存(内含种子初值 / models.dev 运行时补充值); +// 4. 全链 miss 且非负缓存 → 异步触发 models.dev 拉取(本次返回 DefaultContextWindow +// 1M,不阻塞;拉到后写 model.json 供下次命中)。 +func ContextWindowListingV4(model string, remote int64, client *http.Client) int64 { + if remote > 0 { + return remote + } + if model == "" { + return DefaultContextWindow + } + if cap, ok := contextCapFallback[model]; ok && cap.context > 0 { + return cap.context // 第 2 级:静态种子表(编译期兜底,永远可用) + } + if e, ok := modelCatalogGet(model); ok { + return e.ContextLength // 第 3 级:model.json + } + if !modelsDev.negativeFresh(model) { + // 第 4 级触发:先查进程内文档索引(拉过一次即常驻),命中直接入缓存 + // 返回(不等待异步拉取);未命中 → 记负缓存 + 异步拉取(本次先回 1M)。 + if e, ok := modelsDev.lookup(model); ok { + modelCatalogPut(model, e.Context, e.Output) + return e.Context + } + modelsDev.ensureDocAsync(client, "") + } + return DefaultContextWindow // 第 4 级兜底:1M(本次先回,拉到后下次命中) +} + +// MaxOutputTokensListingV4 四级查找链的 max_output_tokens 决策(与 context 口径 +// 刻意不同:未知 → 省略,无「宁可高估」安全侧)。 +func MaxOutputTokensListingV4(model string, remote int64, client *http.Client) (int64, bool) { + if remote > 0 { + return remote, true + } + if model == "" { + return 0, false + } + if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 { + return cap.maxOutput, true // 第 2 级 + } + if e, ok := modelCatalogGet(model); ok && e.MaxOutputTokens > 0 { + return e.MaxOutputTokens, true // 第 3 级 + } + if !modelsDev.negativeFresh(model) { + // 与 ContextWindowListingV4 同触发:lookup 命中先入缓存再返回 + // (两条查找链并发 miss 同一模型时,第二调用方直接拿到刚写入的值)。 + if e, ok := modelsDev.lookup(model); ok { + modelCatalogPut(model, e.Context, e.Output) + return e.Output, e.Output > 0 + } + modelsDev.ensureDocAsync(client, "") + } + return 0, false // 第 4 级兜底:省略(输出上限不编造) +} + +// ---- 第 4 级拉取值的回流(fetchDoc 成功后调用,写 model.json)---- + +// noteModelsDevMiss 查询未命中 models.dev 索引 → 负缓存已由 lookup 记录。 +// 本函数是 lookup + 写缓存的粘合层:fetchDoc 拉到文档后对「曾 miss 过的模型」 +// 重查一次并写入 model.json(下次 /v1/models 直接命中第 3 级)。 +func (f *modelsDevFetcher) backfillMisses() { + f.mu.Lock() + doc := f.doc + missed := make([]string, 0, len(f.negatives)) + for m := range f.negatives { + missed = append(missed, m) + } + f.mu.Unlock() + if doc == nil { + return + } + for _, m := range missed { + e, ok := doc[m] + if !ok { + continue // 仍查不到:负缓存 24h 生效,不写 + } + modelCatalogPut(m, e.Context, e.Output) + // 回流成功:清除负缓存条目(该模型已有值,后续走第 3 级缓存, + // 不再进本清单)。 + f.mu.Lock() + delete(f.negatives, m) + f.mu.Unlock() + } +} diff --git a/internal/upstream/modelsdev.go b/internal/upstream/modelsdev.go new file mode 100644 index 0000000000000000000000000000000000000000..273f4eb2b318b1c79a06926b02fde9567fbd4014 --- /dev/null +++ b/internal/upstream/modelsdev.go @@ -0,0 +1,358 @@ +// modelsdev.go models.dev 按需兜底(context_length 四级查找链的第 4 级)。 +// +// 定位:只对「上游动态值缺失 + 静态种子表未收录 + model.json 未缓存」的模型查 +// models.dev,是兜底的兜底——超时短(默认 5s)、失败静默降级(context_length 落 +// DefaultContextWindow=1M),绝不阻塞 /v1/models 主路径(查找异步化,本次请求 +// 直接返回兜底值,拉到后写 model.json 供下次命中)。 +// +// 数据源(2026-09-16 逆向,结论详见 .claude/reports/model-json-dynamic.md): +// - 官方聚合 JSON 端点 https://models.dev/api.json:~4.7MB 单文档、217 provider、 +// 免鉴权、Cloudflare 托管静态站; +// - 无按模型/按 provider 子端点(/z-ai.json 等 302 回 /),「按需」的实现是 +// 单次拉全量文档 + 建裸 id 索引(拉一次只发生一次,此后进程内复用索引); +// - schema:{ "": { "models": { "": { "limit": {"context": N, +// "output": N} } } } },模型 id 有裸名(glm-5.2)与带命名空间(openai/gpt-5.5) +// 两种形态,均取尾段做索引 key; +// - 多 provider 同名值会分歧(聚合网关常自报改动):vendor 官方源(zai/ +// moonshotai/openai/google/deepseek/minimax)优先,其余取众数(共识值)。 +package upstream + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// ModelsDevURL models.dev 官方聚合 JSON 端点(唯一端点,见文件头逆向结论)。 +const ModelsDevURL = "https://models.dev/api.json" + +// modelsDevTimeout 单次拉取超时:兜底的兜底,不值得等(任务书 §2:如 5s)。 +const modelsDevTimeout = 5 * time.Second + +// modelsDevFetchCooldown 拉取节流(进程级):文档是全量聚合体,5min 内不重拉 +// (同模型 24h 负缓存之外的整体节流,防短窗反复打 models.dev)。 +const modelsDevFetchCooldown = 5 * time.Minute + +// modelsDevNegativeTTL 同模型负缓存:查不到的模型 24h 内不重查 +// (任务书 §2:如同模型 24h 内不重查,查不到的模型负缓存防反复打)。 +const modelsDevNegativeTTL = 24 * time.Hour + +// modelsDevNegativesSoftCap 负缓存 map 的软上限:规模超过它时才做一次过期条目淘汰 +// 扫描。分批摊销是为避免每次 lookup 都做 O(n) 全扫——第 4 级触发点在 /v1/models 里 +// 每个模型各调一次(handler 遍历模型列表逐条 V4),n 大时全扫会被请求数放大成 CPU +// 开销。淘汰只针对 TTL 已过期的条目,故低于上限时不扫也不会让任何有效条目过期失效。 +// +// 注意「只扫过期条目」不足以保证有界:TTL 从**首次未命中**起算(见 lookup 的写回 +// 判断),若模型名持续出现在 /v1/models 名单里,一个 TTL 窗口内所有条目都会被反复 +// 盖章成 fresh,扫描一条也删不掉,规模只增不减。因此超过**硬上限**(两倍软上限) +// 时额外丢弃最旧的条目——负缓存是纯性能优化(miss 时多查一次进程内索引),丢条目 +// 只可能让某个模型重新进一次扫描,不改变任何对外语义。 +const modelsDevNegativesSoftCap = 1024 + +// modelsDevNegativesHardCap 负缓存 map 的硬上限:超过即按时间序丢弃最旧条目 +// (见 modelsDevNegativesSoftCap 注释)。取两倍软上限,给「一轮 /v1/models 新增」 +// 留出余量,正常规模远达不到。 +const modelsDevNegativesHardCap = 2 * modelsDevNegativesSoftCap + +// modelsDevMaxBody 拉取响应体上限(文档实测 ~4.7MB,留余量;防异常大响应拖死)。 +const modelsDevMaxBody = 32 << 20 + +// modelsDevValueMax 值校验上限:context/output 超过 1e9 视为脏数据拒绝 +// (量级上限校验,任务书 §2 值校验;正数下界在 catalog 写入侧兜底)。 +const modelsDevValueMax = int64(1e9) + +// modelsDevVendorSources 官方 vendor provider 优先名单:models.dev 收录 217 个 +// provider,聚合网关(merge-gateway/nano-gpt 等)自报的 limit 常与官方源分歧, +// 采值优先级 = 本名单命中 > 众数共识。 +var modelsDevVendorSources = map[string]bool{ + "zai": true, // Z.AI(glm 家族官方) + "moonshotai": true, // Moonshot AI(kimi 家族官方,国际版) + "moonshotai-cn": true, // Moonshot AI 中国版 + "openai": true, + "google": true, + "deepseek": true, + "minimax": true, +} + +// modelsDevEntry models.dev 单模型采值结果(modelsdev json 的 limit 子集)。 +type modelsDevEntry struct { + Context int64 + Output int64 +} + +// modelsDevFetcher models.dev 按需拉取器:进程级单例语义(包级变量 modelsDev), +// 拉取节流 + 裸 id 索引缓存 + 同模型负缓存。测试用 resetModelsDev / 独立 base URL +// 注入隔离(newModelsDevForTest)。 +type modelsDevFetcher struct { + mu sync.Mutex + + // doc 文档解析后的裸 id 索引(多 provider 同名合并采值:vendor 优先/众数)。 + // nil = 未拉取;空 map(非 nil)= 拉取过但索引为空(视作失败冷却)。 + doc map[string]modelsDevEntry + + lastFetch time.Time // 最近一次拉取尝试(成功与失败都算,冷却节流) + fetched bool // 是否已拉取过(doc 字段区分成败) + + negatives map[string]time.Time // 查询未命中的模型 → 记录时间(24h 负缓存) +} + +// modelsDev 包级拉取器实例(单例:全进程共享一份文档索引与节流状态)。 +var modelsDev = &modelsDevFetcher{} + +// resetModelsDev 测试隔离:清空单例状态(doc/fetched/lastFetch/negatives)。 +func resetModelsDev() { + modelsDev.mu.Lock() + modelsDev.doc = nil + modelsDev.fetched = false + modelsDev.lastFetch = time.Time{} + modelsDev.negatives = nil + modelsDev.mu.Unlock() +} + +// lookup 查询一个模型的 (context, output, found): +// - 索引命中且值合法 → found=true; +// - 索引未命中(含索引尚未就绪)→ 记入 negatives(既是 24h 负缓存,也是 +// fetchDoc 成功后 backfillMisses 的回流清单——「曾 miss 过的模型」),found=false。 +// +// 只读内存索引,不发网络请求;网络动作由 ensureDocAsync(goroutine 内)负责。 +// 注意:doc 就绪前的 miss 也记 negatives——backfillMisses 回流时查到即写 +// model.json 并清除负缓存条目(freshLookup),查不到的保持 24h 负缓存。 +func (f *modelsDevFetcher) lookup(model string) (modelsDevEntry, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if f.doc != nil { + if e, ok := f.doc[model]; ok { + return e, true + } + } + // 未命中(索引在但模型不在,或索引尚未就绪):记 miss。 + if f.negatives == nil { + f.negatives = make(map[string]time.Time) + } + now := time.Now() + // 未命中「重复查询」不再刷新记录时刻:TTL 只从**首次未命中**起算。 + // 否则覆盖写等于每次查询都把条目续期——一个持续出现在 /v1/models 名单里的 + // 未知模型(每条 listing 经 ContextWindowListingV4 + MaxOutputTokensListingV4 + // 各查一次,即每请求 2 次写回)其条目永远 fresh,下面的惰性淘汰一条也扫不掉; + // 又因淘汰只在超软上限时才扫,规模超限后只增不减(进程生命周期内无界增长)。 + // 续期对行为零影响:negativeFresh 只判是否存在 + within TTL,未过 TTL 的条目 + // 无论是否续期都同样短路第 4 级触发。 + if _, seen := f.negatives[model]; !seen { + f.negatives[model] = now + } + // 惰性淘汰已过期的负缓存条目:TTL 到期后 negativeFresh 本就判 false(等效不存在), + // 条目继续留着只是内存泄漏——models.dev 永不收录的模型名(model 由客户端任意指定) + // 查一次就永久驻留,而全库唯一的删除点 backfillMisses 只删「文档里查到」的模型, + // 永远不会回收这些条目,map 在进程生命周期内无界增长。 + // 仅在规模超软上限时才扫一遍(摊销 O(1),理由见 modelsDevNegativesSoftCap 注释)。 + if len(f.negatives) > modelsDevNegativesSoftCap { + for m, t := range f.negatives { + if now.Sub(t) >= modelsDevNegativeTTL { + delete(f.negatives, m) + } + } + } + // 硬上限兜底:TTL 未到期的条目本就无可淘汰(上一轮扫描已删净过期项),若规模 + // 仍超硬上限说明输入模型名太多,按时间序丢弃最旧条目、削回软上限 + // (纯性能优化:负缓存 miss 只会多查一次进程内索引,丢条目无语义影响)。 + if len(f.negatives) > modelsDevNegativesHardCap { + cut := len(f.negatives) - modelsDevNegativesSoftCap + oldest := make([]string, 0, len(f.negatives)) + for m := range f.negatives { + oldest = append(oldest, m) + } + sort.Slice(oldest, func(i, j int) bool { return f.negatives[oldest[i]].Before(f.negatives[oldest[j]]) }) + for _, m := range oldest[:cut] { + delete(f.negatives, m) + } + } + return modelsDevEntry{}, false +} + +// negativeFresh 模型是否在负缓存有效期内(供查找链短路第 4 级触发)。 +func (f *modelsDevFetcher) negativeFresh(model string) bool { + f.mu.Lock() + defer f.mu.Unlock() + t, ok := f.negatives[model] + return ok && time.Since(t) < modelsDevNegativeTTL +} + +// ensureDocAsync 确保 models.dev 文档索引可用(异步,不阻塞调用方): +// 已有索引 / 拉取冷却期内 / 已有人在拉(in-flight 去重)→ 直接返回。 +// 否则起 goroutine 拉取解析,完成后落 f.doc(失败静默:只刷新 lastFetch 冷却, +// 下次查找仍走 1M 兜底,不重试风暴)。 +func (f *modelsDevFetcher) ensureDocAsync(client *http.Client, baseOverride string) { + f.mu.Lock() + if f.doc != nil { + f.mu.Unlock() + return + } + if f.fetched && time.Since(f.lastFetch) < modelsDevFetchCooldown { + // 拉取过(成功或失败)且冷却期内:不再打 models.dev。 + f.mu.Unlock() + return + } + // in-flight 去重:把 fetched/lastFetch 先置为「本次进行中」, + // 后续并发调用在冷却窗口内直接返回,不重复起拉取。 + f.fetched = true + f.lastFetch = time.Now() + f.mu.Unlock() + + go f.fetchDoc(client, baseOverride) +} + +// fetchDoc 拉取并解析 models.dev 文档,建裸 id 索引(goroutine 内执行,永不 panic +// 上抛:任何失败只静默冷却)。 +// 拒绝 nil client(不回落 http.DefaultClient):生产调用方恒传非 nil,nil 只意味着 +// 测试疏漏——DefaultClient 无超时(挂起隐患)且会打真网(测试污染 + 不确定延迟), +// 静默 WARN + 返回(与 fetch 失败同语义,降级 1M 兜底)让疏漏显式化。 +func (f *modelsDevFetcher) fetchDoc(client *http.Client, baseOverride string) { + if client == nil { + log.Printf("WARN: [upstream] models.dev fetch: nil client rejected (no DefaultClient fallback, silent fallback to 1M)") + return + } + url := ModelsDevURL + if baseOverride != "" { + url = baseOverride + } + ctx, cancel := context.WithTimeout(context.Background(), modelsDevTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Printf("WARN: [upstream] models.dev fetch: build request: %v", err) + return + } + resp, err := client.Do(req) + if err != nil { + log.Printf("WARN: [upstream] models.dev fetch failed (silent fallback to 1M): %v", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("WARN: [upstream] models.dev fetch status %d (silent fallback to 1M)", resp.StatusCode) + return + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, modelsDevMaxBody)) + if err != nil { + log.Printf("WARN: [upstream] models.dev fetch read: %v", err) + return + } + doc, err := parseModelsDevDoc(raw) + if err != nil { + log.Printf("WARN: [upstream] models.dev parse failed (silent fallback to 1M): %v", err) + return + } + f.mu.Lock() + f.doc = doc + f.mu.Unlock() + // 文档就绪后把「曾 miss 过的模型」回流 model.json(第 4 级 → 第 3 级, + // 下次 /v1/models 直接命中缓存)。仍查不到的保持负缓存。 + f.backfillMisses() +} + +// parseModelsDevDoc 解析 models.dev api.json:{provider:{models:{id:{limit:{context, +// output}}}}} → 裸 id 索引。同名多 provider 采值优先级四级:官方 vendor 源 +// (modelsDevVendorSources)> 票数众数 > provider 字典序 > 先出现。 +// 第 3 级 provider 字典序是确定性 tie-break:聚合时维护候选的最小 provider 名 +// (minProvider,同 doc 稳定的选择器身份),消灭 map 迭代序随机化导致的 +// 「同票先到先得」值抖动(同 binary 两次拉取同一文档可能落不同的值进 model.json, +// /v1/models 的 context_length 不可复现)。不引入「值字典序」——那会把 +// 「选谁」变成「选什么值」的启发式,语义不如 provider 名干净。 +func parseModelsDevDoc(raw []byte) (map[string]modelsDevEntry, error) { + var doc map[string]struct { + Models map[string]struct { + Limit *struct { + Context int64 `json:"context"` + Output int64 `json:"output"` + } `json:"limit"` + } `json:"models"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("models.dev doc: %w", err) + } + // 同名 id 的候选值收集:vendorOfficial 标记官方源,votes 计众数, + // minProvider 维护该候选已见的最小 provider 名(tie-break 用)。 + type candidate struct { + entry modelsDevEntry + vendor bool + votes int + aggKey string // 去重聚合 key(同值多 provider 只计票不重复存) + minProvider string + } + byModel := map[string][]candidate{} + for provider, pv := range doc { + for fullID, mv := range pv.Models { + if mv.Limit == nil { + continue + } + id := fullID + if i := strings.LastIndex(fullID, "/"); i >= 0 { + id = fullID[i+1:] + } + if id == "" { + continue + } + // 值校验(任务书 §2):正数 + 量级上限,脏值不进索引。 + ctx, out := mv.Limit.Context, mv.Limit.Output + if ctx <= 0 || ctx > modelsDevValueMax { + continue + } + if out < 0 || out > modelsDevValueMax { + continue + } + key := fmt.Sprintf("%d/%d", ctx, out) + cs := byModel[id] + dup := false + for i := range cs { + if cs[i].aggKey == key { + cs[i].votes++ + if modelsDevVendorSources[provider] { + cs[i].vendor = true + } + if provider < cs[i].minProvider { + cs[i].minProvider = provider + } + dup = true + break + } + } + if !dup { + byModel[id] = append(cs, candidate{ + entry: modelsDevEntry{Context: ctx, Output: out}, + vendor: modelsDevVendorSources[provider], + votes: 1, + aggKey: key, + minProvider: provider, + }) + } + } + } + out := make(map[string]modelsDevEntry, len(byModel)) + for id, cs := range byModel { + best := 0 + for i, c := range cs { + // 优先级:官方 vendor 源 > 票数众数 > provider 字典序(tie-break 确定性)。 + cur := cs[best] + better := false + if c.vendor && !cur.vendor { + better = true + } else if c.vendor == cur.vendor && c.votes > cur.votes { + better = true + } else if c.vendor == cur.vendor && c.votes == cur.votes && c.minProvider < cur.minProvider { + better = true + } + if better { + best = i + } + } + out[id] = cs[best].entry + } + return out, nil +} diff --git a/internal/upstream/payload.go b/internal/upstream/payload.go new file mode 100644 index 0000000000000000000000000000000000000000..45b29d477ca004dffdf4a169f2da1f9ca800880b --- /dev/null +++ b/internal/upstream/payload.go @@ -0,0 +1,401 @@ +// payload.go 改写发往上游的 chat 请求体: +// 1. 强制 stream:true(上游拒绝非流式) +// 2. tool_choice 归一化(上游该字段是 string,对象形式会 400 code=11101) +// 3. image_url 归一化(上游只认 OpenAI 对象形态,字符串会 400 code=11101) +package upstream + +import ( + "encoding/json" + "log" + "strings" +) + +// PrepareBodyOpt 单 pass 改写;sanitize=false 时行为完全还原(仅强制 stream + 归一化 tool_choice)。 +func PrepareBodyOpt(src []byte, sanitize bool) []byte { + return PrepareBodyOptWithEffortsAndDefault(src, sanitize, nil, nil) +} + +// PrepareBodyOptWithEfforts 在 PrepareBodyOpt 基础上按模型 supportedEfforts 降级 reasoning_effort: +// 仅当请求显式携带且模型不支持该档位时,改为 ≤请求档位的最高支持档;支持档全部高于请求档时取最低档; +// 未知模型/未知档位/未携带该字段一律透传。efforts 为 nil 表示未知(不降级)。 +// +// 向后兼容封装:不传 defaultEfforts(无模型声明默认档),thinking.go 回退硬编码 high。 +func PrepareBodyOptWithEfforts(src []byte, sanitize bool, efforts map[string][]string) []byte { + return PrepareBodyOptWithEffortsAndDefault(src, sanitize, efforts, nil) +} + +// PrepareBodyOptWithEffortsAndDefault 完整管线:efforts 降级 + thinking.go 按 +// defaultEfforts(模型声明默认档)补档。defaultEfforts 为 nil 时与旧行为一致 +// (deepseek 缺档回退硬编码 high)。 +func PrepareBodyOptWithEffortsAndDefault(src []byte, sanitize bool, efforts map[string][]string, defaultEfforts map[string]string) []byte { + if len(src) == 0 { + return src + } + var obj map[string]any + if err := json.Unmarshal(src, &obj); err != nil { + return src + } + obj["stream"] = true + // max_completion_tokens → max_tokens 翻译(吸收上游 PR #116,Closes #117): + // OpenAI 规范里 max_tokens 已 deprecated、max_completion_tokens 是新字段; + // DeepSeek Harness 等新客户端只发别名。WorkBuddy 上游(CN /v2 与 global + // /console 同源)只认 max_tokens——别名透传会被上游忽略后回落默认输出上限 + // (实测 32000),长流任务被截。 + translateMaxCompletionTokens(obj) + // stream_options 仅当 body 未显式带时补 {include_usage: true}(D7): + // 官方 CLI 流式必发该字段,上游据此在末帧返回 usage 用量;显式带则不覆盖。 + if _, has := obj["stream_options"]; !has { + obj["stream_options"] = map[string]any{"include_usage": true} + } + normalizeToolChoice(obj) + normalizeToolPatterns(obj) + normalizeRoles(obj) + normalizeImageURL(obj) + // tool 配对两步(见 tool_pairing.go):先重排再清理。所有模型一律执行(独立于 + // deepseek-only 的 sanitize 开关)。这是「让请求通过」的安全网——不完整配对的 + // tool_calls/tool 结果会让上游对之后每条消息都返 400,必须先行剔除; + // 插在结果中间的非 tool 消息(Codex image_resize_notice)同样判配对断裂, + // 先 repack 挪后,再 cleanup 删孤儿,两侧同口径。 + if msgs, ok := obj["messages"].([]any); ok { + msgs, _ = repackToolResultBlocks(msgs) + msgs, _ = cleanupOrphanToolCalls(msgs) + // 无改动时两步都返回原 slice,这里回写等于零操作;任一步重排/删除 + // (哪怕后续步骤零改动)也必须落到 obj——不能只在「最后一步改动」时回写, + // 否则 repack 单独生效的结果会被原 slice 覆盖丢失。 + obj["messages"] = msgs + } + // DeepSeek 思维链开关(见 thinking.go):注入 thinking.type=enabled + 缺档补默认档。 + // 先于 normalizeReasoningEffort 执行:补入的默认档也要走既有降级管线, + // 模型不支持默认档时自动落到 ≤ 默认档的最高支持档(不出站不合规档位)。 + modelName, _ := obj["model"].(string) + injectThinking(obj, lookupDefaultEffort(defaultEfforts, modelName)) + normalizeReasoningEffort(obj, efforts) + // DeepSeek 多轮一致性:assistant 消息带 reasoning 痕迹时回填 reasoning_content + // (requiresReasoningContentOnAssistantMessages,见 thinking.go)。 + backfillReasoningContent(obj) + if sanitize { + if msgs, ok := obj["messages"].([]any); ok { + sanitizeMessages(msgs) + } + } + out, err := json.Marshal(obj) + if err != nil { + return src + } + return out +} + +// translateMaxCompletionTokens 把 OpenAI 别名 max_completion_tokens 翻译为上游 +// 认的 max_tokens(吸收上游 PR #116)。规则:显式 max_tokens 优先(别名只删); +// 别名非正数值(0/null/负数)不翻译(0/null 语义是「未设置」,负数是非法值, +// 翻译等于把垃圾搬进 max_tokens);非数值别名(字符串等畸形)不翻译(原样 +// 透传由上游报 11101 参数错)。两域同口径:CN /v2 与 global /console 是同一套 +// API,翻译不分 realm。 +func translateMaxCompletionTokens(obj map[string]any) { + alias, has := obj["max_completion_tokens"] + delete(obj, "max_completion_tokens") // 无论翻译与否,别名一律删(减少 body 体积与排障噪音) + if !has { + return + } + if _, explicit := obj["max_tokens"]; explicit { + return // 显式 max_tokens 优先:别名只删不译 + } + // json.Unmarshal 数字 → float64(整数去整后回写,避免 1.28e5 科学计数法/小数 + // 尾巴进上游 body);其他数值类型防御性兼容(int 家族——手构造 map 的调用方)。 + switch v := alias.(type) { + case float64: + if v > 0 && v == float64(int64(v)) { + obj["max_tokens"] = int64(v) + } + case int64: + if v > 0 { + obj["max_tokens"] = v + } + case int: + if v > 0 { + obj["max_tokens"] = int64(v) + } + } +} + +// effortRank 档位从低到高。 +var effortRank = map[string]int{"off": 0, "minimal": 1, "low": 2, "medium": 3, "high": 4, "xhigh": 5, "max": 6} + +// normalizeReasoningEffort 按模型 supportedEfforts 降级 reasoning_effort(snake/camel 双字段兼容)。 +// - 请求档位模型支持 → 原样透传 +// - 请求档位不支持 → 改为 ≤请求档位的最高支持档(降级) +// - 支持档全部高于请求档 → 取最低支持档(偏离最小) +// - 未知模型/未知档位/未携带字段/模型未缓存 → 一律透传 +func normalizeReasoningEffort(obj map[string]any, efforts map[string][]string) { + if len(efforts) == 0 { + return + } + model, _ := obj["model"].(string) + if model == "" { + return + } + supported, ok := efforts[model] + if !ok || len(supported) == 0 { + return + } + key := "" + if _, present := obj["reasoning_effort"]; present { + key = "reasoning_effort" + } else if _, present := obj["reasoningEffort"]; present { + key = "reasoningEffort" + } else { + return + } + reqStr, ok := obj[key].(string) + if !ok { + return + } + reqStr = strings.TrimSpace(strings.ToLower(reqStr)) + reqIdx, known := effortRank[reqStr] + if !known { + return + } + // 在 ≤请求档位的支持档里选最高档;命中且与请求不同才改写。 + best, bestIdx := "", -1 + for _, s := range supported { + idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))] + if k && idx <= reqIdx && idx > bestIdx { + best, bestIdx = s, idx + } + } + if best != "" { + if !strings.EqualFold(best, reqStr) { + obj[key] = best + log.Printf("reasoning_effort downgraded model=%s %s -> %s", model, reqStr, best) + } + return + } + // 支持档全部高于请求档:取最低支持档。 + lowest, lowestIdx := "", 1<<30 + for _, s := range supported { + idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))] + if k && idx < lowestIdx { + lowest, lowestIdx = s, idx + } + } + if lowest != "" { + obj[key] = lowest + log.Printf("reasoning_effort floored model=%s %s -> %s", model, reqStr, lowest) + } +} + +// normalizeRoles 把 messages 里的 developer 角色归一为 system。 +// +// 背景:上游对 messages 的 role 字段做白名单校验,developer 不在白名单内, +// 命中即 HTTP 400 code=11128。developer 是 OpenAI 新规范里 system 的别名 +// (Codex / Cursor 等新客户端用它承载 system 级指令),改写为 system 不丢语义。 +// +// 此归一化是「协议兼容」(补上游 role 白名单),不是「内容脱敏」, +// 因此有意与 SanitizeFingerprints / sanitize 参数解耦:即使 sanitize=false 也照常归一。 +// +// 只认 developer 这一个值:其余 role(system/user/assistant/tool/任意未知值)一律原样保留, +// 不合并、不重排、不删除任何消息(上游对多 system 的行为尚未实测,合并会引入新变量)。 +func normalizeRoles(obj map[string]any) { + msgs, ok := obj["messages"].([]any) + if !ok { + return + } + for i, m := range msgs { + msg, ok := m.(map[string]any) + if !ok { + continue + } + role, ok := msg["role"].(string) + if !ok { + continue + } + if strings.EqualFold(strings.TrimSpace(role), "developer") { + msg["role"] = "system" + log.Printf("role normalized developer->system idx=%d", i) + } + } +} + +// normalizeImageURL 兼容 OpenAI chat 多模态内容的两种 image_url 写法。 +// +// OpenAI Chat Completions 规范使用对象形态 {"url":"...","detail":"..."}, +// 部分客户端(以及 Responses -> Chat 转换器)会发送字符串形态 "data:..." 或 +// "https://..."。WorkBuddy 上游只接受对象形态,字符串会返回 400 code=11101 +// "cannot unmarshal string into ... ImageContent"。 +// +// 这里只做形状转换:字符串转 {"url": 原值};已有对象及其中 url/detail/mime_type +// 原样保留;空字符串、缺失值、对象内非法 url 一律不补默认值,让上游返回真实错误。 +func normalizeImageURL(obj map[string]any) { + msgs, ok := obj["messages"].([]any) + if !ok { + return + } + for _, rawMsg := range msgs { + msg, ok := rawMsg.(map[string]any) + if !ok { + continue + } + parts, ok := msg["content"].([]any) + if !ok { + continue + } + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok || part["type"] != "image_url" { + continue + } + imageURL, ok := part["image_url"].(string) + if !ok || imageURL == "" { + continue + } + part["image_url"] = map[string]any{"url": imageURL} + } + } +} + +// ensureConsoleSystem global realm 兜底 system 注入(吸收 PR #45,防 console 域上游 code 11-128): +// 首条消息非 system 时在 messages 最前补一条 fallback system("You are a helpful assistant.")。 +// 仅对 global 请求调用(CN 现状不动;即使首条就是 system 也不重复注入)。 +// body 不可解析时原样返回(与 prepareBody 语义一致:坏 body 不在这里二次错误化)。 +func ensureConsoleSystem(body []byte) []byte { + if len(body) == 0 { + return body + } + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + return body + } + msgs, ok := obj["messages"].([]any) + if !ok || len(msgs) == 0 { + return body + } + first, ok := msgs[0].(map[string]any) + if ok { + if role, _ := first["role"].(string); strings.EqualFold(strings.TrimSpace(role), "system") { + return body // 首条已是 system:不注入 + } + } + obj["messages"] = append([]any{map[string]any{"role": "system", "content": "You are a helpful assistant."}}, msgs...) + out, err := json.Marshal(obj) + if err != nil { + return body + } + return out +} + +// normalizeToolChoice 按上游 Go struct(string 类型)改写 OpenAI tool_choice。 +// - "none" → 删 tool_choice + 删 tools/functions +// - {"type":"none"} → 同上 +// - {"type":"auto"/"required"} → 字符串 "auto"/"required" +// - {"type":"function","function":{"name":"x"}} → 字符串 "x" +// - 其他对象/非标量 → 删 tool_choice +func normalizeToolChoice(obj map[string]any) { + suppress := func() { + delete(obj, "tools") + delete(obj, "functions") + } + tc, present := obj["tool_choice"] + if !present { + return + } + switch v := tc.(type) { + case string: + if strings.EqualFold(strings.TrimSpace(v), "none") { + delete(obj, "tool_choice") + suppress() + } + case map[string]any: + typ, _ := v["type"].(string) + typ = strings.ToLower(strings.TrimSpace(typ)) + switch typ { + case "none": + delete(obj, "tool_choice") + suppress() + case "auto", "required": + obj["tool_choice"] = typ + case "function": + name := "" + if fn, ok := v["function"].(map[string]any); ok { + name, _ = fn["name"].(string) + } + if name == "" { + name, _ = v["name"].(string) + } + if name = strings.TrimSpace(name); name != "" { + obj["tool_choice"] = name + } else { + obj["tool_choice"] = "auto" + } + default: + delete(obj, "tool_choice") + } + default: + delete(obj, "tool_choice") + } +} + +// normalizeToolPatterns 归一化 tools 子树里 pattern 的非标准转义 `\_`(→ `_`)。 +// +// 上游对 tools[].function.parameters 做严格 JSON Schema/正则文法校验,pattern 含 +// `\_`(转义的字面量下划线)会整体拒收:400 code=11129 invalid_function_call_ +// parameters(displayMsg「工具定义不合规」)。`\_` 不是任何正则文法的合法转义, +// 但所有主流引擎(RE2/PCRE/JS Annex B)都宽容地视为 `_` 本身——上游校验器比它们 +// 全部更严(对照 V8 严格文法 u 标志,唯一同样拒绝的实现)。实案:ZCode 的 exa 插件 +// agent_run 工具 runId/previousRunId 带 `^agent\_run\_`,deepseek 系全家确定性 400 +// → 网关侧归 ErrClient 只换号不罚但喂连败计数 → 轮转烧满 5 连败触发连败降权、 +// 客户端 503(2026-09-29/30 两次实案)。schema 级拒绝换账号无用,只能在发送前修。 +// +// 归一无损:`\_` 与 `_` 在所有引擎匹配语义相同(各引擎实测 + 上游对照探针:归一后 +// 200),工具方功能不变。只动 tools 子树(pattern 值 + patternProperties 键); +// 消息正文里的 `\_`(如 Windows 路径 C:\_x)不碰。其余非标转义(`\:` 等)未证实 +// 触发,不扩面——有实案再议。独立于 sanitize 开关:这是「让请求通过」,不是脱敏。 +func normalizeToolPatterns(obj map[string]any) { + rawTools, ok := obj["tools"].([]any) + if !ok { + return + } + for _, raw := range rawTools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + // OpenAI 形态 tools[].function.parameters;裸 tools[].parameters 兼容。 + if fn, ok := tool["function"].(map[string]any); ok { + unescapePatternLiteralEscapes(fn["parameters"]) + } + unescapePatternLiteralEscapes(tool["parameters"]) + } +} + +// unescapePatternLiteralEscapes 递归改写 schema 树里 pattern 值与 patternProperties +// 键中的 `\_` → `_`(patternProperties 的键也是正则;map 键不可原地改,命中时重建 +// 该层)。 +func unescapePatternLiteralEscapes(node any) { + switch n := node.(type) { + case map[string]any: + if p, ok := n["pattern"].(string); ok && strings.Contains(p, `\_`) { + n["pattern"] = strings.ReplaceAll(p, `\_`, `_`) + } + if props, ok := n["patternProperties"].(map[string]any); ok { + rebuilt := false + fixed := make(map[string]any, len(props)) + for k, v := range props { + if strings.Contains(k, `\_`) { + k = strings.ReplaceAll(k, `\_`, `_`) + rebuilt = true + } + fixed[k] = v + } + if rebuilt { + n["patternProperties"] = fixed + } + } + for _, v := range n { + unescapePatternLiteralEscapes(v) + } + case []any: + for _, v := range n { + unescapePatternLiteralEscapes(v) + } + } +} diff --git a/internal/upstream/payload_test.go b/internal/upstream/payload_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3888e204b089aea63f710f93ca71c536b37f75eb --- /dev/null +++ b/internal/upstream/payload_test.go @@ -0,0 +1,430 @@ +package upstream + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// TestNormalizeRoles 验证出站请求体把 developer 角色归一为 system。 +// 上游 role 白名单不含 developer(OpenAI 新规范的 system 别名), +// 命中即 HTTP 400 code=11128;此处走 PrepareBodyOptWithEfforts 全链路断言。 +func TestNormalizeRoles(t *testing.T) { + cases := []struct { + name string + body string + wantRoles []string // 与输出 messages 逐条对应的期望 role;len 即消息数 + }{ + {"developer 改写为 system", + `{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}}, + {"Developer 首字母大写改写", + `{"messages":[{"role":"Developer","content":"x"}]}`, []string{"system"}}, + {"DEVELOPER 全大写改写", + `{"messages":[{"role":"DEVELOPER","content":"x"}]}`, []string{"system"}}, + {"前后空白 TrimSpace 后改写", + `{"messages":[{"role":" developer ","content":"x"}]}`, []string{"system"}}, + {"system 原样保留", + `{"messages":[{"role":"system","content":"x"}]}`, []string{"system"}}, + {"user 原样保留", + `{"messages":[{"role":"user","content":"x"}]}`, []string{"user"}}, + {"assistant 原样保留", + `{"messages":[{"role":"assistant","content":"x"}]}`, []string{"assistant"}}, + {"tool 原样保留(不因未知而改写)", + `{"messages":[{"role":"tool","content":"x"}]}`, []string{"tool"}}, + {"messages 缺失不 panic 且其余字段不变", + `{"model":"glm-5.2"}`, []string{}}, + {"messages 为空数组不 panic", + `{"messages":[]}`, []string{}}, + {"混合消息仅 developer 被改写", + `{"messages":[{"role":"developer","content":"a"},{"role":"user","content":"b"},{"role":"developer","content":"c"}]}`, + []string{"system", "user", "system"}}, + {"sanitize=false 时仍归一(与脱敏解耦)", + `{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}}, + {"非对象消息元素跳过、其余正常处理", + `{"messages":["str",{"role":"developer","content":"x"},42]}`, []string{"system"}}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // 全程 sanitize=false:验证 role 归一与内容脱敏开关无关(D4)。 + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal: %v (out=%s)", err, out) + } + + // 提取输出 messages 里的 role(非对象元素跳过,不 panic)。 + var got []string + if msgs, ok := obj["messages"].([]any); ok { + for _, m := range msgs { + msg, ok := m.(map[string]any) + if !ok { + continue + } + if role, ok := msg["role"].(string); ok { + got = append(got, role) + } + } + } + + if len(got) != len(c.wantRoles) { + t.Fatalf("role 数量不符: got %v (%d) want %v (%d)", got, len(got), c.wantRoles, len(c.wantRoles)) + } + for i := range got { + if got[i] != c.wantRoles[i] { + t.Errorf("role[%d] = %q want %q", i, got[i], c.wantRoles[i]) + } + } + }) + } + + // messages 缺失时,其余字段必须原样保留(除强制 stream)。 + t.Run("messages 缺失时其余字段不变", func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","temperature":0.7}`), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if obj["model"] != "glm-5.2" || obj["temperature"] != 0.7 { + t.Errorf("其余字段被改动: %v", obj) + } + }) +} + +func TestPrepareBodyOptWithEfforts(t *testing.T) { + efforts := map[string][]string{ + "glm-5.2": {"off", "low", "high"}, + "glm-5.2-mini": {"low", "medium"}, + "glm-5.2-max": {"high", "xhigh"}, + } + cases := []struct { + name string + body string + efforts map[string][]string + wantKey string // 输出应带有的 effort 字段名;空表示该字段应不存在 + wantVal string // 期望值 + }{ + {"downgrade to highest supported at or below request", + `{"model":"glm-5.2-mini","reasoning_effort":"high"}`, efforts, "reasoning_effort", "medium"}, + {"floor to lowest when all supported above request", + `{"model":"glm-5.2-max","reasoning_effort":"low"}`, efforts, "reasoning_effort", "high"}, + {"supported effort passes through unchanged", + `{"model":"glm-5.2","reasoning_effort":"low"}`, efforts, "reasoning_effort", "low"}, + {"camelCase field name downgrades and keeps key", + `{"model":"glm-5.2-mini","reasoningEffort":"high"}`, efforts, "reasoningEffort", "medium"}, + {"unknown model passes through", + `{"model":"unknown","reasoning_effort":"max"}`, efforts, "reasoning_effort", "max"}, + {"unknown effort value passes through", + `{"model":"glm-5.2","reasoning_effort":"ultra"}`, efforts, "reasoning_effort", "ultra"}, + {"empty cache passes through", + `{"model":"glm-5.2","reasoning_effort":"max"}`, map[string][]string{}, "reasoning_effort", "max"}, + {"no effort field untouched", + `{"model":"glm-5.2-mini","messages":[]}`, efforts, "", ""}, + {"nil efforts map passes through", + `{"model":"glm-5.2","reasoning_effort":"max"}`, nil, "reasoning_effort", "max"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, c.efforts) + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, out) + } + if c.wantKey == "" { + if _, ok := m["reasoning_effort"]; ok { + t.Errorf("reasoning_effort should be absent, got %v", m["reasoning_effort"]) + } + if _, ok := m["reasoningEffort"]; ok { + t.Errorf("reasoningEffort should be absent, got %v", m["reasoningEffort"]) + } + return + } + got, ok := m[c.wantKey].(string) + if !ok || got != c.wantVal { + t.Errorf("%s: got %v (%T) want %q", c.wantKey, m[c.wantKey], m[c.wantKey], c.wantVal) + } + }) + } +} + +// TestPrepareBodyStreamOptions body 未显式带 stream_options 时注入 +// {include_usage: true}(D7,官方 CLI 流式必发);body 已带则不覆盖。 +func TestPrepareBodyStreamOptions(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","messages":[]}`), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal: %v (out=%s)", err, out) + } + so, ok := obj["stream_options"].(map[string]any) + if !ok { + t.Fatalf("stream_options not injected: %v", obj["stream_options"]) + } + if so["include_usage"] != true { + t.Errorf("stream_options.include_usage = %v want true", so["include_usage"]) + } + + out2 := PrepareBodyOptWithEffertsPreserve(t, `{"model":"glm-5.2","messages":[],"stream_options":{"include_usage":false}}`) + obj2, err := decodeBody(out2) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + so2, ok := obj2["stream_options"].(map[string]any) + if !ok { + t.Fatalf("stream_options lost: %v", obj2["stream_options"]) + } + if so2["include_usage"] != false { + t.Errorf("stream_options.include_usage = %v want false (not overwritten)", so2["include_usage"]) + } +} + +// PrepareBodyOptWithEffertsPreserve helper:PrepareBodyOptWithEfforts 包装。 +func PrepareBodyOptWithEffertsPreserve(t *testing.T, body string) []byte { + t.Helper() + return PrepareBodyOptWithEfforts([]byte(body), false, nil) +} + +// decodeBody helper:解析 body JSON。 +func decodeBody(b []byte) (map[string]any, error) { + var obj map[string]any + err := json.Unmarshal(b, &obj) + return obj, err +} + +// TestPrepareBodyDeterministic 序列化稳定性:同输入跑多遍出站字节级一致 +// (prompt_cache_key 前缀命中的前提——链中不得注入时间/随机/ID 类不确定源)。 +func TestPrepareBodyDeterministic(t *testing.T) { + inputs := []string{ + `{"model":"glm-5.2","messages":[{"role":"system","content":"你是助手"},{"role":"user","content":"你好"}],"reasoning_effort":"high"}`, + `{"model":"deepseek-v4","messages":[{"role":"user","content":"写个函数"}],"tool_choice":{"type":"auto"},"tools":[{"type":"function","function":{"name":"f"}}]}`, + `{"model":"glm-5.3","messages":[{"role":"developer","content":"sys"},{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, + } + for i, in := range inputs { + var first []byte + for round := 0; round < 5; round++ { + out := PrepareBodyOptWithEfforts([]byte(in), true, map[string][]string{"glm-5.2": {"off", "low", "high"}}) + if round == 0 { + first = out + continue + } + if string(out) != string(first) { + t.Fatalf("input #%d round %d differs from round 0:\n%s\n%s", i, round, first, out) + } + } + } +} + +// TestNormalizeImageURL 覆盖 OpenAI chat 多模态内容的 image_url 兼容: +// 字符串形态必须转为上游需要的对象形态;对象形态及其中字段必须原样保留; +// 无效输入不补默认值,继续交给上游返回真实错误。 +func TestNormalizeImageURL(t *testing.T) { + tests := []struct { + name string + body string + want any + }{ + { + name: "data url string to object", + body: `{"messages":[{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":"data:image/png;base64,QUJD"}]}]}`, + want: map[string]any{"url": "data:image/png;base64,QUJD"}, + }, + { + name: "http url string to object", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":"https://example.test/a.png"}]}]}`, + want: map[string]any{"url": "https://example.test/a.png"}, + }, + { + name: "object with detail preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD","detail":"low","mime_type":"image/png"}}]}]}`, + want: map[string]any{"url": "data:image/png;base64,QUJD", "detail": "low", "mime_type": "image/png"}, + }, + { + name: "invalid object url type preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":123}}]}]}`, + want: map[string]any{"url": float64(123)}, + }, + { + name: "missing image url preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url"}]}]}`, + want: nil, + }, + { + name: "empty string preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":""}]}]}`, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, sanitize := range []bool{false, true} { + out := PrepareBodyOptWithEfforts([]byte(tc.body), sanitize, nil) + obj, err := decodeBody(out) + if err != nil { + t.Fatalf("sanitize=%v unmarshal: %v (out=%s)", sanitize, err, out) + } + msgs := obj["messages"].([]any) + content := msgs[0].(map[string]any)["content"].([]any) + var part map[string]any + for _, rawPart := range content { + candidate, ok := rawPart.(map[string]any) + if ok && candidate["type"] == "image_url" { + part = candidate + break + } + } + if part == nil { + t.Fatal("image_url part not found") + } + if tc.want == nil { + if _, exists := part["image_url"]; exists { + t.Fatalf("sanitize=%v: missing image_url should stay missing, got %#v", sanitize, part) + } + continue + } + if got := part["image_url"]; !reflect.DeepEqual(got, tc.want) { + t.Errorf("sanitize=%v: image_url=%#v want %#v", sanitize, got, tc.want) + } + } + }) + } +} + +// TestNormalizeToolPatterns 工具 schema pattern 的 `\_` 转义归一(11129 实案: +// exa agent_run 的 `^agent\_run\_` 被 deepseek 系确定性拒收,归一后上游 200)。 +// 覆盖:function.parameters 与裸 parameters 两形态、嵌套 schema、patternProperties +// 键、消息正文不碰、无 tools no-op、sanitize 开关两态一致。 +func TestNormalizeToolPatterns(t *testing.T) { + lookupPattern := func(t *testing.T, body string, path ...any) string { + t.Helper() + var obj map[string]any + if err := json.Unmarshal([]byte(body), &obj); err != nil { + t.Fatalf("out not json: %v", err) + } + cur := any(obj) + for _, p := range path { + switch k := p.(type) { + case string: + m, ok := cur.(map[string]any) + if !ok { + t.Fatalf("path %v: want object, got %T", path, cur) + } + cur = m[k] + case int: + l, ok := cur.([]any) + if !ok || k >= len(l) { + t.Fatalf("path %v: bad array at %T", path, cur) + } + cur = l[k] + } + } + s, _ := cur.(string) + return s + } + + t.Run("exa agent_run pattern normalized", func(t *testing.T) { + bs := string(byte(92)) // 反斜杠,测试体经工具链多层转义易被吞,运行时拼装保真 + body := `{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"agent_run","parameters":{"type":"object","properties":{"runId":{"type":"string","pattern":"^agent` + bs + bs + `_run` + bs + bs + `_"}` + `,"query":{"type":"string"}},"required":["query"]}}}]}` //nolint:lll // 实案 body 原样 + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "runId", "pattern"); got != `^agent_run_` { + t.Fatalf("pattern = %q, want %q", got, `^agent_run_`) + } + // 同 schema 的兄弟字段不受影响 + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "query", "type"); got != "string" { + t.Fatalf("sibling field disturbed: %q", got) + } + }) + + t.Run("nested anyOf items pattern normalized", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"anyOf":[{"properties":{"code":{"pattern":"x` + bs + bs + `_y"}}},{"type":"string"}]}}}]}` //nolint:lll + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "anyOf", 0, "properties", "code", "pattern"); got != "x_y" { + t.Fatalf("nested pattern = %q, want %q", got, "x_y") + } + }) + + t.Run("bare tool parameters form normalized", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"name":"t","parameters":{"properties":{"id":{"pattern":"p` + bs + bs + `_q"}}}}]}` + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "parameters", "properties", "id", "pattern"); got != "p_q" { + t.Fatalf("bare parameters pattern = %q, want %q", got, "p_q") + } + }) + + t.Run("patternProperties key normalized", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"patternProperties":{"^a` + bs + bs + `_b` + bs + bs + `_":{"type":"string"}}}}}]}` //nolint:lll + var obj map[string]any + out := PrepareBodyOptWithEfforts([]byte(body), false, nil) + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("out not json: %v", err) + } + tool := obj["tools"].([]any)[0].(map[string]any)["function"].(map[string]any)["parameters"].(map[string]any) + props := tool["patternProperties"].(map[string]any) + if _, ok := props["^a_b_"]; !ok { + t.Fatalf("normalized key missing: %v", props) + } + for k := range props { + if strings.Contains(k, string(byte(92))) { + t.Fatalf("patternProperties key not normalized: %q", k) + } + } + }) + + t.Run("message content backslash untouched", func(t *testing.T) { + bs := string(byte(92)) + raw := `{"messages":[{"role":"user","content":"path C:` + bs + bs + `_dir and regex a` + bs + bs + `_b"}],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"q` + bs + bs + `_r"}}}}}]}` //nolint:lll + out := string(PrepareBodyOptWithEfforts([]byte(raw), false, nil)) + var obj map[string]any + if err := json.Unmarshal([]byte(out), &obj); err != nil { + t.Fatalf("out not json: %v", err) + } + msg := obj["messages"].([]any)[0].(map[string]any) + if got := msg["content"]; got != `path C:\_dir and regex a\_b` { + t.Fatalf("message content mutated: %q", got) + } + }) + + t.Run("clean pattern untouched", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"^[a-z]+` + bs + bs + `d_$"}}}}}]}` //nolint:lll + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != `^[a-z]+\d_$` { + t.Fatalf("clean pattern mutated: %q", got) + } + }) + + t.Run("no tools is no-op", func(t *testing.T) { + bs := string(byte(92)) + body := `{"model":"m","messages":[{"role":"user","content":"a` + bs + bs + `_b"}]}` + var inObj, outObj map[string]any + if err := json.Unmarshal([]byte(body), &inObj); err != nil { + t.Fatal(err) + } + out := PrepareBodyOptWithEfforts([]byte(body), false, nil) + if err := json.Unmarshal(out, &outObj); err != nil { + t.Fatalf("out not json: %v", err) + } + inMsg, _ := json.Marshal(inObj["messages"]) + outMsg, _ := json.Marshal(outObj["messages"]) + if string(inMsg) != string(outMsg) { + t.Fatalf("messages changed without tools: %s -> %s", inMsg, outMsg) + } + if _, has := outObj["tools"]; has { + t.Fatal("tools appeared out of nowhere") + } + }) + + t.Run("sanitize on and off behave the same", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"a` + bs + bs + `_b"}}}}}]}` //nolint:lll + for _, sanitize := range []bool{false, true} { + out := string(PrepareBodyOptWithEfforts([]byte(body), sanitize, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != "a_b" { + t.Fatalf("sanitize=%v: pattern = %q, want %q", sanitize, got, "a_b") + } + } + }) +} diff --git a/internal/upstream/profile.go b/internal/upstream/profile.go new file mode 100644 index 0000000000000000000000000000000000000000..866d416e824076bd8b9577e9d1856fd1571c6825 --- /dev/null +++ b/internal/upstream/profile.go @@ -0,0 +1,51 @@ +// profile.go Web 控制台账号资料(issue #94:改名后免重登同步昵称)。 +// +// GET {webBase}/console/account,Bearer + x-client-platform: web(与 tasks/claim +// 同一鉴权形态,实测 2026-10-01:无需 Web 会话 cookie)。 +// +// 隐私边界(重要):该接口响应包含手机号(phoneNumber)等个人敏感信息。本方法 +// 只解析 nickname 与 uid(uid 仅做一致性核对),其余字段一概不解析、不落日志、 +// 不透传——调用方也拿不到。昵称同步只在面板手动「刷新」时触发,后台余额定时 +// 刷新不调用本接口。 +package upstream + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// FetchAccountProfile 拉取账号资料并返回最新昵称。uid 与凭证不一致时报错 +// (防串号);业务/网络错误原样返回,调用方静默跳过即可。 +func (c *Client) FetchAccountProfile(a *auth.Auth) (string, error) { + req, err := http.NewRequest(http.MethodGet, c.webBase(a)+"/console/account", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("x-client-platform", "web") + req.Header.Set("Origin", "https://www.workbuddy.cn") + req.Header.Set("Referer", "https://www.workbuddy.cn/profile/account-settings") + if ua := c.userAgent(a); ua != "" { + req.Header.Set("User-Agent", ua) + } + data, err := c.doJSON(req) + if err != nil { + return "", err + } + // 只取两个字段:敏感信息(手机号等)在这里就被丢弃,不进入任何后续路径。 + var resp struct { + UID string `json:"uid"` + Nickname string `json:"nickname"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return "", fmt.Errorf("profile parse: %w", err) + } + if resp.UID != "" && a.UID != "" && resp.UID != a.UID { + return "", fmt.Errorf("profile uid mismatch: resp=%s auth=%s", resp.UID, a.UID) + } + return resp.Nickname, nil +} diff --git a/internal/upstream/profile_test.go b/internal/upstream/profile_test.go new file mode 100644 index 0000000000000000000000000000000000000000..adf32b495f2fcc200ecf36553c6c617df1aaa842 --- /dev/null +++ b/internal/upstream/profile_test.go @@ -0,0 +1,50 @@ +// profile_test.go 钉住账号资料拉取(issue #94):Bearer + web 平台头可访问 +// /console/account;只解析 nickname,uid 不一致防串号;业务错误原样返回。 +package upstream + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestFetchAccountProfile(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/console/account") { + return nil, errors.New("wrong path") + } + if r.Header.Get("Authorization") == "" || r.Header.Get("x-client-platform") != "web" { + return nil, errors.New("missing bearer / web platform header") + } + // 响应刻意带 phoneNumber:方法必须只解析 nickname/uid,敏感字段不得进入返回值。 + return jsonResp(200, `{"code":0,"msg":"OK","data":{"uid":"u1","nickname":"新名字","phoneNumber":"13800000000"}}`), nil + }) + c.WebBaseCN = "https://web.example" + nick, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "at"}) + if err != nil || nick != "新名字" { + t.Fatalf("nick=%q err=%v, want 新名字 nil", nick, err) + } +} + +func TestFetchAccountProfileUIDMismatch(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"uid":"someone-else","nickname":"x"}}`), nil + }) + c.WebBaseCN = "https://web.example" + if _, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "at"}); err == nil { + t.Fatal("uid 不一致应报错(防串号)") + } +} + +func TestFetchAccountProfileBusinessError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(401, `{"code":1002,"msg":"unauthorized"}`), nil + }) + c.WebBaseCN = "https://web.example" + if _, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "bad"}); err == nil { + t.Fatal("401 应返回错误") + } +} diff --git a/internal/upstream/report.go b/internal/upstream/report.go new file mode 100644 index 0000000000000000000000000000000000000000..fde5a6582aac881d6645b8ec52118ff14f728492 --- /dev/null +++ b/internal/upstream/report.go @@ -0,0 +1,206 @@ +// report.go growth 域「对话活跃上报」接口:POST {billingBase}/v2/report。 +// 照抄客户端 chat_request_send 事件形状(含 conversationId/mode/inputLength 等全字段, +// 勿用最小 3 字段,防上游后续加严)。事件必须带 userId(=账号 uid),缺失则服务端 +// 200 但静默丢弃(实测见 REPORT-active-map.md §2)。 +// +// 一条上报同时点亮 growth 连登 + 解锁 first_buddy 任务(领养前置)。 +// 风控口径:每号每天 1 次即可(activity_hours 单时点),不做多时点高频上报。 +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// reportPath 活跃上报通道(实测)。 +const reportPath = "/v2/report" + +// billingJSON 发 billing 域(billingBase,codebuddy.cn)请求并解信封;body 为 nil 时不带请求体。 +// 与 travel.go 的 growthJSON 对称(growth 域走 chatBase + BillingHeaders;billing 域走 billingBase)。 +// report/checkin 等 billing 端点共用:请求头统一 BillingHeaders,信封与错误语义同 doJSON。 +func (c *Client) billingJSON(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.billingBase(a)+path, rdr) + if err != nil { + return nil, err + } + c.BillingHeaders(req, a) + return c.doJSON(req) +} + +// billingMeterJSON 仅对 /billing/meter 族端点(get-user-resource / daily-checkin) +// 按 realm 走双路径 fallback:global 先无 /v2 前缀,ErrNotFound 时二次换有 /v2 前缀 +// (上游新旧路径分叉);cn 单路径(有 /v2)现状不变。仅 global realm 才有多路径。 +func (c *Client) billingMeterJSON(a *auth.Auth, paths []string, method string, body any) (json.RawMessage, error) { + var lastErr error + for i, path := range paths { + data, err := c.billingJSON(a, method, path, body) + if err == nil { + return data, nil + } + lastErr = err + // 仅 404 换路径(路径不存在才值得 fallback);其他错误直接返回。 + var ue *Error + if !errors.As(err, &ue) || ue.Kind != ErrNotFound || i == len(paths)-1 { + return nil, err + } + } + return nil, lastErr +} + +// billingRetryDelay 签到/余额等维护类计费调用瞬时错误重试的间隔基数。 +// 独立变量供测试缩短(生产固定 2s:第 1 次重试等 2s、第 2 次等 4s)。 +var billingRetryDelay = 2 * time.Second + +// isTransientBillingErr 报告 err 是否值得对计费维护类调用做有界重试: +// 上游 5xx(ErrServer,实测偶发 "code 10000 / API request failed with status +// code: 500")或网络层错误(非 *Error 的传输失败)。业务错误(code!=0 的 +// 已签到/参数错、4xx、限流)不重试——重试只会原样再失败一次。 +func isTransientBillingErr(err error) bool { + if err == nil { + return false + } + var ue *Error + if errors.As(err, &ue) { + return ue.Kind == ErrServer + } + return true +} + +// retryBillingTransient 对签到/余额这类低频维护调用做瞬时错误有界重试: +// 最多补打 2 次(间隔 2s、4s),首次成功或非瞬时错误立即返回。chat 热路径 +// 不用本策略——它有自己的换号轮转语义,重试会放大在途请求。 +func (c *Client) retryBillingTransient(fn func() error) error { + err := fn() + if err == nil || !isTransientBillingErr(err) { + return err + } + for i := 1; i <= 2; i++ { + time.Sleep(time.Duration(i) * billingRetryDelay) + if err = fn(); err == nil || !isTransientBillingErr(err) { + return err + } + } + return err +} + +// chatRequestEvent 客户端 chat_request_send 事件完整形状(与 probe_active.py chat_event 对齐)。 +// userId 为必填字段(= a.UID);conversationId 由调用方生成,无需真实会话。 +type chatRequestEvent struct { + EventCode string `json:"eventCode"` + Timestamp int64 `json:"timestamp"` + ReportDelay int `json:"reportDelay"` + Mode string `json:"mode"` + ConversationID string `json:"conversationId"` + RequestID string `json:"requestId"` + InputLength int `json:"inputLength"` + RequestModelID string `json:"requestModelId"` + RequestModelName string `json:"requestModelName"` + IsPlan bool `json:"isPlan"` + IsAutoExecuteTerminal bool `json:"isAutoExecuteTerminal"` + IsAutoModify bool `json:"isAutoModify"` + CodebaseEnable bool `json:"codebaseEnable"` + MaxToken int `json:"maxToken"` + MaxSteps int `json:"maxSteps"` + Temperature int `json:"temperature"` + MaxRetries int `json:"maxRetries"` + MentionContexts []any `json:"mentionContexts"` + KnowledgeID []any `json:"knowledgeId"` + KnowledgeName []any `json:"knowledgeName"` + CodebaseID string `json:"codebaseId"` + MentionContextCount int `json:"mentionContextCount"` + Command string `json:"command"` + ExpertID string `json:"expertId"` + RecommendID string `json:"recommendId"` + SkillID string `json:"skillId"` + SkillCount int `json:"skillCount"` + TotalCount int `json:"totalCount"` + FileURI string `json:"fileUri"` + PresentAt int64 `json:"presentAt"` + TraceID string `json:"traceId"` + RootRequestID string `json:"rootRequestId"` + ParentConversationID string `json:"parentConversationId"` + AgentName string `json:"agentName"` + AgentType string `json:"agentType"` + UserID string `json:"userId"` +} + +// ReportChatActivity 向上游发送一条对话活跃上报(chat_request_send)。 +// conversationID 由调用方生成(如 wb2api-),无需真实会话——服务端不校验一致性。 +// requestID 为本轮请求独立标识(多轮同会话上报时各条不同);空时回落 conversationID。 +// 错误语义与 doJSON 一致:HTTP 非 2xx / 业务 code != 0 → *Error。 +func (c *Client) ReportChatActivity(a *auth.Auth, conversationID, requestID string) error { + return c.ReportChatActivityModel(a, conversationID, requestID, "deepseek-v4-flash", "DeepSeek V4 Flash") +} + +// ReportChatActivityModel 同上,但可指定上报携带的模型:供「体验某模型」类任务 +// 对齐实际模型(如 Model_chat_GLM5.2 需 requestModelId=glm-5.2 与独立 requestID)。 +func (c *Client) ReportChatActivityModel(a *auth.Auth, conversationID, requestID, modelID, modelName string) error { + if requestID == "" { + requestID = conversationID + } + if modelID == "" { + modelID = "deepseek-v4-flash" + } + if modelName == "" { + modelName = modelID + } + now := time.Now().UnixMilli() + ev := chatRequestEvent{ + EventCode: "chat_request_send", + Timestamp: now, + ReportDelay: 0, + Mode: "craft", + ConversationID: conversationID, + RequestID: requestID, + InputLength: 12, + RequestModelID: modelID, + RequestModelName: modelName, + IsPlan: false, + IsAutoExecuteTerminal: false, + IsAutoModify: false, + CodebaseEnable: false, + MaxToken: 0, + MaxSteps: 0, + Temperature: 0, + MaxRetries: 0, + MentionContexts: []any{}, + KnowledgeID: []any{}, + KnowledgeName: []any{}, + CodebaseID: "", + MentionContextCount: 0, + Command: "", + ExpertID: "", + RecommendID: "", + SkillID: "", + SkillCount: 0, + TotalCount: 0, + FileURI: "", + PresentAt: now, + TraceID: "", + RootRequestID: requestID, + ParentConversationID: conversationID, + AgentName: "default", + AgentType: "conversation", + UserID: a.UID, + } + raw, err := json.Marshal([]chatRequestEvent{ev}) + if err != nil { + return err + } + _, err = c.billingJSON(a, http.MethodPost, reportPath, json.RawMessage(raw)) + return err +} diff --git a/internal/upstream/report_test.go b/internal/upstream/report_test.go new file mode 100644 index 0000000000000000000000000000000000000000..61eefda8895be924e2d604fc8041d8a21707fc9f --- /dev/null +++ b/internal/upstream/report_test.go @@ -0,0 +1,69 @@ +package upstream + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestReportChatActivitySendsArrayWithUserID 断言出站 body 是数组、含 userId、eventCode 正确。 +func TestReportChatActivitySendsArrayWithUserID(t *testing.T) { + var got []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/report" { + t.Errorf("path=%s want /v2/report", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method=%s want POST", r.Method) + } + raw, _ := io.ReadAll(r.Body) + var arr []map[string]any + if err := json.Unmarshal(raw, &arr); err != nil { + t.Fatalf("body must be a JSON array: %v (body=%s)", err, raw) + } + got = arr + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} + if err := c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u-active"}, "wb2api-123", ""); err != nil { + t.Fatalf("report: %v", err) + } + if len(got) != 1 { + t.Fatalf("events=%d want 1", len(got)) + } + ev := got[0] + if ev["eventCode"] != "chat_request_send" { + t.Errorf("eventCode=%v want chat_request_send", ev["eventCode"]) + } + if ev["userId"] != "u-active" { + t.Errorf("userId=%v want u-active(缺失则服务端 200 但静默丢弃)", ev["userId"]) + } + if ev["conversationId"] != "wb2api-123" { + t.Errorf("conversationId=%v want wb2api-123", ev["conversationId"]) + } + if ev["mode"] != "craft" { + t.Errorf("mode=%v want craft", ev["mode"]) + } + // 出站 body 必须是数组(以 [ 开头),不是单个对象。 +} + +// TestReportChatActivityServerError 业务 code 非 0 返回 *Error。 +func TestReportChatActivityServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`boom`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} + err := c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") + if err == nil { + t.Fatal("want error on 500") + } +} diff --git a/internal/upstream/sanitize.go b/internal/upstream/sanitize.go new file mode 100644 index 0000000000000000000000000000000000000000..c24fb6511f981f642717e201051a1c72634c152e --- /dev/null +++ b/internal/upstream/sanitize.go @@ -0,0 +1,210 @@ +// sanitize.go 出站请求体脱敏:剥离上游内容审核黑名单指纹。 +// +// 背景:客户端(Claude Code 类 CLI)在 system prompt 注入若干固定模板句, +// 上游内容审核按逐字精确匹配拦截(非语义审核),一字改动即可绕过。 +// 策略:键值/header 型指纹整段剥离;承载语义的模板句最小改写(换一词),语义不变。 +package upstream + +import ( + "regexp" + "strings" +) + +// sanitizeFeatures 特征预检:任一命中才进入净化(strings.Contains 快速路径, +// 普通请求全不中 → 原样返回,零分配)。 +var sanitizeFeatures = []string{ + "x-anthropic-billing-header", // header 键值段键名 + "cc_entrypoint=", // 尾随裸键值(截断前缀即可命中) + "You are Claude Code", // 身份句(截断前缀即可命中) + "Main branch (", // 注入指令句(截断前缀即可命中) + "You are a coding agent running in the Codex CLI", // Codex instructions 首段(截断前缀即可命中) + "github.com/anthropics/", // 反馈句里的 Anthropic 仓库链接 + "11128", // 上游反探测:裸数字错误码 +} + +// sanitizeHdrRe 剥离层:header 键名即触发(与值无关),整段删除。 +var sanitizeHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header:[^;\n]*;?\s*`) + +// sanitizeBareHdrRe 兜底层:裸键名(无冒号无值)同样是指纹——2026-09-13 实验 F4 +// 证实 assistant 消息里反引号引用裸键名即触发 11128,而剥离层要求冒号、对裸串无效。 +// 键值形态被整段删除后,残留的裸键名做最小缩写(header→hdr):破坏逐字匹配、 +// 语义不变、保留可读性。大小写不敏感,覆盖 X-Anthropic-... 变体。 +// +// 注意该正则不要求冒号,是 sanitizeHdrRe 的超集——hasFingerprint 与 sanitizeText +// 中两者并用:先删键值形态(sanitizeHdrRe),再缩写残留裸键名(本正则), +// 替换语义不同(整段删除 vs 最小缩写),不可合并为一个正则。 +var sanitizeBareHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header`) + +// sanitizeKvRe 剥离层:尾随裸键值(cc_xxx=...;)循环清理。 +var sanitizeKvRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\n]*;?\s*`) + +// sanitizeRewrites 改写层:全模板句逐字替换(每句只改一个词,语义不变)。 +// +// 身份句的匹配串**不带结尾标点**(只到 "…for Claude" 为止): +// CLI 版这句以句号收尾("…for Claude."),桌面版(claude-desktop-3p / Agent SDK) +// 以逗号接后继内容("…for Claude, running within the Claude Agent SDK.")。 +// 带句号的整句只匹配前者,桌面版会漏网、指纹原样发上游 → 400 code=11128。 +// 去掉结尾标点后两种形态一并覆盖(替换串同样不带标点,让原有标点原样保留)。 +// 注意仍要求 "You are Claude Code, " 前缀,不做更宽的子串替换, +// 以免误伤 TestExactMatchOnlyVariantNotTouched 所保护的零散文本。 +var sanitizeRewrites = [][2]string{ + { + "You are Claude Code, Anthropic's official CLI for Claude", + "You are Claude Code, Anthropic's official CLI tool for Claude", + }, + { + "Main branch (you will usually use this for PRs)", + "Default branch (you will usually use this for PRs)", + }, + { + "You are a coding agent running in the Codex CLI, a terminal-based coding assistant.", + "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.", + }, + { + // 反馈句:整句带 Anthropic 仓库链接,上游按整句拦截(只留链接或只留半边均不拦, + // 实测需整句同时出现)。give→provide 一词之差即可绕过,语义不变。 + "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues", + "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues", + }, + { + // 上游反探测:只要请求体里出现裸数字 11128 就整单拦截(与该数字的上下文无关—— + // "code=11128" / 裸 "11128" / "错误码 11128" / "Code=11128" 全部命中; + // 相邻的 11148 / 11101 / 11115 / 99999 均放行)。11128 正是本类拦截自身的错误码, + // 上游据此识别"在讨论/回显其内部错误码"的请求。 + // 代价:用户对话中任何 11128 都会被改写——但这串数字出现在请求里本身就是拦截条件, + // 不改写必然失败。插入连字符保留可读性与指代(零宽空格无效,实测上游会归一化)。 + "11128", + "11-128", + }, +} + +// sanitizeText 单段文本净化:预检不中 → 返回原串(零分配)。 +func sanitizeText(text string) string { + if !hasFingerprint(text) { + return text + } + for _, rw := range sanitizeRewrites { + text = strings.ReplaceAll(text, rw[0], rw[1]) + } + if sanitizeHdrRe.MatchString(text) { + text = sanitizeHdrRe.ReplaceAllString(text, "") + } + if strings.Contains(text, "cc_") { + prev := "" + for prev != text { // 清尾随裸 kv(cc_version=...; cc_entrypoint=...;) + prev = text + text = sanitizeKvRe.ReplaceAllString(text, "") + } + } + // 兜底:键值形态已在上面整段删除,这里只剩裸键名(引用/示例文本形态)。 + text = sanitizeBareHdrRe.ReplaceAllString(text, "x-anthropic-billing-hdr") + return strings.TrimSpace(text) +} + +// hasFingerprint 特征预检:先走 strings.Contains 快速路径(零分配); +// header 键名有大小写变体(X-Anthropic-...)且可能以裸键名形态出现(无冒号), +// Contains 大小写敏感、sanitizeHdrRe 要求冒号——两者都会漏掉「混合大小写 + 裸键名」, +// 必须再用不要求冒号的 (?i) 正则兜底(sanitizeBareHdrRe),否则整条净化被跳过。 +// sanitizeBareHdrRe 不要求冒号,是 sanitizeHdrRe 的超集,故无需再单独匹配后者。 +func hasFingerprint(text string) bool { + for _, f := range sanitizeFeatures { + if strings.Contains(text, f) { + return true + } + } + return sanitizeBareHdrRe.MatchString(text) +} + +// sanitizeContent 兼容字符串与多模态数组;只动 text part,image 等 part 不动。 +// 返回净化后的值及是否发生变化。 +func sanitizeContent(v any) (any, bool) { + switch c := v.(type) { + case string: + s := sanitizeText(c) + return s, s != c + case []any: + changed := false + for _, p := range c { + m, ok := p.(map[string]any) + if !ok { + continue + } + text, ok := m["text"].(string) + if !ok { + continue + } + if s := sanitizeText(text); s != text { + m["text"] = s + changed = true + } + } + return c, changed + } + return v, false +} + +// sanitizeToolCalls 净化 assistant.tool_calls[].function.arguments。 +// +// arguments 是**字符串化的 JSON**(不是对象),因此按文本走 sanitizeText 即可。 +// 这块长期是盲区:工具调用消息的 content 通常是 null,而旧版 sanitizeMessages +// 在 content 缺失时直接 continue,整条消息连 tool_calls 一起被跳过—— +// 于是历史里任何写进工具参数的被拦字符串(文件名、命令、写入内容)都会原样漏出。 +func sanitizeToolCalls(v any) bool { + callList, ok := v.([]any) + if !ok { + return false + } + changed := false + for _, c := range callList { + call, ok := c.(map[string]any) + if !ok { + continue + } + fn, ok := call["function"].(map[string]any) + if !ok { + continue + } + args, ok := fn["arguments"].(string) + if !ok { + continue + } + if s := sanitizeText(args); s != args { + fn["arguments"] = s + changed = true + } + } + return changed +} + +// sanitizeMessages 净化 messages 中的 content 与 tool_calls;任一命中返回 true。 +func sanitizeMessages(messages []any) bool { + changed := false + for _, msg := range messages { + m, ok := msg.(map[string]any) + if !ok { + continue + } + // content 与 tool_calls 各自独立判断:content 可以为 null(工具调用轮), + // 早期版本在此 continue,导致这类消息的 tool_calls 完全不被净化。 + if c, ok := m["content"]; ok { + if nc, ch := sanitizeContent(c); ch { + m["content"] = nc + changed = true + } + } + // reasoning_content(思维链回填字段,见 thinking.go/sse.go)实测同样 + // 携带指纹,与 content 同等净化。string 形态直接走 sanitizeText。 + if rc, ok := m["reasoning_content"].(string); ok { + if s := sanitizeText(rc); s != rc { + m["reasoning_content"] = s + changed = true + } + } + if tc, ok := m["tool_calls"]; ok { + if sanitizeToolCalls(tc) { + changed = true + } + } + } + return changed +} diff --git a/internal/upstream/sanitize_test.go b/internal/upstream/sanitize_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2bfc2d4933dd39d009f7b1276464297c6d685678 --- /dev/null +++ b/internal/upstream/sanitize_test.go @@ -0,0 +1,374 @@ +package upstream + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + ccIdentity = "You are Claude Code, Anthropic's official CLI for Claude." + ccBranch = "Main branch (you will usually use this for PRs)" + ccHeader = "x-anthropic-billing-header: cc_version=1.0; cc_entrypoint=cli;" + // Codex instructions 首段(上游逐字精确指纹,三句缺一不可)。 + codexInstructions = "You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful." +) + +func TestIdentityRewritten(t *testing.T) { + out := sanitizeText(ccIdentity) + if !strings.Contains(out, "official CLI tool for Claude.") { + t.Errorf("identity not rewritten: %q", out) + } + if strings.Contains(out, ccIdentity) { + t.Errorf("original identity still present: %q", out) + } +} + +// 桌面版(claude-desktop-3p / Agent SDK)的身份句以逗号接后继内容,结尾不是句号。 +// 回归用例:匹配串曾带结尾句号,导致该形态漏网、指纹原样发上游 → 400 code=11128。 +func TestIdentityDesktopVariantRewritten(t *testing.T) { + in := "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." + out := sanitizeText(in) + if strings.Contains(out, "official CLI for Claude") { + t.Errorf("desktop identity not rewritten: %q", out) + } + if !strings.Contains(out, "official CLI tool for Claude, running within the Claude Agent SDK.") { + t.Errorf("desktop identity suffix not preserved: %q", out) + } +} + +func TestBranchRewritten(t *testing.T) { + out := sanitizeText(ccBranch) + if !strings.Contains(out, "Default branch (you will usually use this for PRs)") { + t.Errorf("branch not rewritten: %q", out) + } + if strings.Contains(out, "Main branch") { + t.Errorf("original branch still present: %q", out) + } +} + +// 反馈句带 Anthropic 仓库链接,上游按整句拦截(实测只留链接或只留半边均不拦)。 +// 回归用例:give→provide 一词之差即可绕过。 +func TestFeedbackSentenceRewritten(t *testing.T) { + in := "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues" + out := sanitizeText(in) + if strings.Contains(out, "To give feedback") { + t.Errorf("feedback sentence not rewritten: %q", out) + } + if !strings.Contains(out, "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues") { + t.Errorf("feedback sentence not rewritten as expected: %q", out) + } +} + +// 上游反探测:请求体里出现裸数字 11128 即整单拦截(与上下文无关)。 +// 回归用例:该串会被改写为 11-128 以打断精确匹配。 +func TestUpstreamErrorCodeRewritten(t *testing.T) { + in := "upstream returned code=11128 for this request" + out := sanitizeText(in) + if strings.Contains(out, "11128") { + t.Errorf("error code not rewritten: %q", out) + } + if !strings.Contains(out, "11-128") { + t.Errorf("error code not rewritten as expected: %q", out) + } +} + +// 回归:工具调用消息的 content 常为 null,而旧版 sanitizeMessages 在 content 缺失时 +// 直接 continue,整条消息连 tool_calls 一起被跳过 → arguments 里的被拦字符串原样漏出。 +func TestToolCallArgumentsSanitized(t *testing.T) { + msgs := []any{ + map[string]any{"role": "user", "content": "run"}, + map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ + map[string]any{"id": "c1", "type": "function", "function": map[string]any{ + "name": "Bash", + "arguments": `{"command":"echo 11128"}`, + }}, + }}, + } + if !sanitizeMessages(msgs) { + t.Fatal("sanitizeMessages 未报告任何改动,tool_calls 被跳过") + } + fn := msgs[1].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) + got := fn["arguments"].(string) + if strings.Contains(got, "11128") { + t.Errorf("tool_call arguments 未被净化: %q", got) + } +} + +func TestBillingHeaderStrippedValueIrrelevant(t *testing.T) { + out := sanitizeText(ccHeader) + if strings.Contains(out, "x-anthropic-billing-header") { + t.Errorf("header not stripped: %q", out) + } +} + +// 附加验证:正常对话里出现 github.com/anthropics/ 链接(但不是反馈句整句) +// 时,预检特征命中(进入净化),但改写层只动精确匹配的整句——普通链接文本 +// 不该被改写。同理,既不含 11128 也不含反馈整句的文本原样返回。 +func TestNormalAnthropicLinkNotRewritten(t *testing.T) { + in := "see https://github.com/anthropics/anthropic-cookbook for examples" + if out := sanitizeText(in); out != in { + t.Errorf("normal anthropic link should be untouched: %q -> %q", in, out) + } +} + +// Codex instructions 首段:命中预告且整句改写,逐字指纹被破坏、语义保留。 +func TestCodexInstructionsRewritten(t *testing.T) { + out := sanitizeText(codexInstructions) + if strings.Contains(out, codexInstructions) { + t.Errorf("codex fingerprint still present: %q", out) + } + if !strings.Contains(out, "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.") { + t.Errorf("codex first sentence not rewritten: %q", out) + } + // 其余两句原样保留,语义不变。 + if !strings.Contains(out, "Codex CLI is an open source project led by OpenAI.") || + !strings.Contains(out, "You are expected to be precise, safe, and helpful.") { + t.Errorf("codex remaining sentences altered: %q", out) + } +} + +// 预检必须能认出 Codex 特征(此前只含 Claude Code,导致提前放行)。 +func TestCodexFingerprintDetected(t *testing.T) { + if !hasFingerprint(codexInstructions) { + t.Error("codex fingerprint not detected by precheck") + } +} + +// 非精确变体不应被改写:仅去掉其中一个词即视为已破坏,无需改动。 +func TestCodexVariantNotTouched(t *testing.T) { + in := "You are a coding agent running in a CLI, a terminal-based coding assistant." + if out := sanitizeText(in); out != in { + t.Errorf("already-broken variant should be untouched: %q -> %q", in, out) + } +} + +func TestBillingHeaderCaseInsensitive(t *testing.T) { + alt := "X-Anthropic-Billing-Header: cc_version=1.0;" + out := strings.ToLower(sanitizeText(alt)) + if strings.Contains(out, "billing") { + t.Errorf("case-insensitive header not stripped: %q", out) + } +} + +func TestTrailingKVStripped(t *testing.T) { + out := sanitizeText("...; cc_version=2.0; cc_entrypoint=cli;") + if strings.Contains(out, "cc_version") || strings.Contains(out, "cc_entrypoint") { + t.Errorf("trailing kv not stripped: %q", out) + } +} + +func TestExactMatchOnlyVariantNotTouched(t *testing.T) { + in := "...official CLI for Claude!" + if out := sanitizeText(in); out != in { + t.Errorf("variant should be untouched: %q -> %q", in, out) + } +} + +func TestUserFreeTextNotTouched(t *testing.T) { + in := "please use main branch for this repo" + if out := sanitizeText(in); out != in { + t.Errorf("free text should be untouched: %q -> %q", in, out) + } +} + +func TestNoFeatureReturnsSameString(t *testing.T) { + in := "ordinary user message" + if out := sanitizeText(in); out != in { + t.Errorf("no-feature text should pass through unchanged: %q -> %q", in, out) + } +} + +func TestMultimodalTextPartOnly(t *testing.T) { + imgPart := map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": "..."}} + content := []any{ + map[string]any{"type": "text", "text": ccIdentity}, + imgPart, + } + out, changed := sanitizeContent(content) + if !changed { + t.Fatal("expected change") + } + parts := out.([]any) + txt, _ := parts[0].(map[string]any)["text"].(string) + if !strings.Contains(txt, "CLI tool") { + t.Errorf("text part not sanitized: %q", txt) + } + img, _ := parts[1].(map[string]any) + if img["type"] != "image" || img["source"].(map[string]any)["data"] != "..." { + t.Error("image part modified") + } +} + +// 集成:完整请求体经 PrepareBodyOpt 净化后无残留指纹,且 stream/tool_choice 行为不受影响。 +func TestPrepareBodyOptSanitizesSystem(t *testing.T) { + body := []byte(`{"model":"glm-5.2","messages":[` + + `{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` + + `{"role":"user","content":"hi"}]}`) + out := PrepareBodyOpt(body, true) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatal(err) + } + if obj["stream"] != true { + t.Error("stream not forced") + } + msgs := obj["messages"].([]any) + sys, _ := msgs[0].(map[string]any)["content"].(string) + if strings.Contains(sys, "x-anthropic-billing-header") || strings.Contains(sys, ccIdentity) || strings.Contains(sys, ccBranch) { + t.Errorf("fingerprints remain: %q", sys) + } + if !strings.Contains(sys, "CLI tool") { + t.Errorf("rewrite missing: %q", sys) + } +} + +func TestPrepareBodyOptDisabledPreservesFingerprints(t *testing.T) { + body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) + out := PrepareBodyOpt(body, false) + if !strings.Contains(string(out), ccIdentity) { + t.Error("sanitize=false should preserve fingerprints") + } + // 但 stream 仍强制 + var obj map[string]any + _ = json.Unmarshal(out, &obj) + if obj["stream"] != true { + t.Error("stream should still be forced") + } +} + +// PrepareBody 默认行为 = 开启脱敏(保持向后兼容)。 +func TestPrepareBodyDefaultSanitizes(t *testing.T) { + body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) + out := PrepareBodyOpt(body, true) + if strings.Contains(string(out), ccIdentity) { + t.Error("PrepareBody default should sanitize") + } +} + +// 出站边界集成:ChatStream 发往上游的 wire body 必须无残留指纹。 +func TestChatStreamWireBodySanitized(t *testing.T) { + var gotBody []byte + ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) + }) + defer ts.Close() + + c := New() + c.SanitizeFingerprints.Store(true) + c.ChatBaseCN = ts.URL + acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} + + body := []byte(`{"model":"glm-5.2","messages":[` + + `{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` + + `{"role":"user","content":"hi"}]}`) + rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{}) + if err != nil { + t.Fatal(err) + } + defer rc.Close() + if status >= 400 { + t.Fatalf("upstream status %d: %s", status, respBody) + } + // 上游收到的 body:stream 强制 + 指纹已净化 + var obj map[string]any + if err := json.Unmarshal(gotBody, &obj); err != nil { + t.Fatalf("wire body not json: %v", err) + } + if obj["stream"] != true { + t.Error("wire body stream not forced") + } + sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string) + for _, fp := range []string{"x-anthropic-billing-header", ccIdentity, ccBranch} { + if strings.Contains(sys, fp) { + t.Errorf("wire body contains fingerprint %q: %q", fp, sys) + } + } +} + +// 出站边界(Codex 场景):CPA 把 instructions 折成 role=system 的首条消息, +// 净化后 wire body 不得残留 Codex 逐字指纹;其余消息不受影响。 +func TestChatStreamWireBodyCodexInstructionsSanitized(t *testing.T) { + var gotBody []byte + ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) + }) + defer ts.Close() + + c := New() + c.SanitizeFingerprints.Store(true) + c.ChatBaseCN = ts.URL + acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} + + body := []byte(`{"model":"kimi-k3","messages":[` + + `{"role":"system","content":"` + codexInstructions + `"},` + + `{"role":"user","content":"say ok"}]}`) + rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{}) + if err != nil { + t.Fatal(err) + } + defer rc.Close() + if status >= 400 { + t.Fatalf("upstream status %d: %s", status, respBody) + } + var obj map[string]any + if err := json.Unmarshal(gotBody, &obj); err != nil { + t.Fatalf("wire body not json: %v", err) + } + sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string) + if strings.Contains(sys, codexInstructions) || + strings.Contains(sys, "in the Codex CLI, a terminal-based coding assistant.") { + t.Errorf("wire body still contains codex fingerprint: %q", sys) + } + if !strings.Contains(sys, "running in the Codex CLI tool, a terminal-based") { + t.Errorf("codex rewrite missing on wire: %q", sys) + } + user, _ := obj["messages"].([]any)[1].(map[string]any)["content"].(string) + if user != "say ok" { + t.Errorf("user message altered: %q", user) + } +} + +// 出站边界:关闭脱敏后 wire body 原样保留指纹(验证开关真实有效)。 +func TestChatStreamWireBodySanitizeDisabled(t *testing.T) { + var gotBody []byte + ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: [DONE]\n\n")) + }) + defer ts.Close() + + c := New() + c.SanitizeFingerprints.Store(false) + c.ChatBaseCN = ts.URL + acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} + + body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) + rc, status, _, err := c.ChatStream(acct, body, "", ChatMeta{}) + if err != nil { + t.Fatal(err) + } + defer rc.Close() + if status >= 400 { + t.Fatalf("upstream status %d", status) + } + if !strings.Contains(string(gotBody), ccIdentity) { + t.Error("sanitize disabled should preserve fingerprint on wire") + } +} + +// newTestUpstream 起一个假上游并捕获请求。 +func newTestUpstream(t *testing.T, h http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(h) +} diff --git a/internal/upstream/school.go b/internal/upstream/school.go new file mode 100644 index 0000000000000000000000000000000000000000..7d10bed503d8bd93b23d0471b880e6e846eabb4c --- /dev/null +++ b/internal/upstream/school.go @@ -0,0 +1,242 @@ +// school.go 开学季活动(school-season,活动期 2026-09-13 ~ 09-24)纯 API 自动化。 +// +// 判据(2026-09-13 小程序 MCP 逆向 + 三账号实测,protocol.md §7.11): +// - share_invite(每日 +100c +1抽奖):POST /tasks/share-complete {channel:"wechat"} +// 即点亮——纯前端上报,服务端不校验真实分享回执。本模块的主目标。 +// - chat_3_times / expert_use:判据绑定小程序原生沙箱会话(e2b runtime), +// webchat 普通会话不计数,纯 API 不做(需小程序内人工对话)。 +// - 抽奖:POST /wheel/draw {draw_uuid}(前端生成 uuid,消耗 1 chance)。 +// +// 端点基址 www.codebuddy.cn(billing 同域);信封 {code,msg,data},code=0 成功。 +package upstream + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const schoolBase = "/portal/activity/school" + +// schoolJSON 学院活动 API 请求(剥信封,业务 code≠0 返回带 msg 的 error)。 +func (c *Client) schoolJSON(a *auth.Auth, method, path string, body map[string]any, out any) error { + var raw []byte + if body != nil { + raw, _ = json.Marshal(body) + } + req, err := http.NewRequest(method, c.billingBase(a)+schoolBase+path, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + data, err := c.doJSON(req) + if err != nil { + return err + } + if out != nil { + return json.Unmarshal(data, out) + } + return nil +} + +// - chat_3_times:3 条 chat_request_send 即 3/3(conversationId 任意、桌面/mp +// 头族均可计数,无需真实沙箱会话)。 +// - expert_use:mp 指纹事件链 expert_summon_click + expert_summoned + +// expert_actual_use + chat_request_send(开学季分类专家)即点亮。 + +const mpReportPath = "/v2/report" + +// schoolOpenDayActivityID 开学季/校园日活动 id(事件 activityId 字段值,两域共用)。 +const schoolOpenDayActivityID = "school_open_day_2026" + +// mpEventBase 小程序埋点公共指纹(appservice wQ()+Ao() 对齐)。 +func mpEventBase(a *auth.Auth) map[string]any { + return map[string]any{ + "timestamp": time.Now().UnixMilli(), + "ideType": "WorkBuddy_MP", + "ideVersion": "2.4.0", + "extName": "workbuddy-mp", + "extVersion": "2.4.0", + "product": "SaaS", + "ideName": "wx_app_cloud", + "platform": "mini_program", + "os": "windows", + "osVersion": "11", + "arch": "x64", + "machineId": "0655736a-607f-4d9d-b430-58176ee9a090", + "timezone": "Asia/Shanghai", + "userId": a.UID, + "userNickname": a.Nickname, + } +} + +// ReportMPEvent 以小程序指纹向 www.codebuddy.cn/v2/report 批量上报事件。 +func (c *Client) ReportMPEvent(a *auth.Auth, events ...map[string]any) error { + if len(events) == 0 { + return fmt.Errorf("mp report: no events") + } + base := mpEventBase(a) + arr := make([]map[string]any, 0, len(events)) + for _, ev := range events { + m := map[string]any{} + for k, v := range base { + m[k] = v + } + for k, v := range ev { + m[k] = v + } + arr = append(arr, m) + } + raw, err := json.Marshal(arr) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.BillingBaseCN+mpReportPath, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + req.Header.Set("X-Client-Product", "workbuddy-mp") + req.Header.Set("X-Client-Version", "2.4.0") + req.Header.Set("X-Client-Platform", "mp-weixin") + req.Header.Set("X-Platform", "wechatmp") + _, err = c.doJSON(req) + return err +} + +// SchoolChatTimesEvents 构造一条 chat_request_send 事件(chat_3_times 计数)。 +func SchoolChatTimesEvents(conversationID string) map[string]any { + rid := "wb2api-" + clientToken() + return map[string]any{ + "eventCode": "chat_request_send", + "inputLength": 14, "isPlan": false, "isAutoExecuteTerminal": false, + "isAutoModify": false, "codebaseEnable": false, "maxToken": 0, + "maxSteps": 500, "temperature": 0, "maxRetries": 0, + "mentionContexts": []any{}, "knowledgeId": []any{}, "knowledgeName": []any{}, + "codebaseId": "", "mentionContextCount": 0, "command": "", + "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, + "traceId": rid, "rootRequestId": rid, + "parentConversationId": conversationID, "conversationId": conversationID, + "messageId": "msg-" + rid[len(rid)-8:], + "agentName": "mp", "agentType": "main", + "codebuddy.session_id": conversationID, + "codebuddy.conversation_request_id": rid, + } +} + +// SchoolSeasonChatEvent 构造 growth 域「校园日」(school_season)判据事件: +// mini 指纹 chat_request_send + activityId=school_open_day_2026(与 school 域 +// 开学季同 activityId 关联;实测无 activityId 的事件不点亮)。事件形状与 +// SchoolChatTimesEvents 同构(school 域 chat_3_times 同款),仅追加 activityId。 +func SchoolSeasonChatEvent(conversationID string) map[string]any { + ev := SchoolChatTimesEvents(conversationID) + ev["activityId"] = schoolOpenDayActivityID + return ev +} + +// MiniExpertUseEvent 构造 growth 域 Sequential_Tasks_2「在小程序内选中专家并完成 +// 有效对话」的判据事件:mp 指纹 expert_actual_use。形状对齐小程序源码 +// app-service.js 的真实发射点(上游 task_runner 实测 2026-09-23:上报即 completed, +// claim +200c+5e)。与 school 域的 SchoolExpertUseEvents 是**两套口径**,勿照抄: +// - 不带 conversationId/activityId——真实事件就是这两个字段都不带; +// - extVersion 用小程序自身版本 2.2.8(覆盖 mpEventBase 的 2.4.0); +// - source=mini_program + type 固定 "send_message"(小程序恒发此值)。 +// +// expertID 必须是专家市场真实 ex_ id(ListMarketExperts),空 id 服务端不入账。 +func MiniExpertUseEvent(expertID, expertName, expertType string) map[string]any { + if expertType == "" { + expertType = "agent" + } + if expertName == "" { + expertName = expertID + } + return map[string]any{ + "eventCode": "expert_actual_use", "reportDelay": 0, + "extVersion": "2.2.8", "source": "mini_program", + "id": expertID, "name": expertID, + "expertTitle": expertName, "type": "send_message", + "characterCount": 12, "expertType": expertType, + } +} + +// MiniChatModelEvent mp 对话事件 + 模型字段(Sequential_Tasks_5「使用 GLM5.2」判据 +// 载体):小程序 chat_request_send 真实发射点(mpsrc main 32904 模块)带 +// requestModelId / requestModelName——Tasks_1/3 的裸对话事件不带模型,模型任务 +// 须用本形态(判据待解锁实测验证)。 +func MiniChatModelEvent(conversationID, modelID, modelName string) map[string]any { + ev := SchoolChatTimesEvents(conversationID) + ev["requestModelId"] = modelID + ev["requestModelName"] = modelName + return ev +} + +// MiniPlaybookEvents mp 指纹灵感事件组(Sequential_Tasks_7「体验灵感功能」判据 +// 载体,形状对齐 mpsrc main 73640/73665 发射点:playbook_cta_click → +// playbook_prompt_send)。issue #42 称该任务为 PC 口径(+500c+5e)——PC 序列 +// (DesktopPlaybookPromptSequence)已实测点亮 playbook_prompt,本组作为 mp 形态 +// 补充(任务在 mp 链上,判据究竟认哪侧待解锁实测)。 +func MiniPlaybookEvents(caseID, caseName string) []map[string]any { + base := map[string]any{ + "id": caseID, "name": caseName, "type": "document", + "categoryId": "", "categoryName": "", + "skills": "", "skillNames": "", + } + cta := map[string]any{ + "eventCode": "playbook_cta_click", "source": "discover", "position": 1, + "extVersion": "2.2.8", + } + for k, v := range base { + cta[k] = v + } + send := map[string]any{ + "eventCode": "playbook_prompt_send", "source": "discover", + "promptLength": 96, "isOfficial": 1, + "conversationId": "wb2api-mp-pb-" + clientToken(), + "extVersion": "2.2.8", + } + for k, v := range base { + send[k] = v + } + return []map[string]any{cta, send} +} + +// ---- 我的券码(#/prizes?tab=vouchers,2026-09-16 接入)---- + +// SchoolVoucher 开学季抽奖抽中的第三方券(KFC/瑞幸/酷狗等)。 +// 字段结构按真实响应样本:GET /vouchers 单次拉全(无分页),data.items[]。 +type SchoolVoucher struct { + GrantID int64 `json:"grant_id"` + DrawUUID string `json:"draw_uuid,omitempty"` + SKUCode string `json:"sku_code,omitempty"` // kfc_ice_cream / voucher_luckin / voucher_kugou … + PrizeName string `json:"prize_name,omitempty"` // 肯德基冰淇淋 + Code string `json:"code"` // 券码本体(复制给店员核销) + ValidFrom string `json:"valid_from,omitempty"` // 上游常为空 + ValidTo string `json:"valid_to,omitempty"` // "2026-10-24" + GrantedAt string `json:"granted_at,omitempty"` // RFC3339 +} + +// SchoolVouchers 查询账号的开学季券码列表(只读)。 +// 抽到积分的记录不在此端点(那是 /rewards 的 type=credit 条目)。 +func (c *Client) SchoolVouchers(a *auth.Auth) ([]SchoolVoucher, error) { + var out struct { + Items []SchoolVoucher `json:"items"` + } + if err := c.schoolJSON(a, http.MethodGet, "/vouchers", nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} diff --git a/internal/upstream/school_mp_test.go b/internal/upstream/school_mp_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d40f2a380956b4120c66383e96f52c77b0327b8b --- /dev/null +++ b/internal/upstream/school_mp_test.go @@ -0,0 +1,37 @@ +package upstream + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestMPEventBase(t *testing.T) { + a := &auth.Auth{UID: "u-1", Nickname: "测试"} + base := mpEventBase(a) + for _, k := range []string{"ideType", "extName", "ideName", "platform", "userId"} { + if _, ok := base[k]; !ok { + t.Errorf("missing common field %s", k) + } + } + if base["ideType"] != "WorkBuddy_MP" || base["extName"] != "workbuddy-mp" { + t.Errorf("fingerprint ideType=%v extName=%v", base["ideType"], base["extName"]) + } +} + +func TestSchoolChatTimesEvents(t *testing.T) { + ev := SchoolChatTimesEvents("conv-1") + if ev["eventCode"] != "chat_request_send" { + t.Errorf("eventCode=%v", ev["eventCode"]) + } + if ev["conversationId"] != "conv-1" || ev["codebuddy.session_id"] != "conv-1" { + t.Errorf("conversation join fields missing: %v", ev) + } + b, _ := json.Marshal(ev) + if !strings.Contains(string(b), "agentName") { + t.Error("agentName missing") + } +} + diff --git a/internal/upstream/sse.go b/internal/upstream/sse.go new file mode 100644 index 0000000000000000000000000000000000000000..c48f351a254afcbb5018b9c1c5a509eba95e94b1 --- /dev/null +++ b/internal/upstream/sse.go @@ -0,0 +1,644 @@ +// sse.go 处理上游 SSE 流:聚合成单个 OpenAI 响应,或透传给客户端。 +package upstream + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// errEmptyStream 上游返回 200 但没有有效 SSE 数据帧(空流/只有注释/[DONE])。 +// 用哨兵错误替代裸 fmt.Errorf:StreamHint 的调用方(handler 流式路径)需要区分 +// 「上游空流」与「客户端断连写失败」——空流是上游缺陷,应记 502 观测;写失败是 +// 客户端已走,日志口径不同。Aggregate 与 StreamHint 共用同一哨兵(errors.Is 判定)。 +var errEmptyStream = errors.New("upstream stream contained no valid data events") + +// IsEmptyStreamError 报告错误是否为「上游空流」(无有效 SSE 帧)——供 handler +// 在流式路径把空流记为失败观测(HTTP 头已发出只能 200,但日志/状态应收敛到 +// upstream_parse 同语义),与客户端断连类错误区分。 +func IsEmptyStreamError(err error) bool { return errors.Is(err, errEmptyStream) } + +// Aggregate 读取完整 SSE 流,聚合 delta.content 为单个 OpenAI chat.completion 响应。 +// 分片/半行由 bufio.Reader.ReadString 处理;遇到 "data: [DONE]" 结束。 +// tool_calls 以流式 delta 到达(按 index 合并:首片带 id/type/name,后续只带 arguments 片段)。 +func Aggregate(r io.Reader) (map[string]any, error) { + br := bufio.NewReaderSize(r, 64*1024) + var ( + id, model string + created float64 + content strings.Builder + reasoning strings.Builder + role = "assistant" + finishReason = "stop" + usage map[string]any + gotAnyContent bool + validEvents int + sawDone bool // 上游显式发过 data: [DONE](正常收尾) + toolCalls = map[int]map[string]any{} + toolOrder []int + // toolSeq 缺 index 的 tool_call 的分配序号源:跨帧延续「最近分配」槽位, + // 同帧内递增(见 mergeToolCallsChunk 注释)。 + toolSeq int + // idIndex id → 已分配的 index:跨帧持续(延续碎片的 id 常出现在后续帧), + // 供缺 index 时按 id 归位既有调用(见 mergeToolCallsChunk 注释)。 + idIndex = map[string]int{} + ) + // appendContent 是「已取到正文」(gotAnyContent latch)的唯一写入点:delta 与 + // message 两路 content 都必须经此并入,规约只有一份(issue #142)—— + // S1 空串不算「已取到正文」、不占 latch 名额:OpenAI 风格 role-only 首帧 + // (delta.content="")和整条空 message 帧是常态帧,若空串置位 latch, + // 后续真正文会被 message 回退分支的 !gotAnyContent 守卫静默拒绝; + // S2 空串本身也无可追加字节,跳过 WriteString 与追加语义自洽。 + appendContent := func(txt string) { + if txt == "" { + return + } + content.WriteString(txt) + gotAnyContent = true + } + // mergeToolCallsChunk 把一段 tool_calls 数组按 index 合并进累计表。 + // delta(流式分片,按 index 累积)与 message(非 delta 整条)共用同一合并逻辑, + // 保证「上游给的身份/函数名不丢、arguments 拼接语义一致」。 + // + // index 缺失兼容:OpenAI 规范要求 delta 帧的 tool_call 带 index(标记分片归属), + // 但部分上游省略它。此前缺 index 一律归 0——多调用场景下不同 call 被合并进同一 + // index 槽,arguments 串联、name 互相覆盖(数据污染)。修法按「id 优先、lastIdx 兜底」: + // - 带 index → 按 index 累积(合规形态,零改动); + // - 缺 index 带 id 且 id 已见过 → 延续该 id 所在 index; + // - 缺 index 带 id 且 id 是新的 → 开新序号(多调用不合并); + // - 缺 index 无 id → 追加到最近收到碎片的 index(单个调用的延续分片无 id + // 是标准形态),无既往则开新号。 + // 带 index 的碎片照常按 index 累积,不受影响。 + // nextToolIndex 分配下一个不冲突的缺 index 序号:从 toolSeq 起递增跳过既有 + // index(合规流的 index 是 0..N-1,缺 index 的补位不能覆盖它们)。 + nextToolIndex := func() int { + for { + idx := toolSeq + toolSeq++ + if _, used := toolCalls[idx]; !used { + return idx + } + } + } + mergeToolCallsChunk := func(tcs []any) { + for _, tc := range tcs { + call, ok := tc.(map[string]any) + if !ok { + continue + } + idx := -1 + if v, ok := call["index"].(float64); ok { + idx = int(v) + } else if cid, _ := call["id"].(string); cid != "" { + if mid, seen := idIndex[cid]; seen { + idx = mid // 该 id 已归位过:延续既有调用(跨帧有效) + } else { + idx = nextToolIndex() + } + } else if len(toolOrder) > 0 { + idx = toolOrder[len(toolOrder)-1] // 无 id 碎片:延续最近调用 + } else { + idx = nextToolIndex() + } + merged, seen := toolCalls[idx] + if !seen { + merged = map[string]any{"index": idx} + toolCalls[idx] = merged + toolOrder = append(toolOrder, idx) + } + if cid, _ := call["id"].(string); cid != "" { + idIndex[cid] = idx + } + if cid, _ := merged["id"].(string); cid != "" { + idIndex[cid] = idx + } + mergeToolCallDelta(merged, call) + } + } + // mergeMessageFields 把非 delta 的完整 message 内容并入聚合(message 是整条下发, + // 非流式拼接,content 只取一次)。role/reasoning_content/tool_calls 与 delta 分支 + // 同构透出;content 同样置 gotAnyContent,与 delta 路径的 latch 语义一致 + // (一帧整条 message 之后,后续 delta 帧不重复追加)。 + mergeMessageFields := func(msg map[string]any) { + if r2, ok := msg["role"].(string); ok && r2 != "" { + role = r2 + } + if txt, ok := msg["content"].(string); ok { + appendContent(txt) + } + if rc, ok := msg["reasoning_content"].(string); ok { + reasoning.WriteString(rc) + } + if tcs, ok := msg["tool_calls"].([]any); ok { + mergeToolCallsChunk(tcs) + } + } + for { + line, err := br.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if strings.HasPrefix(line, "data: ") { + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + // 上游显式结束:停止读取,DONE 之后的任何数据一律忽略。 + sawDone = true + break + } else { + var chunk map[string]any + if json.Unmarshal([]byte(payload), &chunk) == nil { + // 有效事件计数:仅 JSON 解析成功的数据帧计入(解析失败沿用静默 continue)。 + validEvents++ + if v, ok := chunk["id"].(string); ok && id == "" { + id = v + } + if v, ok := chunk["model"].(string); ok && model == "" { + model = v + } + if v, ok := chunk["created"].(float64); ok && created == 0 { + created = v + } + if u, ok := chunk["usage"].(map[string]any); ok { + usage = u + } + if ch, ok := chunk["choices"].([]any); ok { + for _, ci := range ch { + c, _ := ci.(map[string]any) + if c == nil { + continue + } + if fr, ok := c["finish_reason"].(string); ok && fr != "" { + finishReason = fr + } + if delta, ok := c["delta"].(map[string]any); ok { + if r2, ok := delta["role"].(string); ok && r2 != "" { + role = r2 + } + if txt, ok := delta["content"].(string); ok { + appendContent(txt) + } + if rc, ok := delta["reasoning_content"].(string); ok { + reasoning.WriteString(rc) + } + if tcs, ok := delta["tool_calls"].([]any); ok { + mergeToolCallsChunk(tcs) + } + } + // 有的上游把完整消息放在 message 里(非 delta): + // 整条并入(role/content/reasoning_content/tool_calls), + // 与 delta 分支同构。delta 已取过正文(gotAnyContent)则跳过 + // (避免与 delta 路径重复拼接——PR #134 的 latch 语义)。 + if msg, ok := c["message"].(map[string]any); ok && !gotAnyContent { + mergeMessageFields(msg) + } + } + } + } + } + } + if err == io.EOF { + break + } + } + if validEvents == 0 { + // 上游返回 200 但没有任何有效数据事件(空流/只有 [DONE]/只有注释行): + // 不再合成空 content 的假成功响应,直接报错,由 handler 映射为 502 upstream_parse。 + return nil, errEmptyStream + } + if id == "" { + id = fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) + } + if created == 0 { + created = float64(time.Now().Unix()) + } + message := map[string]any{ + "role": role, + "content": content.String(), + } + if reasoning.Len() > 0 { + message["reasoning_content"] = reasoning.String() + } + if len(toolOrder) > 0 { + sort.Ints(toolOrder) + calls := make([]map[string]any, 0, len(toolOrder)) + for _, idx := range toolOrder { + calls = append(calls, toolCalls[idx]) + } + // P1b:流被截断时 tool_call 的 arguments 是残缺 JSON(解析失败),不把脏参数 + // 交给客户端——残留分片会被客户端解析成非法 JSON 卡死会话。截断的两个来源: + // - finish_reason=="length"(模型因 max_tokens 提前中止); + // - 上游连接中断(EOF 收尾但未发 data: [DONE],sawDone=false)。 + // 完整参数原样保留(正例零改动);空参数(无参工具)不是截断,同样保留。 + // 此前只认 finish_reason=="length",EOF 截断的 tool_calls 残缺参数被原样下发。 + if finishReason == "length" || !sawDone { + calls = dropTruncatedToolCalls(calls) + } + if len(calls) > 0 { + message["tool_calls"] = calls + } + } + resp := map[string]any{ + "id": id, + "object": "chat.completion", + "created": int64(created), + "model": model, + "choices": []any{ + map[string]any{ + "index": 0, + "message": message, + "finish_reason": finishReason, + }, + }, + } + if usage != nil { + // OpenAI 非流式 usage 必含 total_tokens。上游若只发 prompt_tokens + + // completion_tokens(部分上游末帧缺 total),网关合成补齐——否则严格按 + // schema 校验的客户端收不到 total_tokens。已有 total 或二者缺一不补 + // (不臆造:单边有值无法合成可信的 total)。 + resp["usage"] = normalizeUsageCacheAliases(ensureUsageTotal(usage)) + } + return resp, nil +} + +// ensureUsageTotal 在 usage 缺 total_tokens 但 prompt_tokens/completion_tokens 都在时 +// 补齐 total = prompt + completion(通过新 map 合并,不修改原上游 map)。 +// 任一缺失或已有 total 时原样返回。 +func ensureUsageTotal(u map[string]any) map[string]any { + if _, ok := u["total_tokens"]; ok { + return u + } + pt, pok := num64(u["prompt_tokens"]) + ct, cok := num64(u["completion_tokens"]) + if !pok || !cok { + return u + } + out := make(map[string]any, len(u)+1) + for k, v := range u { + out[k] = v + } + out["total_tokens"] = pt + ct + return out +} + +// num64 把 JSON number(float64/int64 均可)归一为 float64;非数字返回 ok=false。 +// +// 注意与 payload.go 的类型 switch 语义不同:那边是翻译请求别名字段(非数字拒绝 +// 整个请求),这边是聚合响应求和(非数字只跳过合成)。防后人合并两处。 +func num64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case int: + return float64(n), true + default: + return 0, false + } +} + +// mergeToolCallDelta 把流式 tool_call 片段合并到累计对象: +// id/type/function.name 直覆盖(后续分片通常缺省),function.arguments 拼接。 +func mergeToolCallDelta(merged, delta map[string]any) { + if v, ok := delta["id"].(string); ok && v != "" { + merged["id"] = v + } + if v, ok := delta["type"].(string); ok && v != "" { + merged["type"] = v + } + df, _ := delta["function"].(map[string]any) + if df == nil { + return + } + mf, _ := merged["function"].(map[string]any) + if mf == nil { + mf = map[string]any{} + merged["function"] = mf + } + if v, ok := df["name"].(string); ok && v != "" { + mf["name"] = v + } + if v, ok := df["arguments"].(string); ok && v != "" { + if prev, _ := mf["arguments"].(string); prev != "" { + mf["arguments"] = prev + v + } else { + mf["arguments"] = v + } + } +} + +// stripToolCallNames 收敛流式 tool_calls 的 name 语义为「每个 index 只出现一次」: +// 首片保留 function.name,同一 index 后续分片里的 name 键一律删除(无论上游是 +// 空串还是重复非空串)。这是 OpenAI 官方流的真实形态——首帧带 name,后续帧只带 +// arguments 片段、不再出现 name 键——因此是累加型与覆盖型客户端的共同祖先行为。 +// +// 两类消费模型在该形态下同时正确: +// - 累加型(官方 WorkBuddy/CodeBuddy `name += tc_function?.name || ""`): +// 后续分片 name 键缺失 → 追加空串,累积 name 保持唯一,不再拼成 Bash×帧数(issue #82)。 +// - 覆盖型(hawklithm#2 / Grok Build `name ?? state.name` 或 `if (name) state.name = name`): +// 后续分片 name 键缺失 → 保留已建好的首帧 name,不被空串意外清空。 +// 键缺失是比空串更安全的形态:`??` 与 truthy 守卫对缺失键必然保留旧值, +// 而对空串,`??` 会误判为重设并清空工具名。 +// +// seen 记录每个 index 是否已发过首片(与 name 是否非空无关);删除是幂等的。 +// 只动 function.name 键,id/type/arguments 原样透传。 +func stripToolCallNames(obj map[string]any, seen map[int]bool) { + choices, _ := obj["choices"].([]any) + for _, ci := range choices { + c, _ := ci.(map[string]any) + if c == nil { + continue + } + delta, _ := c["delta"].(map[string]any) + if delta == nil { + continue + } + tcs, _ := delta["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + if tc == nil { + continue + } + idx := 0 + if v, ok := tc["index"].(float64); ok { + idx = int(v) + } + if seen[idx] { + // 已发过首片:删除本分片的 name 键(存在即删,幂等)。 + if fn, _ := tc["function"].(map[string]any); fn != nil { + delete(fn, "name") + } + continue + } + // 首现:保留 name 键原样(上游首片通常带非空 name;空 name 也照发, + // 与 OpenAI 对「首帧无 name」的容忍一致),随后分片统一删除。 + seen[idx] = true + } + } +} + +// normalizeFrame 以 OpenAI 流式规范白名单重建帧:仅保留标准字段, +// 剔除上游噪声(finish_reason:"" → null、空 content/refusal、空 tool_calls 列表、 +// 空占位 function_call、顶层未知字段),空 delta 键一律省略, +// usage 缺失 → null,保证任意标准客户端按规范解析。 +func normalizeFrame(obj map[string]any) map[string]any { + out := map[string]any{} + for _, k := range []string{"id", "object", "created", "model", "system_fingerprint", "service_tier"} { + if v, ok := obj[k]; ok && v != nil { + out[k] = v + } + } + if _, ok := out["object"]; !ok { + out["object"] = "chat.completion.chunk" + } + if _, ok := out["id"]; !ok { + out["id"] = "chatcmpl-wb2api" + } + if chs, ok := obj["choices"].([]any); ok { + nchs := make([]any, 0, len(chs)) + for _, ci := range chs { + c, ok := ci.(map[string]any) + if !ok { + continue + } + nc := map[string]any{} + if idx, ok := c["index"]; ok { + nc["index"] = idx + } + delta := map[string]any{} + if d, ok := c["delta"].(map[string]any); ok { + if v, ok := d["role"].(string); ok && v != "" { + delta["role"] = v + } + if v, ok := d["content"].(string); ok && v != "" { + delta["content"] = v + } + if v, ok := d["reasoning_content"].(string); ok && v != "" { + delta["reasoning_content"] = v + } + if v, ok := d["refusal"].(string); ok && v != "" { + delta["refusal"] = v + } + if tcs, ok := d["tool_calls"].([]any); ok && len(tcs) > 0 { + delta["tool_calls"] = tcs + } + if fc, ok := d["function_call"]; ok && fc != nil { + // 空占位 function_call(name/arguments 全空)视为噪声剔除 + keep := false + if fcm, ok2 := fc.(map[string]any); ok2 { + n, _ := fcm["name"].(string) + a, _ := fcm["arguments"].(string) + keep = n != "" || a != "" + } else { + keep = true + } + if keep { + delta["function_call"] = fc + } + } + } + nc["delta"] = delta + if fr, ok := c["finish_reason"].(string); ok && fr != "" { + nc["finish_reason"] = fr + } else { + nc["finish_reason"] = nil + } + nchs = append(nchs, nc) + } + out["choices"] = nchs + } + if rawUsage, ok := obj["usage"]; ok { + if u, ok := rawUsage.(map[string]any); ok { + out["usage"] = normalizeUsageCacheAliases(u) + } else { + out["usage"] = rawUsage + } + } else { + out["usage"] = nil + } + return out +} + +// Stream 透传上游 SSE 到 w(逐帧规范化后 flush),保证至少写一个 [DONE]。 +// 调用方必须先设置过 status 200;本函数自设 SSE headers。 +// 流式策略:逐帧透传(规范化已剥空 content 噪声),恢复与上游一致的平滑流式。 +// +// StreamHint 变体(gateway_hint 任务):上游 error 帧透传时附加 +// error.gateway_hint 字段——message 原文不动,hint 并列补充;hintFn 返回空串 +// 或 nil 时与 Stream 行为逐字节一致。 +func Stream(w http.ResponseWriter, r io.Reader) error { + return StreamHint(w, r, nil) +} + +// StreamHint 同 Stream,但上游 error 帧透出前把 hintFn(payload) 的返回值写入 +// error.gateway_hint。hintFn 为 nil 或返回空串 → 原样透传(零改写)。 +// 空流兜底 error 帧("empty upstream stream")不带 hint(网关本地故障形态 +// 未覆盖,不编造)。 +func StreamHint(w http.ResponseWriter, r io.Reader, hintFn func(string) string) error { + h := w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "keep-alive") + h.Set("X-Accel-Buffering", "no") + fl, _ := w.(http.Flusher) + + // toolCallSeen 跨帧记录 delta.tool_calls 里已发过首片的 index, + // 供逐 chunk 透传时收敛 name 为「每 index 一次」(对齐 OpenAI 官方流)。 + toolCallSeen := map[int]bool{} + + // firstID 透传流的消息级 id 基准:缓存首个非空上游 id,后续帧缺失/空串时复用 + // (issue #35:同一条 SSE 消息所有帧共用一个真实 id,后台按 id 归并;此前中间帧 + // 一律补 chatcmpl-wb2api 哨兵,造成同流 id 分裂)。全流无真实 id → 才出现哨兵。 + firstID := "" + + // writeRaw 原样写出一帧(绕过 normalizeFrame)并 flush。上游 error 帧(error-passthrough) + // 与空流错误帧需保留 error 字段,不能被白名单剥掉,故经此写出。 + // gateway_hint:上游 error 帧透出前按 hintFn 附加 error.gateway_hint 字段 + // (error 对象上加一个键,message/code/requestId 等原文不动;hintFn 为 + // nil / 空串 / 非 JSON 帧 → 原样写出,零改写)。 + writeRaw := func(payload string) error { + if hint := frameGatewayHint(hintFn, payload); hint != "" { + payload = attachHintToErrorFrame(payload, hint) + } + if _, werr := io.WriteString(w, "data: "+payload+"\n\n"); werr != nil { + return werr + } + if fl != nil { + fl.Flush() + } + return nil + } + + // writeFrame 把 payload 按规范白名单重建后以 data: 帧写出并 flush。 + // 仅 JSON 解析成功时计数记为一次有效转发(JSON 解析失败照常降级原样写出,但不计数)。 + writeFrame := func(payload string) (int, error) { + var obj map[string]any + valid := 0 + if json.Unmarshal([]byte(payload), &obj) == nil { + // 上游错误帧透传(error-passthrough):带 error 键的帧**原样写出**,不走 + // normalizeFrame 白名单——白名单会剥掉 error 字段,客户端就看不到上游 + // code/msg/requestId。error.message 即上游原文(如 6004 限流、审核拦截), + // 计入有效帧(避免误判空流补写 "empty upstream stream")。 + if _, hasErr := obj["error"]; hasErr { + if werr := writeRaw(payload); werr != nil { + return 0, werr + } + return 1, nil + } + // 先按 index 收敛 tool_calls name(每 index 仅首片保留,后续分片删 name 键),再规范化透传。 + stripToolCallNames(obj, toolCallSeen) + // id 续传:首帧非空真实 id 缓存;后续帧缺 id / 空 id 一律用缓存值, + // 有自己 id 的帧保持原样(不同流分裂的帧允许各自 id)。 + if firstID == "" { + if v, ok := obj["id"].(string); ok && v != "" { + firstID = v + } + } else { + if v, ok := obj["id"].(string); !ok || v == "" { + obj["id"] = firstID + } + } + if raw, err := json.Marshal(normalizeFrame(obj)); err == nil { + payload = string(raw) + } + valid = 1 + } + if _, werr := io.WriteString(w, "data: "+payload+"\n\n"); werr != nil { + return 0, werr + } + if fl != nil { + fl.Flush() + } + return valid, nil + } + + br := bufio.NewReaderSize(r, 64*1024) + validFrames := 0 +readLoop: + for { + line, err := br.ReadString('\n') + trimmed := strings.TrimRight(line, "\r\n") + switch { + case strings.HasPrefix(trimmed, "data: [DONE]"): + // 上游显式结束:停止读取,DONE 之后的任何数据(含垃圾帧)一律不再透传。 + // [DONE] 统一在循环结束后写出,保证恰好一个。 + break readLoop + case strings.HasPrefix(trimmed, "data: "): + n, werr := writeFrame(strings.TrimPrefix(trimmed, "data: ")) + validFrames += n + if werr != nil { + return werr + } + case trimmed != "": + // 注释/其他行:原样透传 + if _, werr := io.WriteString(w, line); werr != nil { + return werr + } + if fl != nil { + fl.Flush() + } + } + // 空行(帧分隔)吞掉:本函数自产 "\n\n" + if err != nil { + if err == io.EOF { + break + } + return err + } + } + // 空流(0 有效帧):先写一帧 error(绕过 normalizeFrame 原样保留 error 字段), + // 再补 [DONE] 保证客户端能正常收尾,并返回非 nil error 供调用方记录。 + // 网关本地空流兜底帧走 hintFn=nil 的直写路径:该形态未覆盖(不编造 hint), + // 且 writeRaw 的 hintFn 闭包在空流路径下可能携带上一帧的上下文造成误配。 + if validFrames == 0 { + _ = writeRaw(`{"error":{"message":"empty upstream stream","type":"upstream_error","code":"upstream_parse"}}`) + } + // 保证恰好写一个 [DONE](上游漏发时兜底补上)。 + if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil { + return err + } + if fl != nil { + fl.Flush() + } + if validFrames == 0 { + return errEmptyStream + } + return nil +} + +// frameGatewayHint 取 error 帧的 gateway_hint(hintFn 缺失/异常返回空串 → 不附加)。 +func frameGatewayHint(hintFn func(string) string, payload string) string { + if hintFn == nil { + return "" + } + // panic 隔离:hint 判定是补充功能,任何实现缺陷不得击穿流透传主路径。 + defer func() { _ = recover() }() + return strings.TrimSpace(hintFn(payload)) +} + +// attachHintToErrorFrame 在 error 帧的 error 对象上附加 gateway_hint 字段。 +// message/code/requestId 等既有键原样保留(只加不改);非 JSON / 无 error 对象 → +// payload 原样返回(宁可不加 hint 也不破坏原文透传)。 +func attachHintToErrorFrame(payload, hint string) string { + var obj map[string]any + if json.Unmarshal([]byte(payload), &obj) != nil { + return payload + } + e, ok := obj["error"].(map[string]any) + if !ok { + return payload + } + e["gateway_hint"] = hint + out, err := json.Marshal(obj) + if err != nil { + return payload + } + return string(out) +} diff --git a/internal/upstream/sse_test.go b/internal/upstream/sse_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a093198ab9a790b199b5f97eacad18cbd23462da --- /dev/null +++ b/internal/upstream/sse_test.go @@ -0,0 +1,894 @@ +package upstream + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestPrepareBodyForcesStream(t *testing.T) { + out := PrepareBodyOpt([]byte(`{"model":"glm-5.2","messages":[]}`), true) + var m map[string]any + json.Unmarshal(out, &m) + if m["stream"] != true { + t.Errorf("stream=%v", m["stream"]) + } +} + +func TestPrepareBodyToolChoiceFunctionObject(t *testing.T) { + out := PrepareBodyOpt([]byte(`{"tool_choice":{"type":"function","function":{"name":"get_weather"}},"tools":[{"type":"function"}]}`), true) + var m map[string]any + json.Unmarshal(out, &m) + if m["tool_choice"] != "get_weather" { + t.Errorf("tool_choice=%v", m["tool_choice"]) + } + if _, ok := m["tools"]; !ok { + t.Error("tools should be kept for function choice") + } +} + +func TestPrepareBodyToolChoiceNone(t *testing.T) { + for _, in := range []string{ + `{"tool_choice":"none","tools":[{}],"functions":[{}]}`, + `{"tool_choice":{"type":"none"},"tools":[{}]}`, + } { + out := PrepareBodyOpt([]byte(in), true) + var m map[string]any + json.Unmarshal(out, &m) + if _, ok := m["tool_choice"]; ok { + t.Errorf("%s: tool_choice should be deleted", in) + } + if _, ok := m["tools"]; ok { + t.Errorf("%s: tools should be deleted", in) + } + if _, ok := m["functions"]; ok { + t.Errorf("%s: functions should be deleted", in) + } + } +} + +func TestPrepareBodyToolChoiceAuto(t *testing.T) { + out := PrepareBodyOpt([]byte(`{"tool_choice":{"type":"auto"}}`), true) + var m map[string]any + json.Unmarshal(out, &m) + if m["tool_choice"] != "auto" { + t.Errorf("tool_choice=%v", m["tool_choice"]) + } +} + +func TestPrepareBodyInvalidJSON(t *testing.T) { + in := []byte(`{broken`) + out := PrepareBodyOpt(in, true) + if string(out) != string(in) { + t.Error("invalid json should pass through unchanged") + } +} + +const sseFixture = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"你好\"}}]}\n\n" + + "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",世界\"}}]}\n\n" + + "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + +func TestAggregate(t *testing.T) { + resp, err := Aggregate(strings.NewReader(sseFixture)) + if err != nil { + t.Fatal(err) + } + if resp["object"] != "chat.completion" { + t.Errorf("object=%v", resp["object"]) + } + if resp["model"] != "glm-5.2" { + t.Errorf("model=%v", resp["model"]) + } + choices := resp["choices"].([]any) + msg := choices[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != "你好,世界" { + t.Errorf("content=%q", msg["content"]) + } + if msg["role"] != "assistant" { + t.Errorf("role=%v", msg["role"]) + } + if choices[0].(map[string]any)["finish_reason"] != "stop" { + t.Errorf("finish_reason=%v", choices[0].(map[string]any)["finish_reason"]) + } + usage := resp["usage"].(map[string]any) + if usage["total_tokens"].(float64) != 7 { + t.Errorf("usage=%v", usage) + } +} + +func TestAggregateSkipsNonDataLines(t *testing.T) { + raw := ": comment\n\n" + sseFixture + resp, err := Aggregate(strings.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != "你好,世界" { + t.Errorf("content=%q", msg["content"]) + } +} + +func TestAggregateToolCalls(t *testing.T) { + // 流式 tool_calls:首片带 id/type/name + 空 arguments,后续只带 arguments 片段 + raw := `data: {"id":"x1","model":"deepseek-v4-pro","created":1,"choices":[{"index":0,"delta":{"role":"assistant","content":"","tool_calls":[{"id":"call_a","type":"function","function":{"name":"get_weather","arguments":""},"index":0}]}}],"usage":null} + +data: {"id":"x1","choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"{\"city\":"},"index":0}]}}]} + +data: {"id":"x1","choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"\"北京\"}"},"index":0}]}}]} + +data: {"id":"x1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"total_tokens":11}} + +data: [DONE] + +` + resp, err := Aggregate(strings.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + choice := resp["choices"].([]any)[0].(map[string]any) + if choice["finish_reason"] != "tool_calls" { + t.Errorf("finish_reason=%v", choice["finish_reason"]) + } + msg := choice["message"].(map[string]any) + calls, ok := msg["tool_calls"].([]map[string]any) + if !ok || len(calls) != 1 { + t.Fatalf("tool_calls=%#v", msg["tool_calls"]) + } + if calls[0]["id"] != "call_a" || calls[0]["type"] != "function" { + t.Errorf("call meta=%v", calls[0]) + } + fn := calls[0]["function"].(map[string]any) + if fn["name"] != "get_weather" { + t.Errorf("fn.name=%v", fn["name"]) + } + if fn["arguments"] != `{"city":"北京"}` { + t.Errorf("fn.arguments=%q", fn["arguments"]) + } +} + +// TestStripToolCallNames 直测跨帧 name 收敛:首片保留 name、同 index 后续分片删除 +// name 键(空串或重复非空串都删),不同 index 互不串扰,非 tool_calls 帧零影响。 +func TestStripToolCallNames(t *testing.T) { + mkFrame := func(idx float64, name, args string) map[string]any { + fn := map[string]any{} + if name != "" { + fn["name"] = name + } + if args != "" { + fn["arguments"] = args + } + return map[string]any{"choices": []any{ + map[string]any{"delta": map[string]any{"tool_calls": []any{ + map[string]any{"index": idx, "function": fn}, + }}}, + }} + } + getFn := func(f map[string]any) map[string]any { + return f["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) + } + seen := map[int]bool{} + + // 首片带 name:保留,seen 建立 + f0 := mkFrame(0, "lookup", "") + stripToolCallNames(f0, seen) + if !seen[0] { + t.Fatal("index 0 should be marked seen after first chunk") + } + if getFn(f0)["name"] != "lookup" { + t.Errorf("first chunk name=%v want lookup", getFn(f0)["name"]) + } + + // 后续 chunk name 为空串:删除 name 键 + f1 := mkFrame(0, "", `{"term":"x"}`) + stripToolCallNames(f1, seen) + if _, ok := getFn(f1)["name"]; ok { + t.Errorf("subsequent empty name should be stripped: %#v", getFn(f1)) + } + if getFn(f1)["arguments"] != `{"term":"x"}` { + t.Errorf("arguments altered: %#v", getFn(f1)["arguments"]) + } + + // 后续 chunk 重复非空 name(上游噪声):同样删除,arguments 原样 + f2 := mkFrame(0, "lookup", "y") + stripToolCallNames(f2, seen) + if _, ok := getFn(f2)["name"]; ok { + t.Errorf("subsequent duplicate non-empty name should be stripped: %#v", getFn(f2)) + } + if getFn(f2)["arguments"] != "y" { + t.Errorf("arguments altered: %#v", getFn(f2)["arguments"]) + } + + // 不同 index 互不串扰:index 1 首片保留 name + f3 := mkFrame(1, "other", "") + stripToolCallNames(f3, seen) + if getFn(f3)["name"] != "other" { + t.Errorf("index 1 first name=%v want other", getFn(f3)["name"]) + } + if !seen[1] { + t.Error("index 1 should be marked seen") + } + + // 非 tool_calls 帧(content only)零影响 + f4 := map[string]any{"choices": []any{ + map[string]any{"delta": map[string]any{"content": "hi"}}, + }} + stripToolCallNames(f4, seen) + if got := f4["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any); len(got) != 1 || got["content"] != "hi" { + t.Errorf("content-only frame altered: %#v", got) + } +} + +// TestStreamToolCallNameOnce 11 帧 tool_call:首帧 name=Bash,后续 10 帧不得携带 +// name 键,arguments 逐帧原样透传(issue #82:累加型客户端把每个分片 name 拼接成 +// Bash×帧数;正确行为是 name 只在首帧出现一次)。 +func TestStreamToolCallNameOnce(t *testing.T) { + const nFrames = 11 + var sb strings.Builder + for i := 0; i < nFrames; i++ { + sb.WriteString(`data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"Bash","arguments":"arg` + string(rune('0'+i)) + `"}}]}}]}`) + sb.WriteString("\n\n") + } + sb.WriteString("data: [DONE]\n\n") + + frames, done := streamFrames(t, sb.String()) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + if len(frames) != nFrames { + t.Fatalf("frames=%d want %d", len(frames), nFrames) + } + gotName := 0 + for i, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + if len(tcs) != 1 { + t.Fatalf("frame %d tool_calls len=%d want 1", i, len(tcs)) + } + fn, _ := tcs[0].(map[string]any)["function"].(map[string]any) + if _, ok := fn["name"]; ok { + gotName++ + if i != 0 || fn["name"] != "Bash" { + t.Errorf("frame %d unexpected name=%v (name 只能出现在首帧且为 Bash)", i, fn["name"]) + } + } + if want := "arg" + string(rune('0'+i)); fn["arguments"] != want { + t.Errorf("frame %d arguments=%v want %q", i, fn["arguments"], want) + } + } + if gotName != 1 { + t.Errorf("name 出现帧数=%d want 1", gotName) + } +} + +// TestStreamToolCallParallelFragments 多 tool_call(index 0 与 1 并行分片交错下发): +// 每个 index 只保留自己的首帧 name,后续分片互不串扰、arguments 各自原样。 +func TestStreamToolCallParallelFragments(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":0,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"Read\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Bash\",\"arguments\":\"a0\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"Read\",\"arguments\":\"a1\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + + argByIndex := map[int]string{} + nameCount := map[int]int{} + for _, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + idx := int(tc["index"].(float64)) + fn, _ := tc["function"].(map[string]any) + if _, ok := fn["name"]; ok { + nameCount[idx]++ + } + if a, _ := fn["arguments"].(string); a != "" { + argByIndex[idx] = a + } + } + } + // 每个 index 恰好出现一次 name,arguments 逐片原样(a0/a1 各自保留) + if nameCount[0] != 1 || nameCount[1] != 1 { + t.Errorf("name 出现次数 index0=%d index1=%d,各 want 1", nameCount[0], nameCount[1]) + } + if argByIndex[0] != "a0" || argByIndex[1] != "a1" { + t.Errorf("arguments index0=%q index1=%q want a0/a1", argByIndex[0], argByIndex[1]) + } +} + +// TestStreamToolCallNoiseEmptyName 上游后续帧带空串 name(噪声形态)→ 输出帧无 name 键。 +// 键缺失是比空串更安全的形态,客户端「键缺失则保留旧值」不会清空工具名。 +func TestStreamToolCallNoiseEmptyName(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"arg1\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"arg2\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + for i, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + fn, _ := tc["function"].(map[string]any) + if i == 0 { + if fn["name"] != "lookup" { + t.Errorf("frame 0 name=%v want lookup", fn["name"]) + } + continue + } + if _, ok := fn["name"]; ok { + t.Errorf("frame %d: 空串 name 应被剥离为键缺失, got %#v", i, fn) + } + } + } +} + +// TestStreamToolCallOverwriteClientSemantics 覆盖型语义验证:模拟「键缺失则保留旧值」 +// 的覆盖型客户端(name ?? state.name / if (name) state.name = name),在输出流上逐帧 +// 重建 name,最终必须收敛为 Bash——证明键缺失形态不会清空工具名。 +func TestStreamToolCallOverwriteClientSemantics(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + state := map[int]string{} + reconstructed := map[int]string{} + for _, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + idx := int(tc["index"].(float64)) + fn, _ := tc["function"].(map[string]any) + // 覆盖型语义:键缺失 → ?? 保留旧值;非空 name → 覆盖。 + if name, ok := fn["name"]; ok { + state[idx] = name.(string) + } + if state[idx] != "" { + reconstructed[idx] = state[idx] + } + } + } + // 覆盖型客户端重建后最终 name 必须是 Bash(首帧建立,后续空/重复分片均不破坏)。 + if len(reconstructed) != 1 || reconstructed[0] != "Bash" { + t.Errorf("覆盖型重建 name=%v want map[0:Bash]", reconstructed) + } +} + +// streamFrames 把原始 SSE 输入经 Stream 处理后解析出所有 JSON 帧及 [DONE] 计数。 +func streamFrames(t *testing.T, raw string) (frames []map[string]any, doneCount int) { + t.Helper() + rec := httptest.NewRecorder() + if err := Stream(rec, strings.NewReader(raw)); err != nil { + t.Fatal(err) + } + body := rec.Body.String() + for _, ln := range strings.Split(body, "\n") { + ln = strings.TrimSpace(ln) + if strings.HasPrefix(ln, "data: [DONE]") { + doneCount++ + continue + } + if strings.HasPrefix(ln, "data: ") { + var obj map[string]any + if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &obj); err != nil { + t.Fatalf("bad frame %q: %v", ln, err) + } + frames = append(frames, obj) + } + } + return frames, doneCount +} + +func TestNormalizeFrame(t *testing.T) { + cases := []struct { + name string + in map[string]any + want string // 规范化后 marshal 的期望 JSON(Go map 键按字典序输出) + }{ + {"empty content/refusal and finish_reason empty string", + map[string]any{"id": "x", "choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"content": "", "refusal": ""}, "finish_reason": ""}, + }}, + `{"choices":[{"delta":{},"finish_reason":null,"index":0}],"id":"x","object":"chat.completion.chunk","usage":null}`}, + {"non-empty tool_calls kept", + map[string]any{"choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"id": "c1", "type": "function"}}}}, + }}, + `{"choices":[{"delta":{"tool_calls":[{"id":"c1","type":"function"}]},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, + {"empty tool_calls list dropped", + map[string]any{"choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{}, "content": "hi"}}, + }}, + `{"choices":[{"delta":{"content":"hi"},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, + {"empty placeholder function_call dropped", + map[string]any{"choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"function_call": map[string]any{"name": "", "arguments": ""}}}, + }}, + `{"choices":[{"delta":{},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, + {"top-level unknown fields dropped, usage null when absent", + map[string]any{"id": "x", "object": "chat.completion.chunk", "created": 1, "junk": "noise", "choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}, + }}, + `{"choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":1,"id":"x","object":"chat.completion.chunk","usage":null}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + raw, err := json.Marshal(normalizeFrame(c.in)) + if err != nil { + t.Fatal(err) + } + if string(raw) != c.want { + t.Errorf("got %s\nwant %s", raw, c.want) + } + }) + } +} + +func TestStreamNormalizesFrames(t *testing.T) { + // 混合噪声帧:空 content/reasoning/refusal/function_call + 空 tool_calls + 顶层非标字段, + // 随后非空 content + tool_calls 帧,最后 finish/usage 帧。 + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"\",\"refusal\":\"\",\"tool_calls\":[],\"function_call\":{\"name\":\"\",\"arguments\":\"\"}},\"finish_reason\":\"\"}],\"extra_field\":\"junk\"}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"北京\\\"}\"},\"index\":0}]},\"finish_reason\":\"\"}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done frames=%d want 1", done) + } + if len(frames) != 3 { + t.Fatalf("frames=%d want 3", len(frames)) + } + + // 帧 1:噪声全剔除,finish_reason ""→null,usage 缺失→null,顶层非标字段剥除 + f0 := frames[0] + if _, ok := f0["extra_field"]; ok { + t.Error("top-level extra_field should be dropped") + } + if f0["usage"] != nil { + t.Errorf("usage should be null when absent, got %v", f0["usage"]) + } + ch0 := f0["choices"].([]any)[0].(map[string]any) + if ch0["finish_reason"] != nil { + t.Errorf("frame1 finish_reason=%v want null", ch0["finish_reason"]) + } + d := ch0["delta"].(map[string]any) + // role 是合法白名单键保留;空 content/reasoning/refusal/tool_calls/function_call 噪声全剔除 + if len(d) != 1 || d["role"] != "assistant" { + t.Errorf("frame1 delta should only keep role, got %#v", d) + } + for _, noise := range []string{"content", "reasoning_content", "refusal", "tool_calls", "function_call"} { + if _, ok := d[noise]; ok { + t.Errorf("frame1 delta should drop %q, got %#v", noise, d) + } + } + + // 帧 2:非空 content 与 tool_calls 保留,finish_reason ""→null + f1 := frames[1] + ch1 := f1["choices"].([]any)[0].(map[string]any) + d1 := ch1["delta"].(map[string]any) + if d1["content"] != "hello" { + t.Errorf("frame2 content=%v", d1["content"]) + } + tcs, ok := d1["tool_calls"].([]any) + if !ok || len(tcs) != 1 { + t.Fatalf("frame2 tool_calls=%#v", d1["tool_calls"]) + } + if ch1["finish_reason"] != nil { + t.Errorf("frame2 finish_reason=%v want null (input empty string)", ch1["finish_reason"]) + } + + // 帧 3:finish_reason 非空保留,usage 保留 + f2 := frames[2] + ch2 := f2["choices"].([]any)[0].(map[string]any) + if ch2["finish_reason"] != "stop" { + t.Errorf("frame3 finish_reason=%v want stop", ch2["finish_reason"]) + } + if f2["usage"].(map[string]any)["total_tokens"].(float64) != 7 { + t.Errorf("frame3 usage=%v", f2["usage"]) + } +} + +// TestStreamFirstIdPassthrough 帧混合(首帧有 id / 中间帧无 id / 空串 id):输出每帧 id +// 必须连续一致(取首帧真实值),不再一律 chatcmpl-wb2api(issue #35 后台聚合:透传流里 +// 每帧同 id 才能按消息归并)。 +func TestStreamFirstIdPassthrough(t *testing.T) { + raw := "data: {\"id\":\"chatcmpl-upstream-9\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hello\"}}]}\n\n" + + // 中间帧无 id:应复用首帧 id。 + "data: {\"object\":\"chat.completion.chunk\",\"created\":1,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"}}]}\n\n" + + // 中间帧 id 为空串:同样复用首帧 id。 + "data: {\"id\":\"\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + if len(frames) != 3 { + t.Fatalf("frames=%d want 3", len(frames)) + } + for i, fr := range frames { + if got := fr["id"]; got != "chatcmpl-upstream-9" { + t.Errorf("frame %d id=%v want chatcmpl-upstream-9 (首帧真实 id 续传)", i, got) + } + } +} + +// TestStreamNoIdFallsBackToSentinel 全流无任何真实 id → 兜底 chatcmpl-wb2api +// (整流无 id 时的既有哨兵,帧与帧之间仍一惯性存在)。 +func TestStreamNoIdFallsBackToSentinel(t *testing.T) { + raw := "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n" + + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{}," + + "\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + frames, _ := streamFrames(t, raw) + if len(frames) != 2 { + t.Fatalf("frames=%d want 2", len(frames)) + } + for i, fr := range frames { + if got := fr["id"]; got != "chatcmpl-wb2api" { + t.Errorf("frame %d id=%v want sentinel chatcmpl-wb2api (无真实 id)", i, got) + } + } +} + +func TestStreamDoneFallback(t *testing.T) { + // 上游流在无 [DONE] 时 EOF,Stream 必须兜底写一个 [DONE] + rec := httptest.NewRecorder() + err := Stream(rec, strings.NewReader("data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")) + if err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if !strings.HasSuffix(strings.TrimRight(body, "\n"), "data: [DONE]") { + t.Errorf("missing [DONE] fallback: %q", body) + } + + // 已有 [DONE] 时只写一次,不重复 + rec2 := httptest.NewRecorder() + if err := Stream(rec2, strings.NewReader("data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n")); err != nil { + t.Fatal(err) + } + if n := strings.Count(rec2.Body.String(), "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, rec2.Body.String()) + } +} + +func TestStreamPassthrough(t *testing.T) { + rec := httptest.NewRecorder() + err := Stream(rec, strings.NewReader(sseFixture)) + if err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if !strings.Contains(body, "你好") || !strings.Contains(body, "data: [DONE]") { + t.Errorf("body missing chunks: %q", body) + } + // 逐行仍是合法 SSE(每行以 data: 开头或是空行) + for _, ln := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + if ln != "" && !strings.HasPrefix(ln, "data: ") { + t.Errorf("bad line: %q", ln) + } + } + ct := rec.Header().Get("Content-Type") + if !strings.Contains(ct, "text/event-stream") { + t.Errorf("content-type=%q", ct) + } +} + +// TestAggregateEmptyStreamCases 覆盖空流检测:0 有效事件必须报错、[DONE] 即 break、 +// [DONE] 后垃圾不进聚合、正常聚合回归。 +func TestAggregateEmptyStreamCases(t *testing.T) { + // 正常回归基流:content + finish_reason + usage,[DONE] 收尾。 + valid := "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n" + + "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + + cases := []struct { + name string + raw string + wantErr bool + // 回归断言(仅在 wantErr=false 时校验) + wantContent string + wantUsage float64 + }{ + { + name: "空流(EOF 即止)", + raw: "", + wantErr: true, + }, + { + name: "只有注释行和空行加 DONE", + raw: ": comment\n\n: another comment\n\ndata: [DONE]\n\n", + wantErr: true, + }, + { + name: "DONE 后跟垃圾帧不进聚合", + raw: valid[:len(valid)-len("data: [DONE]\n\n")] + "data: [DONE]\n\ndata: {\"junk\":\"should not aggregate\"}\n\n", + wantErr: false, + // 与 valid 基流一致的聚合期望 + wantContent: "hi", + wantUsage: 7, + }, + { + name: "正常流回归", + raw: valid, + wantErr: false, + wantContent: "hi", + wantUsage: 7, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp, err := Aggregate(strings.NewReader(c.raw)) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (resp=%v)", resp) + } + return + } + if err != nil { + t.Fatal(err) + } + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != c.wantContent { + t.Errorf("content=%q want %q", msg["content"], c.wantContent) + } + if u, ok := resp["usage"].(map[string]any); ok { + if u["total_tokens"].(float64) != c.wantUsage { + t.Errorf("usage=%v want %v", u["total_tokens"], c.wantUsage) + } + } else { + t.Errorf("usage missing") + } + }) + } +} + +// TestAggregateEmptyStreamError 校验空流错误信息形如约定文案。 +func TestAggregateEmptyStreamError(t *testing.T) { + _, err := Aggregate(strings.NewReader("")) + if err == nil || !strings.Contains(err.Error(), "no valid data events") { + t.Fatalf("err=%v", err) + } +} + +// TestStreamEmptyFramesCase 覆盖流式空流检测:0 有效帧时写 error 帧(error 字段存活), +// 恰好一个 [DONE],并返回非 nil error。 +func TestStreamEmptyFramesCase(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"空流", ""}, + {"只有注释行", ": comment\n\n"}, + {"只有 DONE", "data: [DONE]\n\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + err := Stream(rec, strings.NewReader(c.raw)) + if err == nil { + t.Fatalf("expected error, got nil") + } + body := rec.Body.String() + if n := strings.Count(body, "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, body) + } + // error 帧必须原样保留 error 字段(未被 normalizeFrame 白名单剥掉) + var e map[string]any + found := false + for _, ln := range strings.Split(body, "\n") { + ln = strings.TrimSpace(ln) + if strings.HasPrefix(ln, "data: ") { + payload := strings.TrimPrefix(ln, "data: ") + if payload == "[DONE]" { + continue + } + if json.Unmarshal([]byte(payload), &e) == nil { + if em, ok := e["error"].(map[string]any); ok && em["message"] == "empty upstream stream" && em["type"] == "upstream_error" { + found = true + } + } + } + } + if !found { + t.Errorf("error frame absent or error field stripped: %q", body) + } + }) + } +} + +// TestStreamGarbageAfterDone 校验 DONE 之后的垃圾帧不出现在响应里。 +func TestStreamGarbageAfterDone(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: [DONE]\n\n" + + "data: {\"should\":\"not appear\"}\n\n" + rec := httptest.NewRecorder() + if err := Stream(rec, strings.NewReader(raw)); err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if strings.Contains(body, "should") { + t.Errorf("garbage after DONE leaked into response: %q", body) + } + if n := strings.Count(body, "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, body) + } + // 有效帧仍被透传 + if !strings.Contains(body, "hello") { + t.Errorf("valid frame missing: %q", body) + } +} + +// TestStreamNormalPassthroughRegression 校验正常透传回归:帧被 normalize 后透传、 +// 末尾恰好一个 [DONE]、无 error 帧;上游漏发 DONE 时自动补。 +func TestStreamNormalPassthroughRegression(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"带 DONE 的正常流", sseFixture}, + {"漏发 DONE 自动补", "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + if err := Stream(rec, strings.NewReader(c.raw)); err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if strings.Contains(body, `"error"`) { + t.Errorf("unexpected error frame: %q", body) + } + if n := strings.Count(body, "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, body) + } + // 帧被规范化:含 "id" 且有标准 object 字段 + if !strings.Contains(body, `"object":"chat.completion.chunk"`) { + t.Errorf("frame not normalized: %q", body) + } + }) + } +} + +// TestNormalizeUsageCacheAliasesMirrorsNestedHit / PreservesZeroResult 自 +// usage_test.go 迁入(PR #57 原新文件按仓库规则不收,核心断言保留在此—— +// normalize 钩子就挂在 sse.go 的 Aggregate/normalizeFrame 两个出口)。 +func TestNormalizeUsageCacheAliasesMirrorsNestedHit(t *testing.T) { + usage := map[string]any{ + "prompt_tokens": 21041.0, + "completion_tokens": 8.0, + "total_tokens": 21049.0, + "cache_read_input_tokens": 0.0, + "cached_tokens": 0.0, + "prompt_cache_hit_tokens": 0.0, + "prompt_cache_miss_tokens": 177.0, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 20864.0, + }, + } + + got := normalizeUsageCacheAliases(usage) + + for _, key := range []string{ + "cache_read_input_tokens", + "cached_tokens", + "prompt_cache_hit_tokens", + } { + if got[key] != 20864.0 { + t.Fatalf("%s=%v want 20864", key, got[key]) + } + } + details := got["prompt_tokens_details"].(map[string]any) + if details["cached_tokens"] != 20864.0 { + t.Fatalf("prompt_tokens_details.cached_tokens=%v want 20864", details["cached_tokens"]) + } +} + +func TestNormalizeUsageCacheAliasesPreservesZeroResult(t *testing.T) { + usage := map[string]any{ + "prompt_tokens": 35.0, + "completion_tokens": 2.0, + "total_tokens": 37.0, + "cache_read_input_tokens": 0.0, + "prompt_cache_hit_tokens": 0.0, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 0.0, + }, + } + + got := normalizeUsageCacheAliases(usage) + + details := got["prompt_tokens_details"].(map[string]any) + if details["cached_tokens"] != 0.0 { + t.Fatalf("prompt_tokens_details.cached_tokens=%v want 0", details["cached_tokens"]) + } +} + +func TestUserResourceDetailedWithExpirySnapshot(t *testing.T) { + now := time.Now().In(softRateResetLoc) + soon := now.Add(24 * time.Hour).Truncate(time.Second) + later := now.Add(10 * 24 * time.Hour).Truncate(time.Second) + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[` + + `{"PackageName":"soon-a","CycleCapacitySize":10,"CycleCapacityRemain":10,"CycleCapacityUsed":0,"CycleEndTime":"` + soon.Format(packageEndLayout) + `"},` + + `{"PackageName":"soon-b","CycleCapacitySize":15,"CycleCapacityRemain":15,"CycleCapacityUsed":0,"CycleEndTime":"` + soon.Format(packageEndLayout) + `"},` + + `{"PackageName":"later","CycleCapacitySize":20,"CycleCapacityRemain":20,"CycleCapacityUsed":0,"CycleEndTime":"` + later.Format(packageEndLayout) + `"},` + + `{"PackageName":"unknown","CycleCapacitySize":5,"CycleCapacityRemain":5,"CycleCapacityUsed":0}` + + `]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, payload), nil + }) + + remain, total, expiring, earliestAt, earliestRemaining, err := c.UserResourceDetailedWithExpiry( + &auth.Auth{AccessToken: "at", UID: "u1"}, 48*time.Hour, + ) + if err != nil { + t.Fatalf("resource: %v", err) + } + if remain != 50 || total != 50 || expiring != 25 { + t.Fatalf("remain/total/expiring=%d/%d/%d want 50/50/25", remain, total, expiring) + } + if earliestRemaining != 25 || !earliestAt.Equal(soon) { + t.Fatalf("earliest=%v/%d want %v/25", earliestAt, earliestRemaining, soon) + } +} +func TestCreditPackagesExpiryTimestamp(t *testing.T) { + end := time.Now().In(softRateResetLoc).Add(7 * 24 * time.Hour).Truncate(time.Second) + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[`+ + `{"PackageName":"gift","CycleCapacitySize":100,"CycleCapacityRemain":80,"CycleCapacityUsed":20,"CycleEndTime":"`+ + end.Format(packageEndLayout)+`"},`+ + `{"PackageName":"unknown","CycleCapacitySize":10,"CycleCapacityRemain":10,"CycleCapacityUsed":0}`+ + `]}}}}`), nil + }) + packs, remain, size, err := c.CreditPackages(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("packages: %v", err) + } + if remain != 90 || size != 110 { + t.Fatalf("remain/size=%d/%d want 90/110", remain, size) + } + var found bool + for _, p := range packs { + if p.Name == "gift" { + found = p.ExpiresAt == end.UnixMilli() + } + } + if !found { + t.Fatalf("gift pack missing Unix-ms expiry: %+v", packs) + } +} diff --git a/internal/upstream/streak.go b/internal/upstream/streak.go new file mode 100644 index 0000000000000000000000000000000000000000..4f550d3bbc1bc91614fbcf18974643c9be820f98 --- /dev/null +++ b/internal/upstream/streak.go @@ -0,0 +1,106 @@ +// streak.go 成长中心连登兑换 + 抽奖 API(2026-09-12 成长中心 bundle 逆向 + 页面实测)。 +// +// 机制:连登档位(7d/14d/28d)按**连续登录天数**解锁;兑换(POST /activity/growth/redeem) +// 发 credit/energy/补签卡/**抽奖次数**;抽奖(POST /activity/growth/lottery/draw)每次消耗 +// 1 次 chances。未解锁兑换返回 HTTP 403「连续登录天数不足」。 +// client_token 为前端生成的幂等令牌(randomUUID)。 +package upstream + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + + "fmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "net/http" +) + +// streakRedeemPath / lottery 路径(growth 域,growthJSON 走 www.workbuddy.cn)。 +const ( + streakRedeemPath = "/activity/growth/redeem" + lotterySummaryPath = "/activity/growth/lottery/summary" + lotteryDrawPath = "/activity/growth/lottery/draw" +) + +// clientToken 幂等令牌(前端 randomUUID 同款语义)。 +func clientToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return fmt.Sprintf("%s-%s-%s-%s-%s", + hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), + hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16])) +} + +// StreakFull 连登完整状态(GET /activity/growth/streak)。 +type StreakFull struct { + Streak struct { + Days int `json:"days"` + MonthTotalDays int `json:"month_total_days"` + NextTier string `json:"next_tier"` + NextTierRemaining int `json:"next_tier_remaining"` + } `json:"streak"` + MakeupCards struct { + Balance int `json:"balance"` + Max int `json:"max"` + } `json:"makeup_cards"` + RedemptionStatus struct { + Tier7dStatus string `json:"tier_7d_status"` + Tier14dStatus string `json:"tier_14d_status"` + Tier28dStatus string `json:"tier_28d_status"` + RemainingDays int `json:"remaining_days"` + Tiers []struct { + Tier string `json:"tier"` + Days int `json:"days"` + Credit int `json:"credit"` + Energy int `json:"energy"` + Cards int `json:"cards"` + Chances int `json:"chances"` + } `json:"tiers"` + } `json:"redemption_status"` +} + +// GrowthStreakFull 拉取连登完整状态。 +func (c *Client) GrowthStreakFull(a *auth.Auth) (*StreakFull, error) { + data, err := c.growthJSON(a, http.MethodGet, streakPath, nil) + if err != nil { + return nil, err + } + out := &StreakFull{} + if err := json.Unmarshal(data, out); err != nil { + return nil, err + } + return out, nil +} + +// GrowthRedeemTier 兑换连登档位(tier: "7d"|"14d"|"28d")。 +// 未解锁返回 *Error(HTTP 403「连续登录天数不足」),调用方按 locked 状态跳过即可。 +func (c *Client) GrowthRedeemTier(a *auth.Auth, tier string) error { + _, err := c.growthJSON(a, http.MethodPost, streakRedeemPath, + map[string]any{"tier": tier, "client_token": clientToken()}) + return err +} + +// LotteryChances 当前抽奖次数(GET /activity/growth/lottery/summary)。 +func (c *Client) LotteryChances(a *auth.Auth) (int, error) { + data, err := c.growthJSON(a, http.MethodGet, lotterySummaryPath, nil) + if err != nil { + return 0, err + } + var resp struct { + Chances int `json:"chances"` + Module struct { + Enabled bool `json:"enabled"` + } `json:"module"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, err + } + return resp.Chances, nil +} + +// LotteryDraw 抽奖一次,返回原始奖品载荷(prize 字段形状由活动期决定,透传给调用方)。 +func (c *Client) LotteryDraw(a *auth.Auth) (json.RawMessage, error) { + return c.growthJSON(a, http.MethodPost, lotteryDrawPath, + map[string]any{"client_token": clientToken()}) +} diff --git a/internal/upstream/streak_test.go b/internal/upstream/streak_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dc76fc7164f7253db3838122ea74d37e4ff5b874 --- /dev/null +++ b/internal/upstream/streak_test.go @@ -0,0 +1,77 @@ +package upstream + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestGrowthStreakParsesDays 解析 data.streak.days(probe_active.py 同口径)。 +func TestGrowthStreakParsesDays(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/activity/growth/streak" { + t.Errorf("path=%s want /activity/growth/streak", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("method=%s want GET", r.Method) + } + w.Write([]byte(`{"code":0,"msg":"ok","data":{"streak":{"days":3,"total_rewards":1}}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + days, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("GrowthStreak: %v", err) + } + if days != 3 { + t.Errorf("days=%d want 3", days) + } +} + +// TestGrowthStreakDefaultZero 缺 streak/days 字段 → 0(days==0 即自检告警信号)。 +func TestGrowthStreakDefaultZero(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"data":{}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + days, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("GrowthStreak: %v", err) + } + if days != 0 { + t.Errorf("days=%d want 0(缺字段零值)", days) + } +} + +// TestGrowthStreakServerError HTTP 非 2xx → *Error。 +func TestGrowthStreakServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`boom`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on 500") + } +} + +// TestGrowthStreakBusinessCode 业务 code 非 0 → 错误(非 2xx 之外的静默失败也要能被观测)。 +func TestGrowthStreakBusinessCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"code":12000,"msg":"internal"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on non-zero business code") + } +} diff --git a/internal/upstream/tasks.go b/internal/upstream/tasks.go new file mode 100644 index 0000000000000000000000000000000000000000..b48c9755bcd0b0e7c2767fab42b2b3803c9a2e5e --- /dev/null +++ b/internal/upstream/tasks.go @@ -0,0 +1,272 @@ +// tasks.go growth 域「任务」接口:列表查询 / 接受 / 领取奖励。 +// +// 来源:上游 scripts/task_common.py 实测口径(list_tasks/accept_tasks/claim_reward), +// 集成进主程序后不再需要外部脚本。 +// +// 端点(chatBase,BillingHeaders): +// - GET /v2/activity/growth/tasks 全量任务列表(含 progress/accept_status) +// - POST /v2/activity/growth/tasks/accept {"task_codes":[...]} not_accepted → accepted +// - POST /v2/activity/growth/tasks/reward/claim {"task_code":"..."} 完成态领奖 +// +// 语义要点: +// - accept 是"报名",不产生进度;进度由服务端行为事件点亮(如 chat_request_send 上报、 +// 真实对话),故 accept 后可幂等重放。 +// - claim 仅在 progress 达标后可领;重复领返回业务错误(安全,不需要前置状态判断)。 +package upstream + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// growth 域任务路径(与 scripts/task_common.py 对齐)。 +const ( + tasksListPath = "/v2/activity/growth/tasks" + tasksAcceptPath = "/v2/activity/growth/tasks/accept" +) + +// mpPlatform 小程序口径头值:小程序限定任务(Sequential_Tasks_1 / school_season) +// 的列表下发、accept、claim 全链路要求 X-Client-Platform: miniprogram。 +const mpPlatform = "miniprogram" + +// Task 单个任务的对外视图(字段名与上游 JSON 对齐,多余字段不透出)。 +type Task struct { + TaskCode string `json:"task_code"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` // 操作指引(含跳转说明) + TaskDesc string `json:"task_desc,omitempty"` // 达成条件简述 + Credit int64 `json:"credit,omitempty"` // 奖励积分(上游 reward_credit) + Energy int64 `json:"energy,omitempty"` // 奖励能量(上游 reward_energy) + HasReward bool `json:"has_reward,omitempty"` // 是否带奖励 + RewardBuddy bool `json:"reward_buddy,omitempty"` + TaskType string `json:"task_type,omitempty"` // single(一次性)/ 累计型 + Tag string `json:"tag,omitempty"` // 端标记(PC 等) + JumpURL string `json:"jump_url,omitempty"` // 客户端跳转协议(workbuddy://...) + Locked bool `json:"locked,omitempty"` // 上游标记未解锁 + Target int64 `json:"target"` // 目标次数(恒输出:0 是有效进度值) + Current int64 `json:"current"` // 当前进度(恒输出:0 是有效进度值) + AcceptStatus string `json:"accept_status,omitempty"` + Status string `json:"status,omitempty"` // 上游任务状态(complete 等) + Claimable bool `json:"claimable,omitempty"` // 进度达标且未领取(本地推算) + Claimed bool `json:"claimed,omitempty"` // 已领取(accept_status == claimed) +} + +// ListTasks 拉取全量任务列表(默认口径,无端标记头)。 +// 响应形如 data.tasks[],元素字段随任务类型变化(progress 可能是 {current,target} 或平铺), +// 这里做宽松解析:两种形状都尝试。 +func (c *Client) ListTasks(a *auth.Auth) ([]Task, error) { + data, err := c.growthJSON(a, http.MethodGet, tasksListPath, nil) + if err != nil { + return nil, err + } + return parseGrowthTasks(data) +} + +// ListTasksMP 拉取小程序口径的任务列表(X-Client-Platform: miniprogram)。 +// 小程序限定任务(Sequential_Tasks_1「小程序首对话」/ school_season「校园日」等) +// 仅在该口径下发——默认列表不出现,accept/claim 同样要求该头(缺头 accept 返回 +// task not found,上游 task_runner 实测)。**实测 mp 列表是默认口径的超集** +// (含 RichMeow/Model_chat 等常规任务 + wb_wechat_oa_subscribe_task 等 mp 专属), +// 合并时调用方须按 task_code 去重。 +func (c *Client) ListTasksMP(a *auth.Auth) ([]Task, error) { + data, err := c.growthJSONMP(a, http.MethodGet, tasksListPath, nil) + if err != nil { + return nil, err + } + return parseGrowthTasks(data) +} + +// growthJSONMP 发 growth 域请求(小程序口径:叠加 X-Client-Platform: miniprogram) +// 并解信封。语义同 growthJSON。 +func (c *Client) growthJSONMP(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.chatBase(a)+path, rdr) + if err != nil { + return nil, err + } + c.BillingHeaders(req, a) + req.Header.Set("X-Client-Platform", mpPlatform) + return c.doJSON(req) +} + +// AcceptTasksMP 接受小程序限定任务(mp 头;缺头实测 task not found)。 +// 幂等语义同 AcceptTasks。 +func (c *Client) AcceptTasksMP(a *auth.Auth, taskCodes []string) error { + _, err := c.growthJSONMP(a, http.MethodPost, tasksAcceptPath, map[string]any{"task_codes": taskCodes}) + return err +} + +// ClaimRewardMP 领取小程序限定任务奖励:chat 域 /activity/growth/tasks/{code}/claim +// + mp 头(上游 task_runner claim_one(mp=True) 同款);chat 域 400 时降级 Web 域 +// 领奖端点(ClaimReward,x-client-platform: web 形态)。返回 (credit, energy, err)。 +func (c *Client) ClaimRewardMP(a *auth.Auth, taskCode string) (credit, energy int64, err error) { + req, err := http.NewRequest(http.MethodPost, + c.chatBase(a)+"/activity/growth/tasks/"+url.PathEscape(taskCode)+"/claim", nil) + if err != nil { + return 0, 0, err + } + c.BillingHeaders(req, a) + req.Header.Set("X-Client-Platform", mpPlatform) + data, err := c.doJSON(req) + if err != nil { + // chat 域对该路径 400(部分任务/租户形态)→ Web 域降级(已实测可领)。 + if ue, ok := err.(*Error); ok && ue.Status == http.StatusBadRequest { + return c.ClaimReward(a, taskCode) + } + return 0, 0, err + } + return parseClaimReward(data) +} + +// parseClaimReward 解析领奖响应 data:{"already_claimed":bool,"credit":n,"energy":n}。 +func parseClaimReward(data json.RawMessage) (credit, energy int64, err error) { + var resp struct { + AlreadyClaimed bool `json:"already_claimed"` + Credit int64 `json:"credit"` + Energy int64 `json:"energy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, 0, err + } + return resp.Credit, resp.Energy, nil +} + +// parseGrowthTasks 解析 growth 任务列表 data.tasks[](默认与 mp 口径共用)。 +func parseGrowthTasks(data json.RawMessage) ([]Task, error) { + var resp struct { + Tasks []struct { + TaskCode string `json:"task_code"` + Title string `json:"title"` + Description string `json:"description"` + TaskDesc string `json:"task_desc"` + RewardCredit int64 `json:"reward_credit"` // 上游实际字段名(reward_ 前缀) + RewardEnergy int64 `json:"reward_energy"` + HasReward bool `json:"has_reward"` + RewardBuddy bool `json:"reward_buddy"` + TaskType string `json:"task_type"` + Tag string `json:"tag"` + JumpURL string `json:"jump_url"` + Locked bool `json:"locked"` + AcceptStatus string `json:"accept_status"` + Status string `json:"status"` + Target int64 `json:"target"` + Current int64 `json:"current"` + Progress json.RawMessage `json:"progress"` + } `json:"tasks"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + out := make([]Task, 0, len(resp.Tasks)) + for _, t := range resp.Tasks { + cur, tgt := t.Current, t.Target + // progress 可能是 {current,target} 对象(实测口径),覆盖平铺字段。 + if len(t.Progress) > 0 && string(t.Progress) != "null" { + var pr struct { + Current int64 `json:"current"` + Target int64 `json:"target"` + } + if json.Unmarshal(t.Progress, &pr) == nil && (pr.Target > 0 || pr.Current > 0) { + cur, tgt = pr.Current, pr.Target + } + } + claimed := t.AcceptStatus == "claimed" + out = append(out, Task{ + TaskCode: t.TaskCode, + Title: t.Title, + Description: t.Description, + TaskDesc: t.TaskDesc, + Credit: t.RewardCredit, + Energy: t.RewardEnergy, + HasReward: t.HasReward, + RewardBuddy: t.RewardBuddy, + TaskType: t.TaskType, + Tag: t.Tag, + JumpURL: t.JumpURL, + Locked: t.Locked, + Target: tgt, + Current: cur, + AcceptStatus: t.AcceptStatus, + Status: t.Status, + Claimable: !claimed && tgt > 0 && cur >= tgt, + Claimed: claimed, + }) + } + return out, nil +} + +// AcceptTasks 接受任务(幂等:已 accepted 时上游返回成功或业务提示,均不视为致命错误)。 +func (c *Client) AcceptTasks(a *auth.Auth, taskCodes []string) error { + _, err := c.growthJSON(a, http.MethodPost, tasksAcceptPath, map[string]any{"task_codes": taskCodes}) + return err +} + +// ClaimReward 领取单个任务奖励。 +// +// 端点来源(实测):Web 成长中心的领奖请求 —— +// +// POST https://www.workbuddy.cn/activity/growth/tasks//claim +// (任务码在**路径**里,无 body;带 x-client-platform: web 头,Bearer 鉴权) +// +// 关键区别:此前误用 CLI 域 copilot.tencent.com 的 +// /v2/activity/growth/tasks/reward/claim(task_code 放 body),该路径**不存在**, +// 一直返回 400 "task not completed",是此前领奖失败的真实原因。 +// 本实现返回 (credit, energy, err):credit/energy 为本次到账奖励(已领取过时为 0)。 +func (c *Client) ClaimReward(a *auth.Auth, taskCode string) (credit, energy int64, err error) { + req, err := http.NewRequest(http.MethodPost, + c.webBase(a)+"/activity/growth/tasks/"+url.PathEscape(taskCode)+"/claim", nil) + if err != nil { + return 0, 0, err + } + // Web 端请求头形状(对照浏览器实际请求):Origin/Referer 指向 workbuddy.cn 成长中心, + // 带 x-client-platform: web 标记来源端。 + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://www.workbuddy.cn") + req.Header.Set("Referer", "https://www.workbuddy.cn/profile/growth-center") + req.Header.Set("x-client-platform", "web") + if ua := c.userAgent(a); ua != "" { + req.Header.Set("User-Agent", ua) + } + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + req.Header.Set("X-Tenant-Id", a.EnterpriseID) + } + if d := a.DomainValue(); d != "" { + req.Header.Set("X-Domain", d) + } + + data, err := c.doJSON(req) + if err != nil { + return 0, 0, err + } + // 响应 data:{"already_claimed":bool,"credit":100,"energy":5,...} + var resp struct { + AlreadyClaimed bool `json:"already_claimed"` + Credit int64 `json:"credit"` + Energy int64 `json:"energy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, 0, err + } + if resp.AlreadyClaimed { + return 0, 0, nil // 幂等:重复领取不算错误,但无新增奖励 + } + return resp.Credit, resp.Energy, nil +} diff --git a/internal/upstream/tasks_test.go b/internal/upstream/tasks_test.go new file mode 100644 index 0000000000000000000000000000000000000000..456d57e1d876e4fb3daa9d8a69b3a85a1d962263 --- /dev/null +++ b/internal/upstream/tasks_test.go @@ -0,0 +1,85 @@ +package upstream + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestClaimRewardWebEndpoint 领奖走 Web 域(workbuddy.cn)、任务码在路径里、无 body。 +// 这是与 CLI 域(copilot.tencent.com/v2/.../reward/claim,task_code 在 body)的关键区别—— +// 后者路径不存在,曾导致长期 400 "task not completed" 误判为"上游不支持领取"。 +func TestClaimRewardWebEndpoint(t *testing.T) { + var gotPath, gotMethod, gotBody string + var gotPlatform, gotReferer string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + gotPlatform = r.Header.Get("x-client-platform") + gotReferer = r.Header.Get("Referer") + buf := make([]byte, 64) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":false,"credit":100,"energy":5}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL} + a := &auth.Auth{AccessToken: "at", UID: "u1"} + + credit, energy, err := c.ClaimReward(a, "Model_chat_GLM5.2") + if err != nil { + t.Fatalf("claim: %v", err) + } + if credit != 100 || energy != 5 { + t.Errorf("credit/energy = %d/%d, want 100/5", credit, energy) + } + if gotMethod != http.MethodPost { + t.Errorf("method=%s want POST", gotMethod) + } + if want := "/activity/growth/tasks/Model_chat_GLM5.2/claim"; gotPath != want { + t.Errorf("path=%q want %q(任务码必须在路径里)", gotPath, want) + } + if gotBody != "" { + t.Errorf("claim 不应携带 body,got %q", gotBody) + } + if gotPlatform != "web" { + t.Errorf("x-client-platform=%q want web", gotPlatform) + } + if !strings.Contains(gotReferer, "workbuddy.cn") { + t.Errorf("Referer=%q 应指向 workbuddy.cn", gotReferer) + } +} + +// TestClaimRewardAlreadyClaimed 重复领取:上游返回 already_claimed=true, +// 本地应视为"无新增奖励但不报错"(幂等语义)。 +func TestClaimRewardAlreadyClaimed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":true}}`)) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), WebBaseCN: srv.URL} + credit, energy, err := c.ClaimReward(&auth.Auth{AccessToken: "at", UID: "u1"}, "chat_5") + if err != nil { + t.Fatalf("already_claimed should not error: %v", err) + } + if credit != 0 || energy != 0 { + t.Errorf("already claimed should yield 0/0, got %d/%d", credit, energy) + } +} + +// TestClaimRewardNotCompleted 未达标:上游 400 + task not completed 应作为错误透出。 +func TestClaimRewardNotCompleted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]any{"code": 400, "msg": "task not completed"}) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), WebBaseCN: srv.URL} + if _, _, err := c.ClaimReward(&auth.Auth{AccessToken: "at", UID: "u1"}, "chat_5"); err == nil { + t.Fatal("want error for not-completed task") + } +} diff --git a/internal/upstream/thinking.go b/internal/upstream/thinking.go new file mode 100644 index 0000000000000000000000000000000000000000..3d154943138cb09515047c973d31e3c41d5b3c80 --- /dev/null +++ b/internal/upstream/thinking.go @@ -0,0 +1,204 @@ +// thinking.go DeepSeek 思维链开启:出站请求体注入 thinking:{type:"enabled"} + 默认档位。 +// +// 根因(issue #43,Hermes 逆向官方客户端 codebuddy.js 已确认): +// 官方客户端对 deepseek 系模型标记 thinkingFormat:"deepseek" + requiresReasoningContentOnAssistantMessages, +// 发请求时「开思考」必须显式带 thinking:{type:"enabled"},否则上游默认按不思考应答 +// (思维链不返回)。网关 payload 层此前完全不感知该字段,透传请求没有这个开关 +// → 上游不给思维链;glm/kimi 走其他 thinkingFormat(qwen 系 enable_thinking 或默认开)所以正常。 +// +// 打回修复(Hermes #43 验收实测): +// +// thinking.type=enabled 单一字段不足——真实上游 deepseek-v4-flash 对「不带 reasoning_effort」的裸请求 +// 仍然按不思考应答(reasoning_content 长度 0),带 reasoning_effort:high 才有思维链。 +// 逆向 codebuddy.js 证实:isThinkingEnabled = !!(reasoning_summary || reasoning_effort || reasoning?.effort), +// case "deepseek" 的 enabled 分支在实际出站里同时保留 reasoning_effort,官方「开思考」= thinking.type:enabled +// + 某档 effort;默认档来自 reasoning.defaultEffort ?? 兜底 "high"(configure thinking 无来源时 warn fallback to 'high')。 +// +// 行为对齐官方客户端(两路组合): +// - thinking.type 已显式 enabled / disabled → 客户端显式控制,绝不覆盖;disabled 时照抄 case 行为 +// 删 reasoning_effort(snake/camel 双字段)。enabled 但缺 effort → 补默认档(官方 configure 行为)。 +// - 无 thinking / thinking.type 空 / 已有 reasoning_effort → 注入 {type:"enabled"} + 补默认档。 +// - 显式 reasoning_effort 一律不覆盖、不降级(降级交给 payload.go normalizeReasoningEffort)。 +// - 非 deepseek 模型(glm/kimi/qwen 等)→ 零改动。 +package upstream + +import ( + "strings" +) + +// defaultDeepSeekEffort 官方客户端默认档兜底(configure thinking 无来源时 warn fallback to 'high', +// REASONING_SUPPLEMENTS.defaultEffort 亦为 "high")。补入后走 normalizeReasoningEffort 降级管线, +// 模型不支持 high 时自动落到 ≤high 的最高支持档。 +const defaultDeepSeekEffort = "high" + +// lookupDefaultEffort 从 FetchModels 缓存的 defaultEfforts 表按模型名查默认档。 +// nil map 或模型未缓存 → 空串(thinking.go 回退硬编码 high)。 +// 键为模型 ID 原样(与 efforts 缓存对齐:normalizeReasoningEffort 精确匹配 model)。 +func lookupDefaultEffort(defaultEfforts map[string]string, model string) string { + if len(defaultEfforts) == 0 || model == "" { + return "" + } + return defaultEfforts[model] +} + +// isDeepSeekModel 模型名以 deepseek 为前缀(不区分大小写)。 +// 覆盖 deepseek-v4.1-flash / deepseek-v4-pro / deepseek-r1 等变体; +// 前缀匹配对齐官方 thinkingFormat:"deepseek" 的判定口径,避免漏注。 +func isDeepSeekModel(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "deepseek") +} + +// backfillReasoningContent DeepSeek 多轮一致性:保证每条 assistant 消息带 +// reasoning_content 字段且值为 string——即 requiresReasoningContentOnAssistantMessages +// (官方客户端 matches 规则,issue #165 对齐官方 apply 门控)。 +// +// 门控(对齐官方 ReasoningContentBackfillRule:thinkingEnabled || hasTrace), +// thinkingEnabled 取自注入后请求体的 thinking.type == "enabled"(injectThinking 先行, +// payload.go 管线顺序已保证;本网关对 deepseek 无条件注入 enabled,等价于非 disabled 一律补): +// - 非 deepseek 模型 → 零改动(isDeepSeekModel 闸不动)。 +// - deepseek + enabled(含 L1 注入后)→ 每条 assistant 保证 reasoning_content 是 +// string:已有 string 原样保留(不覆盖);reasoning 是非空 string 且 rc 非 string → +// 复制 reasoning 值;两者皆无 → 补空串 ""。第三方客户端丢推理回传(零痕迹)形态 +// 下官方本就补,网关此前只移植了 hasTrace 半边(issue #165 修复点)。 +// - deepseek + disabled + 无痕迹 → 零改动(官方 thinkingEnabled=false 且 ec=false → 不补)。 +// - deepseek + disabled + 有痕迹 → 照补(官方 hasTrace 半边,双方一致)。 +// +// 归一化对齐官方 "string"!=typeof 语义:reasoning_content 为 null/数字等非 string +// 值时不算「已有」,落补 ""/复制分支(旧代码键存在即跳过,null 会被当「已有」漏补)。 +// hasTrace = 会话内任一消息带非空 reasoning(string)或已有 reasoning_content 字段 +// (比官方仅扫 assistant 的口径宽,只影响 disabled 分支,装饰性差异)。 +func backfillReasoningContent(obj map[string]any) { + model, _ := obj["model"].(string) + if !isDeepSeekModel(model) { + return + } + msgs, ok := obj["messages"].([]any) + if !ok || len(msgs) == 0 { + return + } + // thinkingEnabled 半边:读注入后的 thinking.type(与官方 el.thinkingEnabled 对应)。 + thinkingEnabled := false + if th, ok := obj["thinking"].(map[string]any); ok { + if typ, _ := th["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "enabled") { + thinkingEnabled = true + } + } + // hasTrace 半边:检测是否有任何 reasoning 痕迹(非空 reasoning 或已有 reasoning_content)。 + hasTrace := false + for _, mm := range msgs { + msg, ok := mm.(map[string]any) + if !ok { + continue + } + if r, ok := msg["reasoning"].(string); ok && r != "" { + hasTrace = true + break + } + if _, ok := msg["reasoning_content"]; ok { + hasTrace = true + break + } + } + if !thinkingEnabled && !hasTrace { + return + } + // 第二遍:所有 assistant 消息补/复制 reasoning_content 字段,并镜像保证 + // reasoning 字段存在且非空(issue #165 追评——部分账号/租户对 thinking 形态 + // 校验 len(reasoning)>0:缺失/null/空串 400,空白串 200;官方 CLI 本就给 + // assistant 挂上一轮 reasoning 文本,见 itemsToMessages 的 applyPendingReasoning)。 + // 跳过条件只认 string(官方 "string"!=typeof 才动手):null/数字归一化。 + // - reasoning 已是非空 string → 不动; + // - rc 是非空 string → 镜像写入 rc 值(两字段最终都存在且非空); + // - 两者皆无/皆空 → 补单个空格 " "(上游 len>0 不 trim:空白串过闸、空串 + // 不过——空白串占位有官方 Moonshot 规则 "-" 同款先例,且对模型上下文 + // 无语义影响:该字段是透传校验位非内容消费位)。 + for _, mm := range msgs { + msg, ok := mm.(map[string]any) + if !ok { + continue + } + role, _ := msg["role"].(string) + if role != "assistant" { + continue + } + rc, hasRC := msg["reasoning_content"].(string) + if hasRC { + // rc 已有 string → 不覆盖(原有语义保留)。 + } else if r, ok := msg["reasoning"].(string); ok { + rc = r + msg["reasoning_content"] = rc + } else { + rc = "" + msg["reasoning_content"] = rc + } + // 镜像:reasoning 缺失/null/空串 → 归一化(非空 rc 优先,皆无补 " ")。 + if r, ok := msg["reasoning"].(string); ok && r != "" { + continue // 已非空 → 不覆盖 + } + if rc != "" { + msg["reasoning"] = rc + } else { + msg["reasoning"] = " " + } + } +} + +// injectThinking 按 DeepSeek 思维链开关规则改写请求体。非 deepseek 零改动。 +// +// 核心逻辑(对齐官方客户端): +// - 「开思考」必须 thinking.type=enabled + 有 effort 档位(Hermes #43 打回证据)。 +// - 显式 thinking.type 非空 → 客户端显式控制:enabled 缺 effort 时补默认档; +// disabled 尊重并删 reasoning_effort(snake/camel 双字段)。 +// - 无 thinking / type 空 / 已有 effort → 注入 enabled 并补默认档(已有 effort 不覆盖)。 +// +// defaultEffort 为该模型声明的默认档(来自 FetchModels 缓存 reasoning.defaultEffort); +// 空串时回退硬编码 defaultDeepSeekEffort(向后兼容)。 +func injectThinking(obj map[string]any, defaultEffort string) { + model, _ := obj["model"].(string) + if !isDeepSeekModel(model) { + return + } + th, ok := obj["thinking"].(map[string]any) + typ := "" + if ok { + typ, _ = th["type"].(string) + typ = strings.TrimSpace(typ) + } + // 显式控制分支:type 非空(enabled/disabled 均为明确意图)→ 不改 type。 + if typ != "" { + if strings.EqualFold(typ, "disabled") { + delete(obj, "reasoning_effort") + delete(obj, "reasoningEffort") + return // disabled:关思考且不带任何 effort(照抄客户端 case 行为) + } + ensureDeepSeekEffort(obj, defaultEffort) // 显式 enabled 缺 effort → 补默认档 + return + } + // 无 thinking(或 thinking 非法非对象值)或 thinking 对象 type 缺失/为空: + // 注入 enabled(客户端 case "deepseek" 行为)。有 reasoning_effort 也走此分支 + // (effort 保留给既有降级逻辑,开关照开)。 + if !ok { + obj["thinking"] = map[string]any{"type": "enabled"} + } else { + th["type"] = "enabled" + } + ensureDeepSeekEffort(obj, defaultEffort) +} + +// ensureDeepSeekEffort 缺 effort 档位时补默认档(snake 优先,camel 兜底)。 +// 已有任一 effort → 不覆盖(显式档位不做任何改写,降级交给 normalizeReasoningEffort)。 +// defaultEffort 空串 → 回退 defaultDeepSeekEffort(硬编码 "high")。 +func ensureDeepSeekEffort(obj map[string]any, defaultEffort string) { + _, hasSnake := obj["reasoning_effort"] + if hasSnake { + return + } + _, hasCamel := obj["reasoningEffort"] + if hasCamel { + return + } + if defaultEffort == "" { + defaultEffort = defaultDeepSeekEffort + } + obj["reasoning_effort"] = defaultEffort +} diff --git a/internal/upstream/thinking_test.go b/internal/upstream/thinking_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e2157df3addc166da2727921745b7331e9e91321 --- /dev/null +++ b/internal/upstream/thinking_test.go @@ -0,0 +1,305 @@ +package upstream + +import ( + "encoding/json" + "strings" + "testing" +) + +// getThinkingType 从输出 body 提取 thinking.type(缺字段返回空串 + 是否存在)。 +func getThinkingType(t *testing.T, out []byte) (typ string, present bool) { + t.Helper() + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v (out=%s)", err, out) + } + th, ok := m["thinking"].(map[string]any) + if !ok { + return "", false + } + s, ok := th["type"].(string) + if !ok { + return "", true + } + return s, true +} + +// TestInjectThinkingDeepSeekEnabled 开思考开关注入:deepseek 系模型请求体不带 +// thinking 时必须注入 {type:"enabled"},否则上游默认按不思考应答(思维链不显示)。 +func TestInjectThinkingDeepSeekEnabled(t *testing.T) { + cases := []struct { + name string + body string + wantTyp string + }{ + {"deepseek 无 thinking 注入 enabled", + `{"model":"deepseek-v4-flash","messages":[]}`, "enabled"}, + {"DeepSeek 大小写不敏感", + `{"model":"DeepSeek-v4.1-flash","messages":[]}`, "enabled"}, + {"DEEPSEEK 全大写不敏感", + `{"model":"DEEPSEEK-R1","messages":[]}`, "enabled"}, + {"deepseek 带 reasoning_effort 无 thinking 注入 enabled", + `{"model":"deepseek-v4-flash","reasoning_effort":"medium","messages":[]}`, "enabled"}, + {"deepseek thinking 对象 type 空 补 enabled", + `{"model":"deepseek-v4-flash","thinking":{},"messages":[]}`, "enabled"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, present := getThinkingType(t, out) + if !present { + t.Fatalf("thinking 字段缺失 (out=%s)", out) + } + if typ != c.wantTyp { + t.Errorf("thinking.type = %q want %q (out=%s)", typ, c.wantTyp, out) + } + }) + } +} + +// TestInjectThinkingDefaultEffort 打回修复主证据:无 effort 裸请求必须同时带 +// thinking.type=enabled 与默认档 reasoning_effort(否则上游 deepseek-v4-flash 不开思维链)。 +// 默认档 = 官方客户端兜底 "high",并带上 supportedEfforts 时经降级管线落到合法档。 +func TestInjectThinkingDefaultEffort(t *testing.T) { + // 裸请求无任何思考参数 → 注入 enabled + reasoning_effort="high"。 + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}]}`), + false, nil) + typ, present := getThinkingType(t, out) + if !present || typ != "enabled" { + t.Fatalf("thinking.type=%q present=%v want enabled (out=%s)", typ, present, out) + } + eff, ok := objFieldString(t, out, "reasoning_effort") + if !ok || eff != "high" { + t.Errorf("reasoning_effort=%q ok=%v want high(默认档)(out=%s)", eff, ok, out) + } + + // 模型只支持 low/high → 默认 high 经降级管线后仍是 high(合法档)。 + out = PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[]}`), + false, map[string][]string{"deepseek-v4-flash": {"low", "high"}}) + eff, _ = objFieldString(t, out, "reasoning_effort") + if eff != "high" { + t.Errorf("supportedEfforts=[low high] 下默认档=%q want high (out=%s)", eff, out) + } + + // 模型只支持 minimal/low → 默认 high 降级到 low(≤high 的最高支持档)。 + out = PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[]}`), + false, map[string][]string{"deepseek-v4-flash": {"minimal", "low"}}) + eff, _ = objFieldString(t, out, "reasoning_effort") + if eff != "low" { + t.Errorf("supportedEfforts=[minimal low] 下默认档降级=%q want low (out=%s)", eff, out) + } + + // 显式 enabled + 缺 effort → 同样补默认档(官方 configure thinking 行为)。 + out = PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"messages":[]}`), + false, nil) + if eff, _ = objFieldString(t, out, "reasoning_effort"); eff != "high" { + t.Errorf("显式 enabled 缺 effort 应补默认档, got %q (out=%s)", eff, out) + } +} + +// TestInjectThinkingEffortNotOverridden 已有显式 reasoning_effort(snake/camel) +// 一律不覆盖、不降级、不删除;降级由 normalizeReasoningEffort 单独负责。 +func TestInjectThinkingEffortNotOverridden(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"snake effort 原样保留", + `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"reasoning_effort":"medium","messages":[]}`}, + {"camel effort 原样保留", + `{"model":"deepseek-v4-flash","reasoningEffort":"low","messages":[]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, _ := getThinkingType(t, out) + if typ != "enabled" { + t.Fatalf("thinking.type=%q want enabled (out=%s)", typ, out) + } + // camel 分支:输入只有 camel,injectThinking 不得另加 snake 默认档。 + if strings.Contains(c.body, "reasoningEffort") { + if _, ok := objFieldString(t, out, "reasoning_effort"); ok { + t.Errorf("已有 reasoningEffort 却新增 reasoning_effort 默认档 (out=%s)", out) + } + } + }) + } + // 显式 effort + 无 thinking → 注入 enabled 但 effort 不覆盖。 + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","reasoning_effort":"medium","messages":[]}`), + false, nil) + if eff, _ := objFieldString(t, out, "reasoning_effort"); eff != "medium" { + t.Errorf("显式 effort 被改写 %q (out=%s)", eff, out) + } +} + +// TestInjectThinkingDisabledNoDefaultEffort disabled 保持既有语义: +// thinking.type=disabled 尊重关闭意图;reasoning_effort 删除;不得再补默认档。 +func TestInjectThinkingDisabledNoDefaultEffort(t *testing.T) { + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[]}`), + false, nil) + typ, present := getThinkingType(t, out) + if !present || typ != "disabled" { + t.Fatalf("thinking.type=%q present=%v want disabled (out=%s)", typ, present, out) + } + for _, k := range []string{"reasoning_effort", "reasoningEffort"} { + if _, ok := objFieldString(t, out, k); ok { + t.Errorf("%s 应被删除且不得补默认档(disabled 时)(out=%s)", k, out) + } + } +} + +// objFieldString 提取顶层字段 string 值。 +func objFieldString(t *testing.T, out []byte, key string) (string, bool) { + t.Helper() + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + s, ok := m[key].(string) + return s, ok +} + +// TestInjectThinkingDeepSeekExplicitControl 客户端显式控制时必须尊重: +// thinking.type 非空(enabled/disabled)都不得被覆盖。 +func TestInjectThinkingDeepSeekExplicitControl(t *testing.T) { + cases := []struct { + name string + body string + wantTyp string + wantEff string // 期望 reasoning_effort 值;"" 且 wantEffAbsent=true 表示应删除 + }{ + {"已有 enabled 不动", + `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"messages":[]}`, + "enabled", ""}, + {"已有 enabled 且带 reasoning_effort 保持(不删 effort)", + `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"reasoning_effort":"high","messages":[]}`, + "enabled", "high"}, + {"已有 disabled 保留(尊重关闭意图)", + `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"messages":[]}`, + "disabled", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, present := getThinkingType(t, out) + if !present { + t.Fatalf("thinking 字段缺失 (out=%s)", out) + } + if typ != c.wantTyp { + t.Errorf("thinking.type = %q want %q (out=%s)", typ, c.wantTyp, out) + } + if c.wantEff != "" { + if eff, _ := objFieldString(t, out, "reasoning_effort"); eff != c.wantEff { + t.Errorf("reasoning_effort = %q want %q (out=%s)", eff, c.wantEff, out) + } + } + }) + } +} + +// TestInjectThinkingDisabledDeletesEffort 显式 disabled 时 reasoning_effort 一并删除 +// (照抄官方客户端 case "deepseek" 行为:默认分支 delete reasoning_effort)。 +// snow/camel 双字段都删。 +func TestInjectThinkingDisabledDeletesEffort(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"disabled + snake effort 删除", + `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[]}`}, + {"disabled + camel effort 删除", + `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoningEffort":"high","messages":[]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, present := getThinkingType(t, out) + if !present || typ != "disabled" { + t.Fatalf("thinking.type = %q present=%v want disabled (out=%s)", typ, present, out) + } + for _, k := range []string{"reasoning_effort", "reasoningEffort"} { + if _, ok := objFieldString(t, out, k); ok { + t.Errorf("%s 应被删除(显式 disabled 时)(out=%s)", k, out) + } + } + }) + } +} + +// TestInjectThinkingSkipNonDeepSeek 非 deepseek 模型零改动:无 thinking 不得凭空添加, +// 已有 thinking 原样保留。 +func TestInjectThinkingSkipNonDeepSeek(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"glm 无 thinking 不注入", `{"model":"glm-5.2","messages":[]}`}, + {"glm 已有 thinking 保留", `{"model":"glm-5.2","thinking":{"type":"enabled"},"messages":[]}`}, + {"kimi 无 thinking 不注入", `{"model":"kimi-k2.5","messages":[]}`}, + {"qwen 系不注入", `{"model":"qwen2.5-coder-32b","messages":[]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // 记录输入是否已带 thinking 及值,输出必须逐字节语义不变。 + var in map[string]any + if err := json.Unmarshal([]byte(c.body), &in); err != nil { + t.Fatalf("unmarshal input: %v", err) + } + inTh, inHadThink := in["thinking"] + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal out: %v (out=%s)", err, out) + } + outTh, outHadThink := got["thinking"] + if inHadThink != outHadThink { + t.Errorf("thinking 出现性变化: in=%v(had=%v) out=%v(had=%v)", + inTh, inHadThink, outTh, outHadThink) + } + if inHadThink { + // 保留的 thinking 必须原值相等(JSON 语义级)。 + inJSON, _ := json.Marshal(inTh) + outJSON, _ := json.Marshal(outTh) + if string(inJSON) != string(outJSON) { + t.Errorf("thinking 被改动: in=%s out=%s", inJSON, outJSON) + } + } + // 非 deepseek 不得新增 thinking 相关字段;允许的既有新增字段: + // stream(强制流式)+ stream_options(D7 include_usage,CLI 流式必发)。 + allowedNew := map[string]bool{"stream": true, "stream_options": true} + if len(got) > len(in)+len(allowedNew) { + for k := range got { + if _, had := in[k]; !had && !allowedNew[k] { + t.Errorf("非 deepseek 新增字段 %q (out=%s)", k, out) + } + } + } + }) + } +} + +// TestInjectThinkingStringPreserved 注入不得破坏 model/messages 等既有字段。 +func TestInjectThinkingStringPreserved(t *testing.T) { + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0.7}`), + false, nil) + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["model"] != "deepseek-v4-flash" || m["temperature"] != 0.7 { + t.Errorf("既有字段被改动: %v", m) + } + if _, ok := m["messages"].([]any); !ok { + t.Errorf("messages 结构破坏: %v", m) + } + if !strings.Contains(string(out), `"stream":true`) { + t.Errorf("stream 未强制: %s", out) + } +} diff --git a/internal/upstream/tool_pairing.go b/internal/upstream/tool_pairing.go new file mode 100644 index 0000000000000000000000000000000000000000..d251c4427075dfbbd77a57a6437016ae8f1d89fd --- /dev/null +++ b/internal/upstream/tool_pairing.go @@ -0,0 +1,226 @@ +// tool_pairing.go 出站请求体的孤儿 tool_call↔tool 配对清理 + tool 结果块重排 +// (吸收参考仓库 sse.ts:91-123 resolveToolPairing 语义,适配网关的 OpenAI wire 消息形态)。 +// +// 背景:OpenAI 兼容协议要求带 tool_calls 的 assistant 消息,其每一个 tool_call id +// 都必须有对应的一条 role:tool 结果消息;反之 role:tool 消息也必须有对应的前置 +// tool_call。缺任一侧,上游都会以 HTTP 400 拒绝整个请求。 +// +// 工具执行失败时(参数非法、超时、工具不存在……)客户端会把 assistant 的 tool_calls +// 持久化进会话历史,却写不回结果消息。这条坏历史随后被每次请求原样重放——上游对之后 +// 每一条用户消息都返回 400,整条会话报废。网关是最后一道防线:发出请求前剔除无法配对 +// 的条目让会话自愈,宁可丢一轮工具上下文,也好过整条会话死亡。 +package upstream + +// repackToolResultBlocks 把插在 assistant.tool_calls 与其 tool 结果之间的非 tool 消息 +// 挪到整组之后,保证同一批 tool_call 的结果在 wire 上连续。 +// +// 背景:Codex 的 image_resize_notice 特性会把 作为一条 +// developer/system 消息插在 tool 输出后面。并行调用时它插在两份 tool 结果中间: +// +// assistant tool_calls=[c00 c01] +// tool c00 +// developer <- 插在中间 +// tool c01 +// +// OpenAI 兼容协议要求 tool 结果紧跟 assistant,中间插任何消息都算配对断裂,上游判 +// 11148(tool_call_sequence_broken)并顶死整条会话。这里只调顺序、不改内容: +// +// assistant tool_calls=[c00 c01] | tool c00 | X | tool c01 +// → assistant tool_calls=[c00 c01] | tool c00 | tool c01 | X +// +// 结果顺序保持不变(同批 tool_call 的原相对顺序 = 结果顺序),不引入新的顺序敏感 +// 问题。无插入消息时零改动零分配(返回原 slice)。 +func repackToolResultBlocks(messages []any) ([]any, bool) { + if len(messages) < 3 { + return messages, false + } + out := make([]any, 0, len(messages)) + changed := false + i := 0 + for i < len(messages) { + m, ok := messages[i].(map[string]any) + if !ok || m["role"] != "assistant" { + out = append(out, messages[i]) + i++ + continue + } + tcs, hasCalls := m["tool_calls"].([]any) + if !hasCalls || len(tcs) == 0 { + out = append(out, messages[i]) + i++ + continue + } + want := map[string]bool{} + for _, tci := range tcs { + if tc, ok := tci.(map[string]any); ok { + if id, _ := tc["id"].(string); id != "" { + want[id] = true + } + } + } + // 收集紧随其后(允许被其他消息打断)的同批 tool 结果,按原相对顺序。 + out = append(out, messages[i]) + i++ + var results []any + var between []any + sawNonTool := false + for i < len(messages) { + mm, ok := messages[i].(map[string]any) + if !ok { + break + } + role, _ := mm["role"].(string) + if role == "tool" { + id, _ := mm["tool_call_id"].(string) + if !want[id] { + break + } + results = append(results, messages[i]) + if sawNonTool { + changed = true + } + i++ + continue + } + if len(results) == 0 { + break // assistant 后没有结果:交由 cleanupOrphanToolCalls 处理 + } + // 下一组 assistant.tool_calls 是新的组头,绝不能当插入物吞掉:一旦被收进 + // between,它永远不再被外层循环当作组头处理,它自己那批结果也就永远得不 + // 到重排(真实会话 msg[181] 正是这样漏掉的)。必须 break 交还外层循环。 + if role == "assistant" { + if next, _ := mm["tool_calls"].([]any); len(next) > 0 { + break + } + } + // 同批结果尚未收齐时,中间消息视为插入物,暂存待后移。 + between = append(between, messages[i]) + sawNonTool = true + i++ + } + out = append(out, results...) + out = append(out, between...) + } + if !changed { + return messages, false + } + return out, true +} + +// cleanupOrphanToolCalls 剔除无法配对的 tool_call 与 tool 结果(所有模型,独立于 +// deepseek-only 的 sanitize 开关)。语义对齐参考仓库 resolveToolPairing: +// +// - 收集全线 role:tool 消息的 tool_call_id(结果集)与 assistant.tool_calls[].id(调用集); +// - 一批 assistant.tool_calls 按 keepCalls 对称裁剪:只留有结果配对的调用(部分保留 +// 不会留下无结果的 tool_call),过滤后为空才删掉整个 tool_calls 键; +// - role:tool 只在对应 tool_call 被保留时才保留,否则删除整条消息; +// - 无任何工具流量 → 原 slice 原样返回,changed=false(零分配零改动)。 +// +// 这是「让请求通过」的安全网:只要存在合法配对就整段保留这些字段,绝不吞掉正确配对。 +// 返回清理后的 slice(无改动时等于原 slice,勿依赖其是否新分配)及是否发生删除。 +func cleanupOrphanToolCalls(messages []any) ([]any, bool) { + if len(messages) == 0 { + return messages, false + } + callIDs := map[string]bool{} + resultIDs := map[string]bool{} + hasTraffic := false + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + switch msg["role"] { + case "tool": + if id, ok := msg["tool_call_id"].(string); ok && id != "" { + resultIDs[id] = true + hasTraffic = true + } + case "assistant": + if tcs, ok := msg["tool_calls"].([]any); ok { + for _, tci := range tcs { + tc, ok := tci.(map[string]any) + if !ok { + continue + } + if id, ok := tc["id"].(string); ok && id != "" { + callIDs[id] = true + hasTraffic = true + } + } + } + } + } + if !hasTraffic { + return messages, false + } + // keepCalls:调用 id 是否双侧齐全(调用存在且结果存在)。重复 id 与乱序均按集合处理。 + keepCalls := map[string]bool{} + for id := range callIDs { + if resultIDs[id] { + keepCalls[id] = true + } + } + changed := false + // 1) assistant.tool_calls:按 keepCalls 对称裁剪——只留有结果的调用,过滤后为空则删键。 + // + // 历史实现是「批内每个 id 都齐才整批保留,否则删掉整个 tool_calls 键」。那会留下 + // 无主结果:批 [c1,c2] 只回了 c1 时,调用侧整批被删,而 tool{c1} 仍按 id 命中 + // keepCalls 得以保留 —— 出站载荷于是变成「无 tool_calls 的 assistant + 孤儿 tool」, + // 上游判 11148(tool calls and tool results do not match)并顶死整条会话。 + // 现在两侧共用同一份 keepCalls 按 id 对称裁剪(与 2) 的删除侧同口径), + // 任何输入都不会再产生半截配对。 + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + if role, _ := msg["role"].(string); role != "assistant" { + continue + } + tcs, ok := msg["tool_calls"].([]any) + if !ok || len(tcs) == 0 { + continue + } + keptCalls := make([]any, 0, len(tcs)) + for _, tci := range tcs { + tc, ok := tci.(map[string]any) + if !ok { + continue + } + if id, _ := tc["id"].(string); keepCalls[id] { + keptCalls = append(keptCalls, tc) + } + } + if len(keptCalls) == len(tcs) { + continue // 整批齐全:零改动 + } + changed = true + if len(keptCalls) == 0 { + delete(msg, "tool_calls") + continue + } + msg["tool_calls"] = keptCalls + } + // 2) role:tool 结果:只有对应 tool_call 被保留才保留;孤儿结果整条删除。 + kept := make([]any, 0, len(messages)) + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + kept = append(kept, m) + continue + } + if role, _ := msg["role"].(string); role == "tool" { + id, _ := msg["tool_call_id"].(string) + if !keepCalls[id] { + changed = true + continue + } + } + kept = append(kept, m) + } + if !changed { + return messages, false + } + return kept, true +} diff --git a/internal/upstream/transport.go b/internal/upstream/transport.go new file mode 100644 index 0000000000000000000000000000000000000000..ac5920b03cee5d08090c8bc7dfdf1cf87910e4f2 --- /dev/null +++ b/internal/upstream/transport.go @@ -0,0 +1,116 @@ +// transport.go 出站 Transport 构造的单一事实来源(连接层加固,吸收 kongjianguan +// 4 连击实测经验的前三件,见 .claude/reports/fork-scan-absorb.md T-2): +// 真正禁 h2 / TLS 握手超时 / 短 keepalive 探测,全参数集中定义可测试可调整。 +// 第四件 DisableKeepAlives 按报告 trade-off 不吸收(每请求 TLS 握手开销与连接 +// 复用意图相反),分析见 .claude/reports/transport-hardening.md。 +package upstream + +import ( + "crypto/tls" + "net" + "net/http" + "time" +) + +// 连接层参数集中定义(与 server/backoff.go 同风格:一处定义,测试可回读断言)。 +const ( + // dialTimeout TCP 连接建立上限。半死连接的第一道闸:连不上就快速失败 + // 轮转换号,不再干等系统 TCP 重传窗口(kongjianguan 实测半开 TCP 单次 + // TTFB 卡 936s——默认 Dialer 无超时上限)。 + dialTimeout = 10 * time.Second + // dialKeepAlive TCP keepalive 探测周期。默认 Dialer 2h 才发首个探测—— + // NAT 黑洞里 2h 足够连接半死且被复用。15s 周期让死连接在 15~30s 内被 + // 内核掐掉(RST/ETIMEDOUT),复用侧立即感知而非卡到重传窗口。 + dialKeepAlive = 15 * time.Second + // tlsHandshakeTimeout TLS 握手上限。现役此前完全缺失——握手挂起时无任何 + // 层兜底(ResponseHeaderTimeout 只在请求写完后才计时),只能干等到 + // HTTP.Client.Timeout(120s)。 + tlsHandshakeTimeout = 10 * time.Second + // idleConnTimeout 空闲连接池保留时长。从 90s 收到 30s:WAF 风暴后上游 + // NGI 常态性掐闲置连接,90s 池里的连接多半已死(kongjianguan 同款取值); + // 复用侧仍有 15s keepalive 兜底识别。 + idleConnTimeout = 30 * time.Second + // responseHeaderTimeout 聊天 SSE 首字节前(响应头)硬上限。从 120s 收到 + // 60s:kongjianguan 笔记实录成功请求 TTFB 曾到 16s(慢模型冷启动), + // 60s ≈ 3.75× 观测最坏健康首包,留足慢冷启动余量;同时把半死连接场景的 + // 单请求卡死从 2 分钟压到 1 分钟(MaxRotate 默认 3 次的最坏轮转从 6 分钟 + // 压到 3 分钟)。不取 kongjianguan 的 20s:其 20s 是 DisableKeepAlives+ + // timedConn 20s 写超时组合的取值,我们保留连接复用,须按自身慢冷启动 + // 观测留余量。语义核对(任务书设计纪律):ResponseHeaderTimeout 只计响应 + // 头到达前的时长,头到达后 SSE 长流不受影响(流中空闲由 IdleTimeout 监控, + // 见 idle.go),不误杀长流——transport_test.go 有显式回归。 + // + // 注意:本常量只是 newTransport 的构造默认,main.go 会按 config + // upstream.header_timeout_seconds 无条件覆盖。因此生产生效值 = config + // 解析值(未配置时 normalize 回落 timeout_seconds,默认 120),本 60s 仅作 + // 「Config 未接线/测试裸用」时的安全网——与 config.example.json 的取值 + // 对齐避免三处口径漂移(transport 60 / config 回落 120 / example 60)。 + responseHeaderTimeout = 60 * time.Second +) + +// maxIdleConns / maxIdleConnsPerHost 连接池容量(既有值,一并集中定义)。 +const ( + maxIdleConns = 100 + maxIdleConnsPerHost = 20 +) + +// newDialer 构造出站拨号器(DialContext 的 Timeout/KeepAlive 参数集中于此, +// 供测试回读断言)。 +func newDialer() *net.Dialer { + return &net.Dialer{ + Timeout: dialTimeout, + KeepAlive: dialKeepAlive, + } +} + +// newTransport 构造共享出站 Transport(HTTP 与 ChatHTTP 同一实例,连接池不重复)。 +// 分两层防半死连接: +// - TLS 层:空 TLSNextProto 真正禁 h2(kongjianguan 二次修正的实证:ForceAttemptHTTP2=false +// 只对自定义 Dial 生效,默认 TLS 经 ALPN 仍协商出 h2,半死 h2 流复用表现为 +// "http2: timeout awaiting response headers"——唯一正确写法是置空映射,让 ALPN +// 完成后无 h2 协议可用,连接退回 HTTP/1.1)。 +// - TCP 层:DialContext 10s 建连上限 + 15s keepalive 探测,半开连接在建立期 +// 和复用期都能被快速识别(见 dialTimeout/dialKeepAlive 注释)。 +func newTransport() *http.Transport { + dialer := newDialer() + return &http.Transport{ + DialContext: dialer.DialContext, + // 空 TLSNextProto(非 nil)真正禁 h2:见函数注释。必须 make 而非 nil—— + // nil 表示「让标准库注入默认 h2 映射」(kongjianguan 实测:设 + // ForceAttemptHTTP2=false 后日志仍报 h2 timeout,正是这个陷阱)。 + TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + TLSHandshakeTimeout: tlsHandshakeTimeout, + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + IdleConnTimeout: idleConnTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, + } +} + +// closeIdler 实现该接口的 RoundTripper 支持清空空闲连接池(*http.Transport、 +// http2.Transport 等均满足;测试注入的自定义 RoundTripper 可选择性实现)。 +type closeIdler interface { + CloseIdleConnections() +} + +// roundTripCloseIdle 在传输层请求失败后清掉 rt 所属 Transport 的空闲连接池 +// (kongjianguan 第 4 件:失败连接可能仍留在空闲池里,等 IdleConnTimeout 才 +// 过期,下一个请求会继续捡到它)。 +// +// 挂载点(任务书「评估挂载点:错误分类处理处」的结论):错误分类(Classify) +// 只见业务信封——传输层失败根本没有 body 可分类(见 doJSON/ChatStreamContext +// 对 read body 失败的处理:不进 Classify、不罚号)。这类失败的正确处理正是 +// 连接层的池清理,故挂在与 Do 并列的传输层出口(ChatStreamContext 的 Do 错误 +// 分支),而非 applyErrorPolicy。 +// +// 关闭是 best-effort:rt 为 nil 或未实现 closeIdler(如测试注入的 rtFunc)时 +// 静默跳过。CloseIdleConnections 只关空闲连接,不影响在途请求;瞬时代价是 +// 下个请求多一次 TCP+TLS 握手,与半死连接被复用卡 60s 的风险完全不成比例。 +func roundTripCloseIdle(rt http.RoundTripper) { + if rt == nil { + return + } + if ci, ok := rt.(closeIdler); ok { + ci.CloseIdleConnections() + } +} diff --git a/internal/upstream/travel.go b/internal/upstream/travel.go new file mode 100644 index 0000000000000000000000000000000000000000..44fe98cff0f4d665a5e0865cd67e011948084ee2 --- /dev/null +++ b/internal/upstream/travel.go @@ -0,0 +1,166 @@ +// travel.go growth 域「猫猫旅行」接口:状态查询 / 派出 / 领奖 / 领养 / 协议。 +// 全部走 chatBase(copilot.tencent.com,不带 /v2 前缀)+ BillingHeaders,信封同 doJSON。 +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// growth 域路径(实测)。 +const ( + travelStatusPath = "/activity/growth/buddy/travel/status" + travelDepartPath = "/activity/growth/buddy/travel/depart" + travelClaimPath = "/activity/growth/buddy/travel/claim" + buddyInfoPath = "/activity/growth/buddy/info" + buddyFirstPath = "/activity/growth/buddy/first" + buddyAgreementPath = "/activity/growth/buddy/agreement" + streakPath = "/activity/growth/streak" +) + +// buddyTaskIncompleteMarker 领养门槛未达标的业务错误关键词(HTTP 400 时出现)。 +const buddyTaskIncompleteMarker = "first_buddy task not completed yet" + +// Buddy 账号当前猫档案;nil(data.buddy 为 null)表示无猫。 +type Buddy struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// TravelState 猫猫旅行状态。 +type TravelState struct { + State string `json:"state"` // idle / traveling / arrived + DailyLimitReached bool `json:"daily_limit_reached"` // 今日已派出过(自然日 00:00 CST 重置) + RecordID int64 `json:"record_id"` // 在途/到站记录 id,claim 必带 + RewardCredit int64 `json:"reward_credit"` // 到站可领奖励积分 +} + +// growthJSON 发 growth 域请求并解信封;body 为 nil 时不带请求体。 +// 错误语义与 doJSON 一致:HTTP 非 2xx / 业务 code != 0 → *Error。 +func (c *Client) growthJSON(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.chatBase(a)+path, rdr) + if err != nil { + return nil, err + } + c.BillingHeaders(req, a) + return c.doJSON(req) +} + +// TravelStatus 查询猫猫旅行状态。 +func (c *Client) TravelStatus(a *auth.Auth) (*TravelState, error) { + data, err := c.growthJSON(a, http.MethodGet, travelStatusPath, nil) + if err != nil { + return nil, err + } + var st TravelState + if err := json.Unmarshal(data, &st); err != nil { + return nil, err + } + return &st, nil +} + +// TravelDepart 派出猫旅行;locationID 实测 1~4(收益/时长区间相同)。 +func (c *Client) TravelDepart(a *auth.Auth, locationID int) error { + _, err := c.growthJSON(a, http.MethodPost, travelDepartPath, map[string]any{"location_id": locationID}) + return err +} + +// TravelClaim 领取到站奖励,返回 reward_credit。 +func (c *Client) TravelClaim(a *auth.Auth, recordID int64) (int64, error) { + data, err := c.growthJSON(a, http.MethodPost, travelClaimPath, map[string]any{"record_id": recordID}) + if err != nil { + return 0, err + } + var resp struct { + RewardCredit int64 `json:"reward_credit"` + } + if len(data) > 0 { + // 奖励字段缺失不视为失败:调用方按 0 记日志即可。 + _ = json.Unmarshal(data, &resp) + } + return resp.RewardCredit, nil +} + +// BuddyInfo 查询当前猫档案;返回 (nil, nil) 表示无猫(data.buddy 为 null)。 +func (c *Client) BuddyInfo(a *auth.Auth) (*Buddy, error) { + data, err := c.growthJSON(a, http.MethodGet, buddyInfoPath, nil) + if err != nil { + return nil, err + } + var resp struct { + Buddy json.RawMessage `json:"buddy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + // null / 缺字段 / 空对象都按无猫处理。 + trimmed := strings.TrimSpace(string(resp.Buddy)) + if trimmed == "" || trimmed == "null" { + return nil, nil + } + var b Buddy + if err := json.Unmarshal(resp.Buddy, &b); err != nil { + return nil, err + } + return &b, nil +} + +// BuddyFirst 领养第一只猫。无猫且已过 conversation 门槛时送 300 分。 +// 门槛未达标返回 HTTP 400(见 IsBuddyTaskIncomplete),属预期行为,调用方静默跳过。 +func (c *Client) BuddyFirst(a *auth.Auth) error { + _, err := c.growthJSON(a, http.MethodPost, buddyFirstPath, map[string]any{}) + return err +} + +// BuddyAgreement 同意协议(幂等,重复调用无副作用)。 +func (c *Client) BuddyAgreement(a *auth.Auth) error { + _, err := c.growthJSON(a, http.MethodPost, buddyAgreementPath, map[string]any{"agree": true}) + return err +} + +// GrowthStreak 查询连登天数(只读 oracle)。响应 `data.streak.days`(probe_active.py +// 实测口径:`(s.get("data", {}).get("streak", {}) or {}).get("days")`)。 +// GET 失败(HTTP 非 2xx / 业务 code != 0)返回 *Error;缺 streak/days 字段返回 0 +// (days==0 即活跃自检的「上报 200 但静默丢弃」告警信号)。 +func (c *Client) GrowthStreak(a *auth.Auth) (int, error) { + data, err := c.growthJSON(a, http.MethodGet, streakPath, nil) + if err != nil { + return 0, err + } + var resp struct { + Streak struct { + Days int `json:"days"` + } `json:"streak"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, err + } + return resp.Streak.Days, nil +} + +// IsBuddyTaskIncomplete 判定「领养门槛未达标」:HTTP 400 + first_buddy 关键词。 +// 该错误当日不应重试(避免对上游重试轰炸)。 +func IsBuddyTaskIncomplete(err error) bool { + if err == nil { + return false + } + var ue *Error + if !errors.As(err, &ue) || ue.Status != http.StatusBadRequest { + return false + } + return strings.Contains(strings.ToLower(ue.Msg), buddyTaskIncompleteMarker) +} diff --git a/internal/upstream/travel_test.go b/internal/upstream/travel_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f57620b334767140e7eb9ad4cba387e16df6b56b --- /dev/null +++ b/internal/upstream/travel_test.go @@ -0,0 +1,227 @@ +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// travelPath 断言请求打到 growth 域的正确路径(BASE 走 chatBase,无 /v2 前缀)。 +func travelPath(r *http.Request, want string) error { + if r.URL.Path != want { + return errors.New("wrong path: " + r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer at" { + return errors.New("missing Authorization") + } + if r.Header.Get("X-User-Id") != "u1" { + return errors.New("missing X-User-Id") + } + return nil +} + +func TestTravelStatusParsesFields(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodGet { + return nil, errors.New("want GET") + } + if err := travelPath(r, "/activity/growth/buddy/travel/status"); err != nil { + return nil, err + } + return jsonResp(200, `{"code":0,"msg":"ok","data":{"state":"arrived","daily_limit_reached":true,"record_id":42,"reward_credit":7}}`), nil + }) + st, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("travel status: %v", err) + } + if st.State != "arrived" || !st.DailyLimitReached || st.RecordID != 42 || st.RewardCredit != 7 { + t.Errorf("state=%+v", st) + } +} + +// TestTravelStatusBusinessError 上游 400 且 body 仍是 {code,msg,data} 信封时, +// 应被 doJSON 归一为 *Error(带 HTTP 状态码),而不是解析失败。 +func TestTravelStatusBusinessError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(400, `{"code":400,"msg":"no active buddy","data":null}`), nil + }) + _, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + var ue *Error + if !errors.As(err, &ue) { + t.Fatalf("want *Error, got %T %v", err, err) + } + if ue.Status != 400 || ue.Kind != ErrClient { + t.Errorf("status=%d kind=%v", ue.Status, ue.Kind) + } +} + +func TestTravelDepartSendsLocationID(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/travel/depart"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{}}`), nil + }) + if err := c.TravelDepart(&auth.Auth{AccessToken: "at", UID: "u1"}, 4); err != nil { + t.Fatalf("depart: %v", err) + } + var body map[string]any + if err := json.Unmarshal(got, &body); err != nil { + t.Fatalf("depart body: %v (%s)", err, got) + } + if n, _ := body["location_id"].(float64); int(n) != 4 { + t.Errorf("location_id=%v want 4 (body=%s)", body["location_id"], got) + } +} + +func TestTravelClaimReturnsReward(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/travel/claim"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"reward_credit":9}}`), nil + }) + reward, err := c.TravelClaim(&auth.Auth{AccessToken: "at", UID: "u1"}, 42) + if err != nil { + t.Fatalf("claim: %v", err) + } + if reward != 9 { + t.Errorf("reward=%d want 9", reward) + } + if !bytes.Contains(got, []byte(`"record_id":42`)) { + t.Errorf("claim body missing record_id: %s", got) + } +} + +// TestBuddyInfoNullMeansNoBuddy data.buddy 为 null → 返回 nil 表示无猫。 +func TestBuddyInfoNullMeansNoBuddy(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodGet { + return nil, errors.New("want GET") + } + if err := travelPath(r, "/activity/growth/buddy/info"); err != nil { + return nil, err + } + return jsonResp(200, `{"code":0,"msg":"ok","data":{"buddy":null}}`), nil + }) + b, err := c.BuddyInfo(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("buddy info: %v", err) + } + if b != nil { + t.Errorf("buddy=%+v want nil (无猫)", b) + } +} + +func TestBuddyInfoPresent(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"buddy":{"id":7,"name":"档案喵 R"}}}`), nil + }) + b, err := c.BuddyInfo(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil || b == nil { + t.Fatalf("buddy=%+v err=%v", b, err) + } + if b.Name != "档案喵 R" || b.ID != 7 { + t.Errorf("buddy=%+v", b) + } +} + +func TestBuddyAgreementIdempotent(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/agreement"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"agreed":true}}`), nil + }) + if err := c.BuddyAgreement(&auth.Auth{AccessToken: "at", UID: "u1"}); err != nil { + t.Fatalf("agreement: %v", err) + } + if !bytes.Contains(got, []byte(`"agree":true`)) { + t.Errorf("agreement body=%s", got) + } +} + +func TestBuddyFirstPath(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/first"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"buddy":{"id":1}}}`), nil + }) + if err := c.BuddyFirst(&auth.Auth{AccessToken: "at", UID: "u1"}); err != nil { + t.Fatalf("first: %v", err) + } + if string(bytes.TrimSpace(got)) != "{}" { + t.Errorf("first body=%q want {}", got) + } +} + +// TestIsBuddyTaskIncomplete conversation 门槛未达标:HTTP 400 + first_buddy 关键词。 +func TestIsBuddyTaskIncomplete(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(400, `{"code":400,"msg":"first_buddy task not completed yet"}`), nil + }) + err := c.BuddyFirst(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err == nil { + t.Fatal("want error") + } + if !IsBuddyTaskIncomplete(err) { + t.Errorf("err=%v should be classified as 门槛未达标", err) + } +} + +func TestIsBuddyTaskIncompleteNegative(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"普通错误", errors.New("boom"), false}, + {"400 其他业务错误", &Error{Kind: ErrClient, Status: 400, Msg: "no active buddy"}, false}, + {"500 含关键词也不认", &Error{Kind: ErrServer, Status: 500, Msg: "first_buddy task not completed yet"}, false}, + {"401 会话失效", &Error{Kind: ErrSessionDead, Status: 401, Msg: "Offline user session not found"}, false}, + } + for _, c := range cases { + if got := IsBuddyTaskIncomplete(c.err); got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} + +// TestTravel401ClassifiedSessionDead 401 交由调用方跳过本轮(巡检不强刷 token)。 +func TestTravel401ClassifiedSessionDead(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(401, `{"code":12153,"msg":"Offline user session not found"}`), nil + }) + _, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + var ue *Error + if !errors.As(err, &ue) || ue.Kind != ErrSessionDead { + t.Fatalf("err=%v want ErrSessionDead", err) + } +} diff --git a/internal/upstream/trial.go b/internal/upstream/trial.go new file mode 100644 index 0000000000000000000000000000000000000000..8959a9204e8e5ddcb621fa12d49c932a160c99a5 --- /dev/null +++ b/internal/upstream/trial.go @@ -0,0 +1,49 @@ +// trial.go global 专属「一次性 trial 加油包」领取:POST {billingBase}/billing/ide/trial。 +// 仅 global 账号适用(CN 无此端点);幂等码 14051 = 已领过(视为正常,非错误)。 +// 这是 global 唯一天然的积分增益动作(无签到/任务中心,见 PLAN D4)。 +package upstream + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// trialPath global trial 加油包端点(Maquer/workbuddy-checkin 实测)。 +const trialPath = "/billing/ide/trial" + +// trialAlreadyMarkers 幂等码 14051「已领取过」的两种指纹: +// - "code=14051":doJSON 对 HTTP 200 + 业务 code 非 0 时拼出的 Msg 格式; +// - `"code":14051`:HTTP ≥400 时 doJSON 把原始 JSON body 直接塞进 Msg。 +var trialAlreadyMarkers = []string{"code=14051", `"code":14051`} + +// ClaimTrial 领取一次性 trial 加油包。仅 global 账号可调(CN 无此端点): +// 非 global → 直接报错(工具层还会再拦一道,这里是客户端侧防线)。 +// 返回 claimed:true=成功新领;false=已领过(幂等,不算失败)。 +func (c *Client) ClaimTrial(a *auth.Auth) (claimed bool, err error) { + if a == nil || a.Realm() != "global" { + return false, fmt.Errorf("claim trial: only global accounts") + } + _, err = c.billingJSON(a, http.MethodPost, trialPath, nil) + if err != nil { + var ue *Error + if errors.As(err, &ue) && trialAlreadyErr(ue.Msg) { + return false, nil // 已领过:幂等成功,非错误 + } + return false, err + } + return true, nil +} + +// trialAlreadyErr 判定错误 Msg 是否携带幂等码 14051(已领取过)。 +func trialAlreadyErr(msg string) bool { + for _, m := range trialAlreadyMarkers { + if strings.Contains(msg, m) { + return true + } + } + return false +} diff --git a/internal/upstream/truncation.go b/internal/upstream/truncation.go new file mode 100644 index 0000000000000000000000000000000000000000..26268d4751eee27091e9988c7fcf416905702210 --- /dev/null +++ b/internal/upstream/truncation.go @@ -0,0 +1,47 @@ +// truncation.go 工具调用的残缺参数检测(吸收参考仓库 sse.ts:158-167 +// isTruncatedArguments 语义)。 +// +// 背景:SSE 流被截断(连接中断 / finish_reason==length)时,工具调用的 arguments +// 会只剩半截 JSON。此时网关若把脏参数原样交给客户端,客户端解析会报非法 JSON 并卡死会话。 +// 参考仓库的处置是丢弃残缺调用并触发重试(报告 max-tokens),而非补成 {} 伪造合法外观。 +// +// 关键区分:只把「非空但无法解析」视为截断。空串是合法的无参数工具;能解析但类型不对 +// (标量 / 数组)属于模型输出错误,交给客户端 schema 校验回传即可,不在此判定。 +package upstream + +import ( + "encoding/json" + "strings" +) + +// isTruncatedArguments 判定工具参数字符串是否因分片丢失而残缺(区别于「该工具本就无参数」)。 +// - 空串 / 纯空白 → false(合法无参工具); +// - 非空但 JSON 解析失败 → true(截断); +// - 能解析(含 null/标量/数组等任何合法 JSON)→ false。 +func isTruncatedArguments(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return false + } + var v any + return json.Unmarshal([]byte(trimmed), &v) != nil +} + +// dropTruncatedToolCalls 过滤出 arguments 完整的 tool_call(返回新 slice)。 +// 只依据 isTruncatedArguments 判定,不改动任何保留的调用(正例零改动)。 +func dropTruncatedToolCalls(calls []map[string]any) []map[string]any { + kept := make([]map[string]any, 0, len(calls)) + for _, call := range calls { + fn, _ := call["function"].(map[string]any) + if fn == nil { + kept = append(kept, call) + continue + } + args, _ := fn["arguments"].(string) + if isTruncatedArguments(args) { + continue + } + kept = append(kept, call) + } + return kept +} diff --git a/internal/upstream/usage.go b/internal/upstream/usage.go new file mode 100644 index 0000000000000000000000000000000000000000..74a757fc4fa20fc9faef29dce8a4a0a99829a2e7 --- /dev/null +++ b/internal/upstream/usage.go @@ -0,0 +1,149 @@ +package upstream + +import "encoding/json" + +// normalizeUsageCacheAliases keeps cache-hit aliases consistent before the +// response leaves the gateway. Some WorkBuddy responses carry the real hit in +// prompt_tokens_details.cached_tokens while also emitting +// cache_read_input_tokens: 0 and cached_tokens: 0 compatibility aliases. +// Strict downstream parsers may prefer those zero aliases and lose the hit. +func normalizeUsageCacheAliases(usage map[string]any) map[string]any { + best, ok := bestUsageCacheHitTokens(usage) + if !ok || best <= 0 { + return usage + } + + out := cloneUsageMap(usage) + out["cache_read_input_tokens"] = best + out["cached_tokens"] = best + out["prompt_cache_hit_tokens"] = best + + promptDetails := cloneUsageDetails(out, "prompt_tokens_details") + promptDetails["cached_tokens"] = best + out["prompt_tokens_details"] = promptDetails + + // Responses API consumers use this nested form. Preserve it when the + // upstream already supplies it, but do not invent it for Chat-only clients. + if _, exists := out["input_tokens_details"]; exists { + inputDetails := cloneUsageDetails(out, "input_tokens_details") + inputDetails["cached_tokens"] = best + out["input_tokens_details"] = inputDetails + } + + return out +} + +func bestUsageCacheHitTokens(usage map[string]any) (float64, bool) { + paths := []struct { + section string + key string + }{ + {"prompt_tokens_details", "cached_tokens"}, + {"", "prompt_cache_hit_tokens"}, + {"", "cache_read_input_tokens"}, + {"", "cached_tokens"}, + {"input_tokens_details", "cached_tokens"}, + } + + for _, path := range paths { + var value any + if path.section == "" { + value = usage[path.key] + } else if details, ok := usage[path.section].(map[string]any); ok { + value = details[path.key] + } + if tokens, ok := positiveUsageNumber(value); ok { + return tokens, true + } + } + return 0, false +} + +func positiveUsageNumber(value any) (float64, bool) { + switch n := value.(type) { + case float64: + return n, n > 0 + case float32: + value := float64(n) + return value, value > 0 + case int: + return float64(n), n > 0 + case int64: + return float64(n), n > 0 + case int32: + return float64(n), n > 0 + case uint: + return float64(n), n > 0 + case uint64: + return float64(n), n > 0 + case uint32: + return float64(n), n > 0 + default: + return 0, false + } +} + +func cloneUsageMap(usage map[string]any) map[string]any { + out := make(map[string]any, len(usage)) + for key, value := range usage { + out[key] = value + } + return out +} + +func cloneUsageDetails(usage map[string]any, key string) map[string]any { + out := make(map[string]any) + details, _ := usage[key].(map[string]any) + for detailKey, value := range details { + out[detailKey] = value + } + return out +} + +// UsageCacheHitTokens 返回 usage 里的缓存命中 token 数(多别名取最优,口径与 +// 回写给客户端的 normalizeUsageCacheAliases 一致)。供网关统计层(usage 桶 / +// reqlog)观测命中率使用;usage 缺失该维度时 ok=false。 +func UsageCacheHitTokens(usage map[string]any) (float64, bool) { + if usage == nil { + return 0, false + } + return bestUsageCacheHitTokens(usage) +} + +// UsageCacheMissTokens 返回 usage 里的缓存未命中 token 数:优先读上游显式的 +// prompt_cache_miss_tokens,缺失时按 prompt_tokens - 命中 推导(推导值为负时 +// 视为不可信,返回 ok=false)。 +func UsageCacheMissTokens(usage map[string]any) (float64, bool) { + if usage == nil { + return 0, false + } + if miss, ok := usageNumber(usage, "prompt_cache_miss_tokens"); ok { + return miss, true + } + prompt, okP := usageNumber(usage, "prompt_tokens") + hit, okH := UsageCacheHitTokens(usage) + if okP && okH && prompt-hit >= 0 { + return prompt - hit, true + } + return 0, false +} + +// usageNumber 从 usage 顶层取数值字段(JSON 数字可能是 float64 / json.Number 形态)。 +func usageNumber(usage map[string]any, key string) (float64, bool) { + v, ok := usage[key] + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + case json.Number: + f, err := n.Float64() + return f, err == nil + } + return 0, false +} diff --git a/internal/upstream/useragent_test.go b/internal/upstream/useragent_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e583b1e2a970bf1aa98c78ec72b859a063a0ebe6 --- /dev/null +++ b/internal/upstream/useragent_test.go @@ -0,0 +1,336 @@ +package upstream + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// uaCaptureTransport 记录出站请求的 User-Agent。 +type uaCaptureTransport struct { + ua *string +} + +func (t uaCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) { + *t.ua = r.Header.Get("User-Agent") + return jsonResp(200, `{"code":0}`), nil +} + +// TestUserAgentDefaultEmptyKeepsClientUA 默认(UserAgent/client_name 空)行为: +// chat/refresh 路径 UA=默认 WorkBuddy 三段式;billing 路径(report/travel/balance) +// 单段 WorkBuddy/(对齐官方 banner 白名单头组,默认伪造桌面端指纹); +// 显式 client_name="SaaS" 才还原"billing 不设 UA"的旧行为。 +func TestUserAgentDefaultEmptyKeepsClientUA(t *testing.T) { + for _, tc := range []struct { + name string + call func(c *Client) error + wantUA string + }{ + { + name: "chat", + call: func(c *Client) error { + rc, status, _, err := c.ChatStream(&auth.Auth{AccessToken: "at", UID: "u1"}, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) + if status != 200 { + t.Fatalf("chat status=%d", status) + } + if rc != nil { + rc.Close() + } + return err + }, + wantUA: defaultUAString, + }, + { + name: "billing_report", + call: func(c *Client) error { + return c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") + }, + wantUA: "WorkBuddy/5.5.4", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var ua string + c := &Client{ + HTTP: &http.Client{Transport: uaCaptureTransport{ua: &ua}}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: &ua}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + } + if err := tc.call(c); err != nil { + t.Fatalf("call: %v", err) + } + if ua != tc.wantUA { + t.Errorf("UA = %q want %q", ua, tc.wantUA) + } + }) + } +} + +// TestUserAgentOverrideAllOutbound 显式设置后 chat/billing/refresh 全路径覆盖。 +// 用 env 别名直接验证 fields 传输到 headers 的行为。 +func TestUserAgentOverrideAllOutbound(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1", RefreshToken: "rt"} + ua := "WorkBuddy/9.9.9" + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != ua { + t.Errorf("UA = %q want %q (path=%s)", got, ua, r.URL.Path) + } + return jsonResp(200, `{"code":0}`), nil + })}, + ChatHTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != ua { + t.Errorf("Chat UA = %q want %q", got, ua) + } + return jsonResp(200, `{"code":0}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: ua, + } + // chat + if rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}); status != 200 || err != nil { + t.Errorf("chat: status=%d err=%v", status, err) + } else if rc != nil { + rc.Close() + } + // refresh(RefreshHeaders→CommonHeaders) + c.HTTP.Transport = rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != ua { + t.Errorf("Refresh UA = %q want %q", got, ua) + } + return jsonResp(200, `{"code":0,"data":{"accessToken":"nat","refreshToken":"nrt"}}`), nil + }) + if err := c.RefreshToken(a); err != nil { + t.Errorf("refresh: %v", err) + } +} + +// TestUserAgentOverrideBilling 余额/签到类 billing 请求同样覆盖。 +func TestUserAgentOverrideBilling(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != "CustomAgent/1" { + t.Errorf("Billing UA = %q want CustomAgent/1", got) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: "CustomAgent/1", + } + if _, _, err := c.UserResource(a); err != nil { + t.Errorf("userResource: %v", err) + } +} + +// TestFetchModelsUsesConfiguredUA FetchModels 手工 Set UA 也走覆盖。 +func TestFetchModelsUsesConfiguredUA(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): + if got := r.Header.Get("User-Agent"); got != "FetchAgent/2" { + t.Errorf("personal/models UA = %q want FetchAgent/2", got) + } + case strings.HasSuffix(r.URL.Path, "/v3/config"): + if got := r.Header.Get("User-Agent"); got != codeBuddyIDEUA { + t.Errorf("v3/config UA = %q want %s", got, codeBuddyIDEUA) + } + return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil + default: + t.Errorf("path=%s", r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"models":[{"id":"glm-5.2","name":"GLM","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":[]},"disabled":false}],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: "FetchAgent/2", + } + if _, err := c.FetchModels(a); err != nil { + t.Errorf("fetchModels: %v", err) + } +} + +// --- A 段:UA 对齐官方 WorkBuddy 三段式 --- + +const ( + defaultUAString = "WorkBuddy/5.5.4 WorkBuddy/5.5.4 CLI/2.137.1" + explicitString = "MyCustomAgent/3.1" + clientVerUAString = "WorkBuddy/6.0.0 WorkBuddy/6.0.0 CLI/2.137.1" + billingUAWorkBuddy = "WorkBuddy/5.5.4" + billingUACustomVer = "WorkBuddy/6.0.0" + billingUAAgentString = "BillingAgent/1" +) + +// TestUserAgentDefaultWorkBuddyShape 默认(无任何配置)聊天/刷新出站 UA = +// 官方 WorkBuddy 三段式,旧值 `CLI/2.63.2 CodeBuddy/2.63.2` 已被对齐替换。 +func TestUserAgentDefaultWorkBuddyShape(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + } + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}) + if status != 200 || err != nil { + t.Fatalf("chat: status=%d err=%v", status, err) + } + if ua := c.chatLastUA(); ua != defaultUAString { + t.Errorf("chat UA = %q want %q", ua, defaultUAString) + } + if rc != nil { + rc.Close() + } +} + +// chatLastUA 从最近一次聊天请求捕获 UA(当前测试 Client 的 ChatHTTP transport 记录)。 +func (c *Client) chatLastUA() string { + if t, ok := c.ChatHTTP.Transport.(uaCaptureTransport); ok && t.ua != nil { + return *t.ua + } + return "" +} + +// TestUserAgentExplicitOverride config user_agent 非空时以用户显式值为准(兼容旧覆盖逻辑)。 +func TestUserAgentExplicitOverride(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1", RefreshToken: "rt"} + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != explicitString { + t.Errorf("UA = %q want %q (path=%s)", got, explicitString, r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"accessToken":"nat","refreshToken":"nrt"}}`), nil + })}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: explicitString, + } + if rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}); status != 200 || err != nil { + t.Errorf("chat: status=%d err=%v", status, err) + } else if rc != nil { + rc.Close() + } + if got := c.chatLastUA(); got != explicitString { + t.Errorf("chat UA = %q want %q", got, explicitString) + } + if err := c.RefreshToken(a); err != nil { + t.Errorf("refresh: %v", err) + } +} + +// TestUserAgentClientVersionOverride config client_version 生效:UA 的 WorkBuddy 段跟随 +// 且成对相同(platform 段 = applicationName 段),CLI 段保持默认。 +func TestUserAgentClientVersionOverride(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientVersion: "6.0.0", + } + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}) + if status != 200 || err != nil { + t.Fatalf("chat: status=%d err=%v", status, err) + } + if got := c.chatLastUA(); got != clientVerUAString { + t.Errorf("UA = %q want %q", got, clientVerUAString) + } + if rc != nil { + rc.Close() + } +} + +// TestBillingUA_WhenClientNameSet billing/checkin 路径:client_name 非空时用单段 +// `WorkBuddy/`(不带 CLI 段,对齐官方 banner/check-in 显式头组)。 +func TestBillingUA_WhenClientNameSet(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + // 默认 client_version → WorkBuddy/5.5.4 + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != billingUAWorkBuddy { + t.Errorf("billing UA = %q want %q (path=%s)", got, billingUAWorkBuddy, r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientName: "WorkBuddy", + } + if _, _, err := c.UserResource(a); err != nil { + t.Errorf("userResource: %v", err) + } + // 自定义 client_version → WorkBuddy/6.0.0 + c2 := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != billingUACustomVer { + t.Errorf("billing UA = %q want %q", got, billingUACustomVer) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientName: "WorkBuddy", + ClientVersion: "6.0.0", + } + if _, _, err := c2.UserResource(a); err != nil { + t.Errorf("userResource v2: %v", err) + } + // 显式 user_agent 仍优先于 billingUA + c3 := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != billingUAAgentString { + t.Errorf("billing UA = %q want %q", got, billingUAAgentString) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientName: "WorkBuddy", + UserAgent: billingUAAgentString, + } + if _, _, err := c3.UserResource(a); err != nil { + t.Errorf("userResource v3: %v", err) + } +} + +// TestBillingUA_WhenClientNameEmpty client_name 空 = 默认对齐官方桌面端: +// billing UA 单段 WorkBuddy/;显式 client_name="SaaS" 才不设 UA。 +func TestBillingUA_WhenClientNameEmpty(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + var ua string + const fullResp = `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}` + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + ua = r.Header.Get("User-Agent") + return jsonResp(200, fullResp), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientVersion: "6.0.0", + } + if _, _, err := c.UserResource(a); err != nil { + t.Errorf("userResource: %v", err) + } + if ua != "WorkBuddy/6.0.0" { + t.Errorf("billing UA = %q want WorkBuddy/6.0.0 (default desktop fingerprint)", ua) + } + if got := c.billingUA(); got != "WorkBuddy/6.0.0" { + t.Errorf("billingUA() = %q want WorkBuddy/6.0.0", got) + } + // 显式 SaaS 还原旧行为(不设 UA)。 + c.ClientName = "SaaS" + if got := c.billingUA(); got != "" { + t.Errorf("billingUA() SaaS = %q want empty", got) + } +} + +var _ = io.Discard diff --git a/internal/usage/usage.go b/internal/usage/usage.go new file mode 100644 index 0000000000000000000000000000000000000000..007af4582170625c2ac53bc926203baeba570a0c --- /dev/null +++ b/internal/usage/usage.go @@ -0,0 +1,795 @@ +// Package usage 记录并聚合逐请求 token 用量,供面板「用量」视图展示。 +// +// 与 internal/pool 的 TokenUsage 的区别: +// - pool 的 TokenUsage 是**每账号一个累计计数器**,只保留总量与「最近一次」, +// 没有时间维度,也无法按模型/时间下钻; +// - 本包按 (时间片, realm, uid, model, rate) 分桶累计,因此可以出「今天各模型各用了多少」 +// 「这一小时 prompt 涨得多快」这类问题,且能长期保留。 +// +// 保留策略(分片粒度自动降级,总量因此有界): +// - 近 hourlyKeep 小时内:小时桶(细粒度,看尖峰) +// - 更早:折叠为日桶,**永久保留**(看长期趋势) +// +// 落盘:data/usage.json,原子替换 + 防抖刷新(默认 30s),重启不丢。 +// 桶数上界 ≈ 账号数 × 模型数 × (hourlyKeep + 已过天数),实测单桶约 90 字节。 +package usage + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// hourlyKeep 小时桶的保留时长;超出后折叠为日桶。 +const hourlyKeep = 90 * 24 * time.Hour + +// flushInterval 防抖落盘间隔。 +const flushInterval = 30 * time.Second + +// maxBuckets 桶数硬上限。超过时立即触发一次折叠,避免异常流量把内存/文件撑爆。 +const maxBuckets = 400_000 + +// hourLayout / dayLayout 分片键的时间格式(本地时区,与用户直觉一致)。 +const ( + hourLayout = "2006-01-02T15" + dayLayout = "2006-01-02" +) + +// fileVersion 是 usage.json 的当前格式版本。版本 2 增加积分观测字段,版本 3 +// 增加模型生效倍率分区,版本 4 增加前缀缓存命中/未命中累计; +// 旧版本缺失字段按零值加载,旧数据不会丢弃。 +const fileVersion = 4 + +// bucket 一个 (时间片, realm, uid, model, rate) 的累计量。 +// JSON 字段名刻意取短,因为桶数量会随时间增长。 +type bucket struct { + Scope string `json:"s"` // "h:2006-01-02T15" 或 "d:2006-01-02" + Realm string `json:"r"` // cn / global + UID string `json:"u"` // 账号 uid + Model string `json:"m"` // 上游裸模型名 + Rate string `json:"x,omitempty"` // 请求时生效积分倍率(规范化数值;旧桶为空) + Req int64 `json:"q"` // 请求数(含失败) + Err int64 `json:"e"` // 失败数 + PT int64 `json:"p"` // prompt tokens + CT int64 `json:"c"` // completion tokens + TT int64 `json:"t"` // total tokens(上游给什么用什么的合计) + LatMs int64 `json:"l"` // 延迟累计(ms) + LatN int64 `json:"ln"` // 延迟样本数 + TPS float64 `json:"v"` // 吐字速率累计 + TPSN int64 `json:"vn"` // 速率样本数 + CR float64 `json:"cr,omitempty"` // usage.credit 累计(仅明确存在的观测) + CRN int64 `json:"cn,omitempty"` // usage.credit 样本数(区分缺字段与真实 0) + CRT int64 `json:"ct,omitempty"` // 同时具备 credit 与 token 的 Token 合计 + CH int64 `json:"ch,omitempty"` // 前缀缓存命中 token 累计(上游回该维度才累计) + CM int64 `json:"cm,omitempty"` // 前缀缓存未命中 token 累计 +} + +// file 落盘结构。 +type file struct { + Version int `json:"version"` + Saved string `json:"saved"` + Buckets []bucket `json:"buckets"` +} + +// Recorder 并发安全的用量记录器。 +type Recorder struct { + mu sync.Mutex + path string + buckets map[string]*bucket // key: scope|realm|uid|model|rate + dirty bool + started time.Time + + stopOnce sync.Once + stop chan struct{} + done chan struct{} +} + +// New 创建记录器。path 为空时禁用落盘(纯内存,测试用)。 +func New(path string) *Recorder { + r := &Recorder{ + path: path, + buckets: make(map[string]*bucket), + started: time.Now(), + stop: make(chan struct{}), + done: make(chan struct{}), + } + if path != "" { + if err := r.load(); err != nil { + log.Printf("[usage] 读取 %s 失败(从零开始): %v", path, err) + } + } + return r +} + +// Start 启动后台防抖落盘与折叠。Stop 前一直运行。 +func (r *Recorder) Start() { + go func() { + defer close(r.done) + t := time.NewTicker(flushInterval) + defer t.Stop() + for { + select { + case <-r.stop: + r.flush(true) + return + case <-t.C: + r.mu.Lock() + n := len(r.buckets) + r.mu.Unlock() + if n > maxBuckets { + r.Rollup(time.Now()) + } + r.flush(false) + } + } + }() +} + +// Stop 停止后台循环并做最后一次落盘。 +func (r *Recorder) Stop() { + r.stopOnce.Do(func() { close(r.stop) }) + <-r.done +} + +// Delta 一次请求尝试的用量增量(与 pool.TokenUsageDelta 同形,避免包间依赖)。 +type Delta struct { + PromptTokens int64 + HasPromptTokens bool + CompletionTokens int64 + HasCompletion bool + TotalTokens int64 + HasTotal bool + Credit float64 + HasCredit bool + ModelRate string + // CacheHitTokens / CacheMissTokens 前缀缓存命中/未命中观测(issue #92)。 + HasCacheTokens bool + CacheHitTokens int64 + CacheMissTokens int64 + LatencyMs int64 + HasLatency bool + TokensPerSecond float64 + HasTPS bool +} + +// Add 记录一次请求尝试。 +// +// ok=false 表示该次尝试失败(传输错误 / 上游 >=400 / 解析失败)。失败尝试通常 +// 没有 usage,但**仍要计入请求数与失败数**——重试放大正是靠这一列才看得出来。 +func (r *Recorder) Add(now time.Time, realm, uid, model string, d Delta, ok bool) { + if r == nil { + return + } + if realm == "" { + realm = "cn" + } + if model == "" { + model = "(unknown)" + } + scope := "h:" + now.Format(hourLayout) + key := scope + "|" + realm + "|" + uid + "|" + model + "|" + d.ModelRate + + r.mu.Lock() + defer r.mu.Unlock() + + b := r.buckets[key] + if b == nil { + b = &bucket{Scope: scope, Realm: realm, UID: uid, Model: model, Rate: d.ModelRate} + r.buckets[key] = b + } + b.Req++ + if !ok { + b.Err++ + } + if d.HasPromptTokens { + b.PT += d.PromptTokens + } + if d.HasCompletion { + b.CT += d.CompletionTokens + } + if d.HasTotal { + b.TT += d.TotalTokens + } else if d.HasPromptTokens || d.HasCompletion { + // 上游没给 total:用 pt+ct 兜底,保证总量口径连续。 + b.TT += d.PromptTokens + d.CompletionTokens + } + if d.HasCredit { + b.CR += d.Credit + b.CRN++ + // 比例只使用同一次请求同时具备 credit 与 token 的样本,避免把 + // 仅 token 的旧记录或仅 credit 的观测混进分母。 + if d.HasTotal { + b.CRT += d.TotalTokens + } else if d.HasPromptTokens || d.HasCompletion { + b.CRT += d.PromptTokens + d.CompletionTokens + } + } + if d.HasCacheTokens { + b.CH += d.CacheHitTokens + b.CM += d.CacheMissTokens + } + if d.HasLatency { + b.LatMs += d.LatencyMs + b.LatN++ + } + if d.HasTPS { + b.TPS += d.TokensPerSecond + b.TPSN++ + } + r.dirty = true +} + +// Rollup 把超出 hourlyKeep 的小时桶折叠为日桶(按本地日历日)。 +// 幂等:同一小时反复折叠不会重复计数(先累加再删源桶)。 +func (r *Recorder) Rollup(now time.Time) { + if r == nil { + return + } + cutoff := now.Add(-hourlyKeep) + + r.mu.Lock() + defer r.mu.Unlock() + + type move struct{ from, to string } + var moves []move + for k, b := range r.buckets { + if !strings.HasPrefix(b.Scope, "h:") { + continue + } + ts, err := time.ParseInLocation(hourLayout, strings.TrimPrefix(b.Scope, "h:"), time.Local) + if err != nil || !ts.Before(cutoff) { + continue + } + day := "d:" + ts.Format(dayLayout) + moves = append(moves, move{from: k, to: day + "|" + b.Realm + "|" + b.UID + "|" + b.Model + "|" + b.Rate}) + } + for _, m := range moves { + src := r.buckets[m.from] + if src == nil { + continue + } + dst := r.buckets[m.to] + if dst == nil { + cp := *src + cp.Scope = strings.SplitN(m.to, "|", 2)[0] + dst = &cp + r.buckets[m.to] = dst + } else { + dst.Req += src.Req + dst.Err += src.Err + dst.PT += src.PT + dst.CT += src.CT + dst.TT += src.TT + dst.LatMs += src.LatMs + dst.LatN += src.LatN + dst.TPS += src.TPS + dst.TPSN += src.TPSN + dst.CR += src.CR + dst.CRN += src.CRN + dst.CRT += src.CRT + dst.CH += src.CH + dst.CM += src.CM + } + delete(r.buckets, m.from) + } + if len(moves) > 0 { + r.dirty = true + log.Printf("[usage] 折叠 %d 个小时桶为日桶(保留 %v 细粒度)", len(moves), hourlyKeep) + } +} + +// ---------------------------------------------------------------- 持久化 ---- + +func (r *Recorder) load() error { + raw, err := os.ReadFile(r.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var f file + if err := json.Unmarshal(raw, &f); err != nil { + return err + } + for i := range f.Buckets { + b := f.Buckets[i] + r.buckets[b.Scope+"|"+b.Realm+"|"+b.UID+"|"+b.Model+"|"+b.Rate] = &b + } + log.Printf("[usage] 已恢复 %d 个用量桶(%s)", len(r.buckets), r.path) + return nil +} + +func (r *Recorder) flush(force bool) { + if r == nil || r.path == "" { + return + } + r.mu.Lock() + if !r.dirty && !force { + r.mu.Unlock() + return + } + snap := file{Version: fileVersion, Saved: time.Now().Format(time.RFC3339), Buckets: make([]bucket, 0, len(r.buckets))} + for _, b := range r.buckets { + snap.Buckets = append(snap.Buckets, *b) + } + r.dirty = false + r.mu.Unlock() + + raw, err := json.Marshal(snap) + if err != nil { + log.Printf("[usage] 序列化失败: %v", err) + return + } + if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { + log.Printf("[usage] 建目录失败: %v", err) + return + } + tmp := r.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + log.Printf("[usage] 写临时文件失败: %v", err) + return + } + if err := os.Rename(tmp, r.path); err != nil { + log.Printf("[usage] 原子替换失败: %v", err) + } +} + +// Save 立即落盘(面板「刷新」或关闭前调用)。 +func (r *Recorder) Save() { r.flush(true) } + +// ---------------------------------------------------------------- 聚合 ---- + +// Agg 一组累计量。 +type Agg struct { + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTok int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Credits float64 `json:"credits"` + CreditSamples int64 `json:"credit_samples"` + CreditTokens int64 `json:"credit_tokens"` + CreditsPer1MTokens float64 `json:"credits_per_1m_tokens"` + CacheHitTokens int64 `json:"cache_hit_tokens,omitempty"` + CacheMissTokens int64 `json:"cache_miss_tokens,omitempty"` + CacheHitRate float64 `json:"cache_hit_rate,omitempty"` + AvgLatencyMs float64 `json:"avg_latency_ms"` + AvgTPS float64 `json:"avg_tokens_per_second"` +} + +// aggAcc 是聚合过程中的累加器:Agg 只放已算好的结果,均值需要样本数才能 +// 正确加权(不能对每桶的均值再取平均),所以样本数留在这里。 +type aggAcc struct { + Agg + latSum int64 + latSamples int64 + tpsSum float64 + tpsSamples int64 +} + +func (g *aggAcc) add(b *bucket) { + g.Requests += b.Req + g.Errors += b.Err + g.PromptTokens += b.PT + g.CompletionTok += b.CT + g.TotalTokens += b.TT + g.Credits += b.CR + g.CreditSamples += b.CRN + g.CreditTokens += b.CRT + g.CacheHitTokens += b.CH + g.CacheMissTokens += b.CM + g.latSum += b.LatMs + g.latSamples += b.LatN + g.tpsSum += b.TPS + g.tpsSamples += b.TPSN +} + +func (g *aggAcc) finish() Agg { + a := g.Agg + if g.latSamples > 0 { + a.AvgLatencyMs = float64(g.latSum) / float64(g.latSamples) + } + if g.tpsSamples > 0 { + a.AvgTPS = g.tpsSum / float64(g.tpsSamples) + } + if g.CreditTokens > 0 { + a.CreditsPer1MTokens = g.Credits / float64(g.CreditTokens) * 1_000_000 + } + if total := g.CacheHitTokens + g.CacheMissTokens; total > 0 { + a.CacheHitRate = float64(g.CacheHitTokens) / float64(total) * 100 + } + return a +} + +// KeyedAgg 按某个维度聚合的一行。 +type KeyedAgg struct { + Key string `json:"key"` + Realm string `json:"realm,omitempty"` + Extra string `json:"extra,omitempty"` // 账号行放昵称 + Agg +} + +// Point 时序上的一个点。 +type Point struct { + T string `json:"t"` + Scope string `json:"scope"` // "hour" | "day" + Agg +} + +// CreditAgg 积分扣除统计的一行。Key 在账号维度是 UID,在模型维度是裸模型名; +// Rate 仅模型维度使用;比例分母只统计与 credit 同时存在的 Token 样本。 +type CreditAgg struct { + Key string `json:"key"` + Realm string `json:"realm,omitempty"` + Nickname string `json:"nickname,omitempty"` + Rate string `json:"rate,omitempty"` + Requests int64 `json:"requests"` + Credits float64 `json:"credits"` + CreditSamples int64 `json:"credit_samples"` + CreditTokens int64 `json:"credit_tokens"` + CreditsPer1MTokens float64 `json:"credits_per_1m_tokens"` + CacheHitTokens int64 `json:"cache_hit_tokens,omitempty"` + CacheMissTokens int64 `json:"cache_miss_tokens,omitempty"` + CacheHitRate float64 `json:"cache_hit_rate,omitempty"` +} + +type creditAcc struct { + CreditAgg +} + +func (a *creditAcc) add(b *bucket) { + a.Requests += b.Req + a.Credits += b.CR + a.CreditSamples += b.CRN + a.CreditTokens += b.CRT + a.CacheHitTokens += b.CH + a.CacheMissTokens += b.CM +} + +func (a *creditAcc) finish() CreditAgg { + out := a.CreditAgg + if a.CreditTokens > 0 { + out.CreditsPer1MTokens = a.Credits / float64(a.CreditTokens) * 1_000_000 + } + if total := a.CacheHitTokens + a.CacheMissTokens; total > 0 { + out.CacheHitRate = float64(a.CacheHitTokens) / float64(total) * 100 + } + return out +} + +// Snapshot 面板一次拉取的全部用量视图数据。 +type Snapshot struct { + Totals Agg `json:"totals"` + ByRealm []KeyedAgg `json:"by_realm"` + ByAccount []KeyedAgg `json:"by_account"` + ByModel []KeyedAgg `json:"by_model"` + Series []Point `json:"series"` + CreditByAccount []CreditAgg `json:"credit_by_account"` + CreditByModel []CreditAgg `json:"credit_by_model"` + Buckets int `json:"buckets"` + FileBytes int64 `json:"file_bytes"` + Since string `json:"since,omitempty"` + // WindowFrom/WindowTo 本次实际生效的统计区间(本地时间,RFC3339),供面板 + // 回显口径——「自定义」区间下用户必须能确认服务端到底按哪段算的。 + // 空串 = 该侧不设界(全部历史 / 到今天为止)。 + WindowFrom string `json:"window_from,omitempty"` + WindowTo string `json:"window_to,omitempty"` + Generated string `json:"generated"` +} + +// Window 用量统计窗口。三种口径按优先级解析(见 bounds): +// - From/To 任一非零 → 显式区间 [From, To](To 零值 = 不设上界) +// - 否则 Hours>0 → 滚动窗口:当前整点往回 Hours-1 小时 +// - 否则 → 全部历史 +// +// 为什么显式区间用「桶起点落在 [From, To] 内」判定而不是求交集:小时桶的粒度 +// 就是一小时,用户选到 14:00 时把 14:00 这一小时的桶算进来符合直觉;同时这也 +// 让 Hours 口径与历史行为逐位一致(原实现就是 ts.Before(from) 即跳过)。 +type Window struct { + Hours int + From time.Time + To time.Time +} + +// bounds 解析出实际生效的 [from, to];零值表示该侧不设界。 +func (w Window) bounds() (time.Time, time.Time) { + if !w.From.IsZero() || !w.To.IsZero() { + return w.From, w.To + } + if w.Hours <= 0 { + return time.Time{}, time.Time{} + } + h := w.Hours + if h > 24*60 { + h = 24 * 60 + } + return time.Now().Truncate(time.Hour).Add(-time.Duration(h-1) * time.Hour), time.Time{} +} + +// bucketTime 把桶 scope 解析成本地时间;脏 scope 返回 false(不进任何口径)。 +func bucketTime(scope string) (time.Time, bool) { + if strings.HasPrefix(scope, "h:") { + ts, err := time.ParseInLocation(hourLayout, strings.TrimPrefix(scope, "h:"), time.Local) + return ts, err == nil + } + ts, err := time.ParseInLocation(dayLayout, strings.TrimPrefix(scope, "d:"), time.Local) + return ts, err == nil +} + +// Snapshot 聚合**所选窗口内**的桶,产出面板一次拉取的全部用量视图数据。 +// +// hours>0:窗口 = [当前整点-(hours-1)小时, now],卡片汇总/按域/按账号/按模型/ +// 时序**全部**按同一窗口口径统计——切窗口时所有数字随之变化(曾长期是"卡片为 +// 全部历史累计、hours 只改时序分片"的口径,界面上被读成"筛选没生效",已废弃)。 +// 小时桶按整点入窗;日桶(Rollup 折叠出的长期数据)按日起点入窗,故小时窗口 +// 天然不含更早的日桶。 +// hours<=0:全部历史(含已折叠日桶),供「全部历史」选项看长期趋势。 +// +// nicks 是 uid→昵称映射,仅用于展示。 +func (r *Recorder) Snapshot(hours int, nicks map[string]string) Snapshot { + return r.SnapshotWithRates(hours, nicks, nil) +} + +// SnapshotWithRates 与 Snapshot 相同,但允许为缺少历史倍率的旧桶提供当前 +// 模型倍率回填。currentRate 返回空串时该行按“未知倍率”聚合,不伪造价格。 +func (r *Recorder) SnapshotWithRates(hours int, nicks map[string]string, currentRate func(realm, model string) string) Snapshot { + return r.SnapshotWindow(Window{Hours: hours}, nicks, currentRate) +} + +// SnapshotWindow 聚合**所选窗口内**的桶,产出面板一次拉取的全部用量视图数据。 +// +// 窗口语义见 Window:滚动窗口(Hours)/ 显式区间(From-To)/ 全部历史。卡片汇总、 +// 按域、按账号、按模型、时序**全部**按同一窗口口径统计——切窗口时所有数字随之变化 +// (曾长期是"卡片为全部历史累计、hours 只改时序分片"的口径,界面上被读成"筛选没 +// 生效",已废弃)。小时桶按整点入窗;日桶(Rollup 折叠出的长期数据)按日起点入窗, +// 故小时窗口天然不含更早的日桶。 +func (r *Recorder) SnapshotWindow(w Window, nicks map[string]string, currentRate func(realm, model string) string) Snapshot { + if r == nil { + return Snapshot{Generated: time.Now().Format(time.RFC3339)} + } + from, to := w.bounds() + explicit := !w.From.IsZero() || !w.To.IsZero() + windowed := !from.IsZero() || !to.IsZero() + + r.mu.Lock() + bs := make([]bucket, 0, len(r.buckets)) + for _, b := range r.buckets { + bs = append(bs, *b) + } + r.mu.Unlock() + + var total aggAcc + realmAgg := map[string]*aggAcc{} + acctAgg := map[string]*aggAcc{} + acctRealm := map[string]string{} + modelAgg := map[string]*aggAcc{} + hourSeries := map[string]*aggAcc{} + daySeries := map[string]*aggAcc{} + creditAcctAgg := map[string]*creditAcc{} + creditModelAgg := map[string]*creditAcc{} + rateCache := map[string]string{} + + // 数据起点(全库最早分片):不受窗口影响,表示"记录自何时开始"。scope 字典序 + // 即时间序(同前缀内同格式排序;"d:" 恒早于 "h:"——日桶只来自 90 天前的小时折叠)。 + since := "" + matched := 0 + for i := range bs { + b := &bs[i] + if b.Scope < since || since == "" { + since = b.Scope + } + if windowed { + ts, ok := bucketTime(b.Scope) + // 解析失败的脏桶不进窗口聚合(也不该出现在任何口径里)。 + if !ok { + continue + } + if !from.IsZero() && ts.Before(from) { + continue + } + if !to.IsZero() && ts.After(to) { + continue + } + } + matched++ + total.add(b) + + if realmAgg[b.Realm] == nil { + realmAgg[b.Realm] = &aggAcc{} + } + realmAgg[b.Realm].add(b) + + if acctAgg[b.UID] == nil { + acctAgg[b.UID] = &aggAcc{} + } + acctAgg[b.UID].add(b) + // 一个账号只属于一个 realm,这里记下来供前端展示「域」列; + // keyed() 的 Realm 字段默认是空的(它按 key 分组,不知道 realm)。 + if acctRealm[b.UID] == "" { + acctRealm[b.UID] = b.Realm + } + + if modelAgg[b.Model] == nil { + modelAgg[b.Model] = &aggAcc{} + } + modelAgg[b.Model].add(b) + + if strings.HasPrefix(b.Scope, "h:") { + scope := strings.TrimPrefix(b.Scope, "h:") + if hourSeries[scope] == nil { + hourSeries[scope] = &aggAcc{} + } + hourSeries[scope].add(b) + } else { + scope := strings.TrimPrefix(b.Scope, "d:") + if daySeries[scope] == nil { + daySeries[scope] = &aggAcc{} + } + daySeries[scope].add(b) + } + if b.CRN > 0 { + ca := creditAcctAgg[b.UID] + if ca == nil { + ca = &creditAcc{CreditAgg: CreditAgg{ + Key: b.UID, + Realm: b.Realm, + Nickname: nicks[b.UID], + }} + creditAcctAgg[b.UID] = ca + } + ca.add(b) + + model := canonicalUsageModel(b.Model) + rate := b.Rate + if rate == "" && currentRate != nil { + cacheKey := b.Realm + "\x00" + model + if cached, ok := rateCache[cacheKey]; ok { + rate = cached + } else { + rate = currentRate(b.Realm, model) + rateCache[cacheKey] = rate + } + } + modelKey := model + "\x00" + rate + cm := creditModelAgg[modelKey] + if cm == nil { + cm = &creditAcc{CreditAgg: CreditAgg{Key: model, Rate: rate}} + creditModelAgg[modelKey] = cm + } + cm.add(b) + } + } + + snap := Snapshot{ + Totals: total.finish(), + ByRealm: keyed(realmAgg, func(k string) (string, string) { + return k, "" + }), + ByAccount: keyed(acctAgg, func(k string) (string, string) { + return k, nicks[k] + }), + ByModel: keyed(modelAgg, func(k string) (string, string) { return k, "" }), + CreditByAccount: creditKeyed(creditAcctAgg), + CreditByModel: creditKeyed(creditModelAgg), + Buckets: matched, + Generated: time.Now().Format(time.RFC3339), + } + for i := range snap.ByAccount { + snap.ByAccount[i].Realm = acctRealm[snap.ByAccount[i].Key] + } + + // 日点(升序)+ 小时点(升序)拼成一条连续时序。 + dayKeys := make([]string, 0, len(daySeries)) + for k := range daySeries { + dayKeys = append(dayKeys, k) + } + sort.Strings(dayKeys) + for _, k := range dayKeys { + snap.Series = append(snap.Series, Point{T: k, Scope: "day", Agg: daySeries[k].finish()}) + } + hourKeys := make([]string, 0, len(hourSeries)) + for k := range hourSeries { + hourKeys = append(hourKeys, k) + } + sort.Strings(hourKeys) + for _, k := range hourKeys { + snap.Series = append(snap.Series, Point{T: k, Scope: "hour", Agg: hourSeries[k].finish()}) + } + + if r.path != "" { + if fi, err := os.Stat(r.path); err == nil { + snap.FileBytes = fi.Size() + } + } + // since 去掉 scope 前缀("h:2026-09-16T13" → "2026-09-16T13")给前端展示; + // 无任何桶时保持空(无数据不伪造起点)。 + snap.Since = strings.TrimPrefix(strings.TrimPrefix(since, "h:"), "d:") + // 回显实际生效的区间:**仅显式区间口径**。滚动窗口由 hours 表达(前端自己 + // 知道选的是哪个预设),全部历史没有区间——两者回显都会变成噪音。 + if explicit { + if !w.From.IsZero() { + snap.WindowFrom = w.From.Format(time.RFC3339) + } + if !w.To.IsZero() { + snap.WindowTo = w.To.Format(time.RFC3339) + } + } + return snap +} + +func canonicalUsageModel(model string) string { + model = strings.TrimSpace(model) + for _, prefix := range []string{"cn:", "global:"} { + if strings.HasPrefix(model, prefix) { + model = strings.TrimPrefix(model, prefix) + break + } + } + if model == "" { + return "(unknown)" + } + return model +} + +func creditKeyed(m map[string]*creditAcc) []CreditAgg { + out := make([]CreditAgg, 0, len(m)) + for _, v := range m { + out = append(out, v.finish()) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Credits != out[j].Credits { + return out[i].Credits > out[j].Credits + } + if out[i].CreditTokens != out[j].CreditTokens { + return out[i].CreditTokens > out[j].CreditTokens + } + if out[i].Key != out[j].Key { + return out[i].Key < out[j].Key + } + return out[i].Rate < out[j].Rate + }) + return out +} + +func keyed(m map[string]*aggAcc, label func(string) (string, string)) []KeyedAgg { + out := make([]KeyedAgg, 0, len(m)) + for k, v := range m { + key, extra := label(k) + out = append(out, KeyedAgg{Key: key, Extra: extra, Agg: v.finish()}) + } + // 按总量降序;同量按 key 升序,保证输出稳定(前端 diff 不抖)。 + sort.Slice(out, func(i, j int) bool { + if out[i].TotalTokens != out[j].TotalTokens { + return out[i].TotalTokens > out[j].TotalTokens + } + if out[i].Requests != out[j].Requests { + return out[i].Requests > out[j].Requests + } + return out[i].Key < out[j].Key + }) + return out +} + +// Describe 返回一行人类可读的占用摘要(启动日志用)。 +func (r *Recorder) Describe() string { + if r == nil { + return "disabled" + } + r.mu.Lock() + n := len(r.buckets) + r.mu.Unlock() + var sz int64 + if r.path != "" { + if fi, err := os.Stat(r.path); err == nil { + sz = fi.Size() + } + } + return fmt.Sprintf("%d buckets, file %d bytes", n, sz) +} diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bc872b5bf2a0e77e4d9dce42f8a8de93ff9652b3 --- /dev/null +++ b/internal/usage/usage_test.go @@ -0,0 +1,355 @@ +package usage + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// 成功/失败尝试计数、total 的 pt+ct 兜底口径、按域/账号聚合。 +func TestAddAndTotals(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now, "cn", "uid1", "glm-5.2", Delta{PromptTokens: 100, HasPromptTokens: true, CompletionTokens: 50, HasCompletion: true, Credit: 1.5, HasCredit: true, ModelRate: "0.05", LatencyMs: 200, HasLatency: true}, true) + // 失败尝试:无 usage → 只计请求数与失败数,token 不加。 + r.Add(now, "global", "uid1", "claude-4.6", Delta{}, false) + // 上游没给 total 时用 pt+ct 兜底,保证总量口径连续。 + r.Add(now, "cn", "uid1", "glm-5.2", Delta{PromptTokens: 10, HasPromptTokens: true, CompletionTokens: 5, HasCompletion: true}, true) + + s := r.Snapshot(24, nil) + if s.Totals.Requests != 3 || s.Totals.Errors != 1 { + t.Fatalf("requests/errors = %d/%d, want 3/1", s.Totals.Requests, s.Totals.Errors) + } + if s.Totals.PromptTokens != 110 || s.Totals.CompletionTok != 55 { + t.Fatalf("pt/ct = %d/%d, want 110/55", s.Totals.PromptTokens, s.Totals.CompletionTok) + } + if s.Totals.TotalTokens != 165 { + t.Fatalf("tt = %d, want 165(无 total 时按 pt+ct 兜底)", s.Totals.TotalTokens) + } + if s.Totals.Credits != 1.5 || s.Totals.CreditSamples != 1 || s.Totals.CreditTokens != 150 || s.Totals.CreditsPer1MTokens != 10000 { + t.Fatalf("credit totals = %+v, want credits=1.5 samples=1 tokens=150 ratio=10000", s.Totals) + } + if s.Totals.AvgLatencyMs != 200 { + t.Fatalf("avg latency = %v, want 200", s.Totals.AvgLatencyMs) + } + if len(s.ByRealm) != 2 { + t.Fatalf("by_realm = %d 项, want 2", len(s.ByRealm)) + } + if s.ByAccount[0].Realm == "" { + t.Fatal("by_account 行缺 realm 标注") + } + if len(s.CreditByAccount) != 1 || s.CreditByAccount[0].Key != "uid1" || + s.CreditByAccount[0].CreditSamples != 1 || s.CreditByAccount[0].CreditsPer1MTokens != 10000 { + t.Fatalf("credit_by_account = %+v, want one uid1 row", s.CreditByAccount) + } + if len(s.CreditByModel) != 1 || s.CreditByModel[0].Key != "glm-5.2" || + s.CreditByModel[0].Rate != "0.05" || s.CreditByModel[0].CreditsPer1MTokens != 10000 { + t.Fatalf("credit_by_model = %+v, want one glm-5.2 rate=0.05 row", s.CreditByModel) + } +} + +// Rollup 把超出 hourlyKeep 的小时桶折叠为日桶,且幂等:重复折叠不重复计数。 +// 窗口口径:24h 窗口不含 100 天前的日桶;hours=0(全部历史)才含日点。 +func TestRollupIdempotent(t *testing.T) { + r := New("") + old := time.Now().AddDate(0, 0, -100) // 100 天前,超出 90 天小时保留 + r.Add(old, "cn", "u", "m", Delta{PromptTokens: 7, HasPromptTokens: true}, true) + r.Add(old, "cn", "u", "m", Delta{PromptTokens: 7, HasPromptTokens: true}, true) + r.Add(time.Now(), "cn", "u", "m", Delta{PromptTokens: 1, HasPromptTokens: true}, true) + + r.Rollup(time.Now()) + after := r.Snapshot(24, nil) + if after.Totals.Requests != 1 || after.Totals.PromptTokens != 1 { + t.Fatalf("24h 窗口 totals = %d/%d, want 1/1(窗口外日桶不进聚合)", after.Totals.Requests, after.Totals.PromptTokens) + } + if len(after.Series) != 1 || after.Series[0].Scope != "hour" { + t.Fatalf("series = %+v, want 仅当前小时 1 个点", after.Series) + } + + all := r.Snapshot(0, nil) + if all.Totals.Requests != 3 || all.Totals.PromptTokens != 15 { + t.Fatalf("全部历史 totals = %d/%d, want 3/15", all.Totals.Requests, all.Totals.PromptTokens) + } + if len(all.Series) != 2 || all.Series[0].Scope != "day" || all.Series[1].Scope != "hour" { + t.Fatalf("series = %+v, want 日点在前 + 小时点在后", all.Series) + } + + r.Rollup(time.Now()) + again := r.Snapshot(0, nil) + if again.Totals.Requests != 3 || again.Totals.PromptTokens != 15 { + t.Fatalf("二次折叠后 totals = %d/%d, want 3/15(幂等被破坏)", again.Totals.Requests, again.Totals.PromptTokens) + } +} + +// 落盘→新实例恢复,数据不丢;落盘结构带版本号。 +func TestFlushLoadRoundtrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.json") + r1 := New(path) + r1.Add(time.Now(), "cn", "u1", "glm-5.2", Delta{PromptTokens: 42, HasPromptTokens: true, TotalTokens: 42, HasTotal: true}, true) + r1.Save() + + r2 := New(path) + s := r2.Snapshot(24, nil) + if s.Totals.Requests != 1 || s.Totals.TotalTokens != 42 { + t.Fatalf("恢复后 totals = %d/%d, want 1/42", s.Totals.Requests, s.Totals.TotalTokens) + } + raw, _ := os.ReadFile(path) + var f file + if err := json.Unmarshal(raw, &f); err != nil || f.Version != fileVersion || len(f.Buckets) != 1 { + t.Fatalf("落盘文件异常: err=%v buckets=%d", err, len(f.Buckets)) + } +} + +// 版本 1 文件没有积分字段:按零值恢复,旧 Token 数据保持可见且不产生伪比例。 +func TestLoadLegacyWithoutCredit(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.json") + legacy := `{"version":1,"saved":"2026-09-28T00:00:00+08:00","buckets":[{"s":"h:2026-09-28T10","r":"cn","u":"u1","m":"glm-5.2","q":1,"p":42,"t":42}]}` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + r := New(path) + s := r.Snapshot(0, nil) + if s.Totals.TotalTokens != 42 || s.Totals.CreditSamples != 0 || s.Totals.CreditsPer1MTokens != 0 { + t.Fatalf("legacy totals = %+v, want token-only history", s.Totals) + } + if len(s.CreditByAccount) != 0 || len(s.CreditByModel) != 0 { + t.Fatalf("legacy credit dimensions = %+v / %+v, want none", s.CreditByAccount, s.CreditByModel) + } +} + +// 小时桶折叠为日桶时必须保留积分、样本数和匹配 Token,比例不能因 Rollup 漂移。 +func TestCreditSurvivesRollup(t *testing.T) { + r := New("") + old := time.Now().AddDate(0, 0, -100) + r.Add(old, "cn", "u", "m", Delta{PromptTokens: 100, HasPromptTokens: true, TotalTokens: 100, HasTotal: true, Credit: 1.25, HasCredit: true, ModelRate: "0.5"}, true) + r.Add(old.Add(2*time.Hour), "cn", "u", "m", Delta{PromptTokens: 300, HasPromptTokens: true, TotalTokens: 300, HasTotal: true, Credit: 3.75, HasCredit: true, ModelRate: "0.5"}, true) + r.Rollup(time.Now()) + s := r.Snapshot(0, nil) + if s.Totals.Credits != 5 || s.Totals.CreditSamples != 2 || s.Totals.CreditTokens != 400 || s.Totals.CreditsPer1MTokens != 12500 { + t.Fatalf("rolled credit totals = %+v, want credits=5 tokens=400 ratio=12500", s.Totals) + } + if len(s.CreditByModel) != 1 || s.CreditByModel[0].Rate != "0.5" || s.CreditByModel[0].CreditsPer1MTokens != 12500 { + t.Fatalf("rolled credit_by_model = %+v, want one rate-preserving row", s.CreditByModel) + } +} + +// 模型维度按“裸模型名 + 生效倍率”合并;同倍率跨账号/时间合并,不同倍率拆行。 +func TestCreditDimensionsRateGrouping(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now, "cn", "u1", "cn:glm-5.2", Delta{TotalTokens: 100, HasTotal: true, Credit: 1, HasCredit: true, ModelRate: "0.5"}, true) + r.Add(now.Add(time.Hour), "cn", "u2", "glm-5.2", Delta{TotalTokens: 200, HasTotal: true, Credit: 2, HasCredit: true, ModelRate: "0.5"}, true) + r.Add(now.Add(2*time.Hour), "cn", "u2", "glm-5.2", Delta{TotalTokens: 300, HasTotal: true, Credit: 6, HasCredit: true, ModelRate: "0.8"}, true) + + s := r.Snapshot(24, nil) + if len(s.CreditByAccount) != 2 || len(s.CreditByModel) != 2 { + t.Fatalf("dimensions accounts=%+v models=%+v, want 2 accounts and 2 model-rate rows", s.CreditByAccount, s.CreditByModel) + } + if s.CreditByModel[0].Key != "glm-5.2" || s.CreditByModel[0].Rate != "0.8" || + s.CreditByModel[0].Credits != 6 || s.CreditByModel[0].CreditTokens != 300 { + t.Fatalf("first model row = %+v, want rate=0.8 credits=6 tokens=300", s.CreditByModel[0]) + } + if s.CreditByModel[1].Rate != "0.5" || s.CreditByModel[1].Credits != 3 || s.CreditByModel[1].CreditTokens != 300 { + t.Fatalf("merged model row = %+v, want rate=0.5 credits=3 tokens=300", s.CreditByModel[1]) + } +} + +// 旧桶缺倍率时由当前目录倍率回填,并与新桶同倍率记录合并;目录缺失时保留未知行。 +func TestCreditLegacyRateFallback(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now, "cn", "u1", "glm-5.2", Delta{TotalTokens: 100, HasTotal: true, Credit: 1, HasCredit: true}, true) + r.Add(now.Add(time.Hour), "cn", "u1", "glm-5.2", Delta{TotalTokens: 200, HasTotal: true, Credit: 2, HasCredit: true, ModelRate: "0.79"}, true) + + s := r.SnapshotWithRates(24, nil, func(realm, model string) string { + if realm == "cn" && model == "glm-5.2" { + return "0.79" + } + return "" + }) + if len(s.CreditByModel) != 1 || s.CreditByModel[0].Rate != "0.79" || + s.CreditByModel[0].Credits != 3 || s.CreditByModel[0].CreditTokens != 300 { + t.Fatalf("fallback model rows = %+v, want legacy merged into rate=0.79", s.CreditByModel) + } +} + +// Snapshot 全口径窗口过滤:窗口外的数据不进**任何**聚合(卡片/表格/时序), +// 切窗口数字随之变化;hours=0 全部历史。Buckets 为窗口内命中的桶数。 +func TestSnapshotWindowFilter(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now.Add(-48*time.Hour), "cn", "u", "m", Delta{PromptTokens: 5, HasPromptTokens: true}, true) // 窗口(24h)外 + r.Add(now, "cn", "u", "m", Delta{PromptTokens: 3, HasPromptTokens: true}, true) // 窗口内 + s := r.Snapshot(24, nil) + if s.Totals.Requests != 1 || s.Totals.PromptTokens != 3 { + t.Fatalf("24h 窗口 totals = %d/%d, want 1/3(48h 前的数据应被过滤)", s.Totals.Requests, s.Totals.PromptTokens) + } + if len(s.Series) != 1 || s.Series[0].Scope != "hour" || s.Series[0].PromptTokens != 3 { + t.Fatalf("series = %+v, want 仅窗口内 1 个小时点", s.Series) + } + if s.Buckets != 1 { + t.Fatalf("buckets = %d, want 1(窗口内命中桶数)", s.Buckets) + } + + all := r.Snapshot(0, nil) + if all.Totals.Requests != 2 || all.Totals.PromptTokens != 8 { + t.Fatalf("全部历史 totals = %d/%d, want 2/8", all.Totals.Requests, all.Totals.PromptTokens) + } + // since 是全库数据起点,不受窗口影响。 + if all.Since == "" || s.Since != all.Since { + t.Fatalf("since 应为全库起点且不随窗口变化: all=%q windowed=%q", all.Since, s.Since) + } +} + +// 显式区间(「今天」/「自定义」)与滚动窗口走同一套全口径过滤;区间是闭区间 +// (桶起点落在 [From, To] 内即命中),且 From/To 会回显给面板确认口径。 +func TestSnapshotExplicitWindow(t *testing.T) { + r := New("") + base := time.Now().Truncate(time.Hour).Add(-5 * time.Hour) + for i := 0; i < 6; i++ { + r.Add(base.Add(time.Duration(i)*time.Hour), "cn", "u", "m", + Delta{PromptTokens: 10, HasPromptTokens: true}, true) + } + // 只取中间两小时(base+2h、base+3h)。 + s := r.SnapshotWindow(Window{ + From: base.Add(2 * time.Hour), + To: base.Add(3 * time.Hour), + }, nil, nil) + if s.Totals.Requests != 2 || s.Totals.PromptTokens != 20 { + t.Fatalf("显式区间 totals = %d/%d, want 2/20", s.Totals.Requests, s.Totals.PromptTokens) + } + if len(s.Series) != 2 || s.Buckets != 2 { + t.Fatalf("显式区间 series/buckets = %d/%d, want 2/2", len(s.Series), s.Buckets) + } + if s.WindowFrom == "" || s.WindowTo == "" { + t.Fatalf("显式区间应回显 window_from/window_to: %+v", s) + } + if _, err := time.Parse(time.RFC3339, s.WindowFrom); err != nil { + t.Fatalf("window_from 不是 RFC3339: %q", s.WindowFrom) + } + + // 只有 From(「今天」的形态):从该点起到最新,全量命中。 + only := r.SnapshotWindow(Window{From: base.Add(4 * time.Hour)}, nil, nil) + if only.Totals.Requests != 2 { + t.Fatalf("仅 From 的 totals = %d, want 2", only.Totals.Requests) + } + if only.WindowFrom == "" || only.WindowTo != "" { + t.Fatalf("仅 From 时 window_to 应为空: %+v", only) + } + + // 空窗口(From/To 全零且 Hours<=0)= 全部历史,与 Snapshot(0) 等价。 + all := r.SnapshotWindow(Window{}, nil, nil) + if all.Totals.Requests != 6 { + t.Fatalf("全零窗口 totals = %d, want 6(全部历史)", all.Totals.Requests) + } + if all.WindowFrom != "" || all.WindowTo != "" { + t.Fatalf("全部历史不应回显区间: %+v", all) + } +} + +// 滚动窗口的上限仍是 60 天,且与显式区间互不干扰(From/To 优先)。 +func TestWindowBounds(t *testing.T) { + // From/To 优先于 Hours。 + from := time.Now().Add(-2 * time.Hour) + gotFrom, gotTo := Window{Hours: 720, From: from}.bounds() + if !gotFrom.Equal(from) || !gotTo.IsZero() { + t.Fatalf("From 应优先于 Hours: from=%v to=%v", gotFrom, gotTo) + } + // 只有 Hours:起点 = 当前整点往回 Hours-1 小时。 + f, to := Window{Hours: 24}.bounds() + want := time.Now().Truncate(time.Hour).Add(-23 * time.Hour) + if !f.Equal(want) || !to.IsZero() { + t.Fatalf("24h bounds = %v/%v, want %v/零值", f, to, want) + } + // Hours<=0 且无 From/To = 全部历史。 + if f, to := (Window{}).bounds(); !f.IsZero() || !to.IsZero() { + t.Fatalf("空窗口 bounds = %v/%v, want 零值/零值", f, to) + } + // 上限 60 天。 + f60, _ := Window{Hours: 100000}.bounds() + want60 := time.Now().Truncate(time.Hour).Add(-(24*60 - 1) * time.Hour) + if !f60.Equal(want60) { + t.Fatalf("超限 Hours 未被夹到 60 天: %v want %v", f60, want60) + } +} + +// 脏 scope(解析失败)不进任何口径,也不会让整次快照失败。 +func TestBucketTimeRejectsGarbage(t *testing.T) { + if _, ok := bucketTime("h:not-a-time"); ok { + t.Fatal("脏小时 scope 应判定失败") + } + if _, ok := bucketTime("d:2026-13-45"); ok { + t.Fatal("脏日 scope 应判定失败") + } + if ts, ok := bucketTime("h:2026-09-30T13"); !ok || ts.Hour() != 13 { + t.Fatalf("合法小时 scope 解析失败: %v %v", ts, ok) + } + if ts, ok := bucketTime("d:2026-09-30"); !ok || ts.Day() != 30 { + t.Fatalf("合法日 scope 解析失败: %v %v", ts, ok) + } +} + +// Stop 触发最终落盘(Start 后未到防抖间隔也要落)。 +func TestLifecycleFlush(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.json") + r := New(path) + r.Start() + r.Add(time.Now(), "cn", "u", "m", Delta{PromptTokens: 9, HasPromptTokens: true}, true) + r.Stop() + if _, err := os.Stat(path); err != nil { + t.Fatalf("Stop 后应有落盘文件: %v", err) + } +} + +// TestCacheHitRateAccumulation(issue #92):桶累计 hit/miss,credit_by_model +// 与 totals 输出命中率;无缓存观测的桶不参与(rate 零值省略)。 +func TestCacheHitRateAccumulation(t *testing.T) { + r := New(t.TempDir() + "/usage.json") + base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) + r.Add(base, "cn", "u1", "glm-5.3", Delta{ + PromptTokens: 1000, HasPromptTokens: true, + Credit: 1, HasCredit: true, TotalTokens: 1005, HasTotal: true, + HasCacheTokens: true, CacheHitTokens: 900, CacheMissTokens: 100, + }, true) + r.Add(base.Add(time.Minute), "cn", "u1", "glm-5.3", Delta{ + PromptTokens: 1000, HasPromptTokens: true, + Credit: 2, HasCredit: true, TotalTokens: 1004, HasTotal: true, + HasCacheTokens: true, CacheHitTokens: 100, CacheMissTokens: 900, + }, true) + snap := r.Snapshot(0, nil) + if got := snap.CreditByModel[0].CacheHitRate; got != 50 { + t.Fatalf("cache_hit_rate=%v want 50", got) + } + if snap.Totals.CacheHitRate != 50 { + t.Fatalf("totals cache_hit_rate=%v want 50", snap.Totals.CacheHitRate) + } + if snap.CreditByModel[0].CacheHitTokens != 1000 || snap.CreditByModel[0].CacheMissTokens != 1000 { + t.Fatalf("hit/miss 累计错误: %+v", snap.CreditByModel[0]) + } + + // 无缓存观测的桶:rate 零值。 + r2 := New(t.TempDir() + "/usage2.json") + r2.Add(base, "cn", "u1", "m", Delta{PromptTokens: 10, HasPromptTokens: true, Credit: 1, HasCredit: true, TotalTokens: 12, HasTotal: true}, true) + if got := r2.Snapshot(0, nil).CreditByModel[0].CacheHitRate; got != 0 { + t.Fatalf("无缓存观测应 rate=0, got %v", got) + } +} + +// TestCacheTokensPersistRoundtrip:ch/cm 落盘 v4 并无损恢复。 +func TestCacheTokensPersistRoundtrip(t *testing.T) { + dir := t.TempDir() + r := New(dir + "/usage.json") + base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) + r.Add(base, "cn", "u1", "m", Delta{ + Credit: 1, HasCredit: true, TotalTokens: 100, HasTotal: true, + HasCacheTokens: true, CacheHitTokens: 75, CacheMissTokens: 25, + }, true) + r.Save() + r2 := New(dir + "/usage.json") + snap := r2.Snapshot(0, nil) + if snap.CreditByModel[0].CacheHitRate != 75 { + t.Fatalf("恢复后 cache_hit_rate=%v want 75", snap.CreditByModel[0].CacheHitRate) + } +} diff --git a/log.py b/log.py deleted file mode 100644 index ea944a741093cc8ee0e465a2d4c5c5091566819e..0000000000000000000000000000000000000000 --- a/log.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -日志模块 - 使用环境变量配置 -""" - -import os -import sys -import threading -from datetime import datetime -from collections import deque -import atexit - -# 日志级别定义 -LOG_LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3, "critical": 4} - -# 文件写入状态标志(仅由 writer 线程修改,无需锁保护) -_file_writing_disabled = False -_disable_reason = None - -# 全局文件句柄(仅由 writer 线程访问,无需文件锁) -_log_file_handle = None - -# ----------------------------------------------------------------- -# 高性能无锁队列:用 deque + Condition 替代 Queue -# deque.append / deque.popleft 在 CPython 中受 GIL 保护,是原子操作, -# 不需要额外的 Lock 做入队保护,只用 Condition 做"有数据"通知。 -# ----------------------------------------------------------------- -_log_deque: deque = deque() -_deque_condition = threading.Condition(threading.Lock()) -_writer_thread = None -_writer_running = False - -# ----------------------------------------------------------------- -# 缓存日志级别,避免每次都读 os.getenv(高并发热路径) -# ----------------------------------------------------------------- -_cached_log_level: int = LOG_LEVELS["info"] -_cached_log_file: str = "log.txt" -# ENABLE_LOG=0/false/no/off 时彻底关闭日志 -_log_enabled: bool = True - - -def _refresh_config(): - """从环境变量刷新缓存配置(模块加载时及需要时调用)""" - global _cached_log_level, _cached_log_file, _log_enabled - level = os.getenv("LOG_LEVEL", "info").lower() - _cached_log_level = LOG_LEVELS.get(level, LOG_LEVELS["info"]) - _cached_log_file = os.getenv("LOG_FILE", "log.txt") - _log_enabled = os.getenv("ENABLE_LOG", "1").strip().lower() not in ("0", "false", "no", "off") - - -def _get_current_log_level() -> int: - return _cached_log_level - - -def _get_log_file_path() -> str: - return _cached_log_file - - -# ----------------------------------------------------------------- -# 文件句柄管理(仅在 writer 线程内调用,不需要 _file_lock) -# ----------------------------------------------------------------- - -def _close_log_file(): - global _log_file_handle - if _log_file_handle is not None: - try: - _log_file_handle.flush() - _log_file_handle.close() - except Exception: - pass - finally: - _log_file_handle = None - - -def _open_log_file(mode: str = "a") -> bool: - global _log_file_handle, _file_writing_disabled, _disable_reason - _close_log_file() - try: - # 使用较大缓冲区(64 KB),由 writer 线程定期 flush,减少系统调用 - _log_file_handle = open(_cached_log_file, mode, encoding="utf-8", buffering=65536) - return True - except (PermissionError, OSError, IOError) as e: - _file_writing_disabled = True - _disable_reason = str(e) - print(f"Warning: Cannot open log file, disabling file writing: {e}", file=sys.stderr) - print("Log messages will continue to display in console only.", file=sys.stderr) - return False - except Exception as e: - print(f"Warning: Failed to open log file: {e}", file=sys.stderr) - return False - - -def _clear_log_file(): - """清空日志文件(启动时调用,此时 writer 线程尚未启动,直接操作安全)""" - global _file_writing_disabled, _disable_reason - try: - with open(_cached_log_file, "w", encoding="utf-8") as f: - pass # 覆盖清空 - _open_log_file("a") - except (PermissionError, OSError, IOError) as e: - _file_writing_disabled = True - _disable_reason = str(e) - print( - f"Warning: File system appears to be read-only or permission denied. " - f"Disabling log file writing: {e}", - file=sys.stderr, - ) - print("Log messages will continue to display in console only.", file=sys.stderr) - except Exception as e: - print(f"Warning: Failed to clear log file: {e}", file=sys.stderr) - - -# ----------------------------------------------------------------- -# Writer 线程:批量从 deque 取出并写入,减少系统调用次数 -# ----------------------------------------------------------------- -_BATCH_SIZE = 1000 # 单次最多批量写入条数 -_FLUSH_INTERVAL = 2 # 秒:无新消息时强制 flush 周期 - - -def _log_writer_worker(): - global _writer_running - - last_flush_time = 0.0 - - while True: - # 等待数据或超时 - with _deque_condition: - if not _log_deque and _writer_running: - _deque_condition.wait(timeout=_FLUSH_INTERVAL) - - # 批量取出 - batch = [] - for _ in range(_BATCH_SIZE): - if _log_deque: - batch.append(_log_deque.popleft()) - else: - break - - if batch and not _file_writing_disabled: - # 一次 write 调用搞定整批,最大化减少系统调用 - chunk = "\n".join(batch) + "\n" - try: - if _log_file_handle is None: - _open_log_file("a") - if _log_file_handle is not None: - _log_file_handle.write(chunk) - except Exception as e: - print(f"Warning: Failed to write log batch: {e}", file=sys.stderr) - _close_log_file() - try: - _open_log_file("a") - except Exception: - pass - - # 定时 flush - now = _now_ts() - if now - last_flush_time >= _FLUSH_INTERVAL: - if _log_file_handle is not None: - try: - _log_file_handle.flush() - except Exception: - pass - last_flush_time = now - - # 退出条件:已停止 + deque 已清空 - if not _writer_running and not _log_deque: - break - - # 最终 flush & close - if _log_file_handle is not None: - try: - _log_file_handle.flush() - except Exception: - pass - _close_log_file() - - -def _now_ts() -> float: - import time - return time.monotonic() - - -def _start_writer_thread(): - global _writer_thread, _writer_running - - if _writer_thread is None or not _writer_thread.is_alive(): - _writer_running = True - _writer_thread = threading.Thread(target=_log_writer_worker, daemon=True, name="LogWriter") - _writer_thread.start() - - -def _stop_writer_thread(): - global _writer_running - - _writer_running = False - # 唤醒 writer 线程让它能感知退出信号 - with _deque_condition: - _deque_condition.notify_all() - - if _writer_thread and _writer_thread.is_alive(): - _writer_thread.join(timeout=3.0) - - -# ----------------------------------------------------------------- -# 入队(热路径,极轻量) -# ----------------------------------------------------------------- -_MAX_QUEUE_SIZE = 5000 # 防止极端情况内存无限增长 - - -def _write_to_file(message: str): - if _file_writing_disabled: - return - # deque.append 在 CPython 受 GIL 保护,无需额外锁 - if len(_log_deque) >= _MAX_QUEUE_SIZE: - return # 过载保护:丢弃而非阻塞 - _log_deque.append(message) - # 非阻塞通知 writer(acquire 失败直接跳过,不影响主线程) - if _deque_condition.acquire(blocking=False): - try: - _deque_condition.notify() - finally: - _deque_condition.release() - - -# ----------------------------------------------------------------- -# 核心日志函数(热路径) -# ----------------------------------------------------------------- - -def _log(level: str, message: str): - # 最快短路:日志整体已禁用时直接返回,零开销 - if not _log_enabled: - return - - level = level.lower() - level_val = LOG_LEVELS.get(level) - if level_val is None: - print(f"Warning: Unknown log level '{level}'", file=sys.stderr) - return - - # 热路径:直接与缓存值比较,无函数调用开销 - if level_val < _cached_log_level: - return - - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - entry = f"[{timestamp}] [{level.upper()}] {message}" - - if level in ("error", "critical"): - print(entry, file=sys.stderr) - else: - print(entry) - - _write_to_file(entry) - - -def set_log_level(level: str): - """动态设置日志级别(同时更新缓存)""" - global _cached_log_level - level = level.lower() - if level not in LOG_LEVELS: - print(f"Warning: Unknown log level '{level}'. Valid levels: {', '.join(LOG_LEVELS.keys())}") - return False - _cached_log_level = LOG_LEVELS[level] - return True - - -class Logger: - """支持 log('info', 'msg') 和 log.info('msg') 两种调用方式""" - - def __call__(self, level: str, message: str): - _log(level, message) - - def debug(self, message: str): - _log("debug", message) - - def info(self, message: str): - _log("info", message) - - def warning(self, message: str): - _log("warning", message) - - def error(self, message: str): - _log("error", message) - - def critical(self, message: str): - _log("critical", message) - - def get_current_level(self) -> str: - current_level = _get_current_log_level() - for name, value in LOG_LEVELS.items(): - if value == current_level: - return name - return "info" - - def get_log_file(self) -> str: - return _get_log_file_path() - - def close(self): - """手动关闭(优雅退出用)""" - _stop_writer_thread() - - def get_queue_size(self) -> int: - return len(_log_deque) - - -# 导出全局日志实例 -log = Logger() - -# 导出的公共接口 -__all__ = ["log", "set_log_level", "LOG_LEVELS"] - -# 模块加载时:读取配置缓存 → 清空日志文件 → 启动 writer 线程 -_refresh_config() -if _log_enabled: - _clear_log_file() - _start_writer_thread() - -# 注册退出清理 -atexit.register(_stop_writer_thread) - -# 使用说明: -# 1. 设置日志级别: export LOG_LEVEL=debug (或在 .env 中设置) -# 2. 设置日志文件: export LOG_FILE=log.txt (或在 .env 中设置) -# 3. 日志级别已缓存,热路径零 os.getenv 调用 -# 4. 写入线程批量处理(最多 200 条/次),64 KB 缓冲区,每 0.5 s flush 一次 -# 5. 队列上限 5000 条,超出时丢弃新日志(过载保护,不阻塞主线程) -# 6. 动态调整级别:set_log_level('debug') 立即生效 -# 7. 彻底关闭日志(最高性能):export ENABLE_LOG=0 (或 false/no/off) -# 关闭后不会启动 writer 线程、不写文件、不打印控制台,_log 直接 return diff --git a/login.sh b/login.sh new file mode 100644 index 0000000000000000000000000000000000000000..6471869fd96973a35258b27abb08140e709a022f --- /dev/null +++ b/login.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# login.sh — WorkBuddy CN OAuth 登录 → 落盘 auth 文件 +# +# 用法: +# ./login.sh +# +# 流程: +# 1. POST /v2/plugin/auth/state 拿授权 URL(无 PKCE,state 由服务端签发) +# 2. 你在浏览器打开 URL 完成登录 +# 3. 回到这里按 y → poll 拿 token+uid+nickname → 签到 → 落盘 auths/workbuddy-.json +# 4. 重启 workbuddy2api 容器加载新账号 +set -euo pipefail + +cd "$(dirname "$0")" +AUTH_DIR="./auths" +CONTAINER="workbuddy2api" + +mkdir -p "$AUTH_DIR" + +# login 工具:不存在才编译(源码改动后手动 go build -o login ./cmd/login) +LOGIN_BIN="./login" +if [[ ! -x "$LOGIN_BIN" ]]; then + go build -o "$LOGIN_BIN" ./cmd/login +fi + +echo "============================================================" +echo " WorkBuddy OAuth 登录" +echo "============================================================" +echo "" + +AUTH_URL=$("$LOGIN_BIN" url) + +echo "请在浏览器中打开以下链接完成登录:" +echo "" +echo " $AUTH_URL" +echo "" + +if command -v xclip &>/dev/null; then + echo -n "$AUTH_URL" | xclip -selection clipboard 2>/dev/null && echo "(已复制到剪贴板)" +elif command -v xsel &>/dev/null; then + echo -n "$AUTH_URL" | xsel --clipboard 2>/dev/null && echo "(已复制到剪贴板)" +fi + +echo "" +read -rp "完成登录后按 y 继续: " ans +if [[ "$ans" != "y" && "$ans" != "Y" ]]; then + echo "已取消" + exit 1 +fi + +echo "" +echo "正在获取 token..." + +RESULT=$("$LOGIN_BIN" poll) || { + echo "" + echo "获取 token 失败。可能原因:" + echo " - 登录还没完成就按了 y(重新运行 ./login.sh 再试)" + echo " - 登录页报错(把报错截图发出来排查)" + exit 1 +} + +TOKEN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])") +REFRESH=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['refresh_token'])") +EXPIRES_IN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['expires_in'])") +DOMAIN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('domain',''))") +USER_ID=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('uid',''))") +ENT_ID=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('enterprise_id',''))") +NICKNAME=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('nickname',''))") + +if [[ -z "$USER_ID" ]]; then + echo "无法获取 uid,请检查 token 是否有效" + exit 1 +fi + +EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN )) + +# ─── 签到(CN:POST codebuddy.cn/v2/billing/meter/daily-checkin,幂等不阻塞)─── +# OAuth 返回字段一律通过环境变量传入 Python,并用带引号 heredoc: +# 昵称/domain/token 等值可含引号或换行,拼进 Python 源码会造成注入。 +WB2A_LOGIN_TOKEN="$TOKEN" \ +WB2A_LOGIN_USER_ID="$USER_ID" \ +WB2A_LOGIN_ENT_ID="$ENT_ID" \ +WB2A_LOGIN_DOMAIN="$DOMAIN" \ +python3 - <<'PYEOF' +import json, os, urllib.request, urllib.error + +token = os.environ["WB2A_LOGIN_TOKEN"] +user_id = os.environ["WB2A_LOGIN_USER_ID"] +ent_id = os.environ["WB2A_LOGIN_ENT_ID"] +domain = os.environ["WB2A_LOGIN_DOMAIN"] + +req = urllib.request.Request( + "https://www.codebuddy.cn/v2/billing/meter/daily-checkin", + method="POST", data=b"{}", + headers={ + "Authorization": "Bearer " + token, + "Accept": "application/json", + "Content-Type": "application/json", + "X-User-Id": user_id, + **({"X-Enterprise-Id": ent_id, "X-Tenant-Id": ent_id} if ent_id else {}), + **({"X-Domain": domain} if domain else {}), + }) +try: + with urllib.request.urlopen(req, timeout=15) as r: + body = json.loads(r.read().decode() or "{}") + if body.get("code") == 0: + data = body.get("data") or {} + print(f"签到: 成功 {json.dumps(data, ensure_ascii=False)[:150]}") + else: + print(f"签到: {body.get('msg', json.dumps(body)[:150])}") +except urllib.error.HTTPError as e: + # 已签到等业务错误也走 4xx(实测 code=10001 "今天已签到") + try: + body = json.loads(e.read().decode() or "{}") + print(f"签到: {body.get('msg', 'http %d' % e.code)}") + except Exception: + print(f"签到: http {e.code}") +except Exception as e: + print(f"签到: {e}") +PYEOF + +# ─── 落盘 auth 文件(与 internal/auth 读取格式一致)───────────────── +AUTH_FILE="$AUTH_DIR/workbuddy-${USER_ID}.json" +if [[ -f "$AUTH_FILE" ]]; then + echo "账号已存在(uid=${USER_ID}),将覆盖更新凭证" + ACTION="覆盖" +else + echo "新账号(uid=${USER_ID}),新增 auth 文件" + ACTION="新增" +fi +WB2A_LOGIN_TOKEN="$TOKEN" \ +WB2A_LOGIN_REFRESH="$REFRESH" \ +WB2A_LOGIN_EXPIRES_AT="$EXPIRES_AT" \ +WB2A_LOGIN_DOMAIN="$DOMAIN" \ +WB2A_LOGIN_USER_ID="$USER_ID" \ +WB2A_LOGIN_ENT_ID="$ENT_ID" \ +WB2A_LOGIN_NICKNAME="$NICKNAME" \ +WB2A_LOGIN_AUTH_FILE="$AUTH_FILE" \ +WB2A_LOGIN_ACTION="$ACTION" \ +python3 - <<'PYEOF' +import json, os + +auth = { + "account": { + "uid": os.environ["WB2A_LOGIN_USER_ID"], + "enterpriseId": os.environ["WB2A_LOGIN_ENT_ID"], + "nickname": os.environ["WB2A_LOGIN_NICKNAME"], + }, + "auth": { + "accessToken": os.environ["WB2A_LOGIN_TOKEN"], + "refreshToken": os.environ["WB2A_LOGIN_REFRESH"], + "expiresAt": int(os.environ["WB2A_LOGIN_EXPIRES_AT"]), + "domain": os.environ["WB2A_LOGIN_DOMAIN"], + }, +} +with open(os.environ["WB2A_LOGIN_AUTH_FILE"], "w") as f: + json.dump(auth, f, indent=1) +print(f"已保存({os.environ['WB2A_LOGIN_ACTION']}): {os.environ['WB2A_LOGIN_AUTH_FILE']}") +PYEOF + +# ─── 重启服务 ──────────────────────────────────────────── +echo "" +if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + echo "重启 $CONTAINER 加载新账号..." + docker restart "$CONTAINER" >/dev/null + sleep 2 + # API_KEY 从 config.json 读取(该变量在脚本中未定义,fallback 仅为占位,不会通过鉴权) + API_KEY=$(python3 -c "import json; print(json.load(open('config.json')).get('api_key',''))" 2>/dev/null) + COUNT=$(curl -s http://127.0.0.1:7863/status -H "Authorization: Bearer ${API_KEY:-test_key}" 2>/dev/null | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('accounts',[])))" 2>/dev/null || echo "?") + echo "服务已重启,当前账号数: $COUNT" +else + echo "容器 $CONTAINER 未运行,auth 文件已保存,下次启动自动加载" +fi + +echo "" +echo "============================================================" +echo " 登录完成!" +echo " UID: $USER_ID" +echo " Nickname: ${NICKNAME:-(未获取到)}" +echo " Token: ${TOKEN:0:30}..." +echo " 有效期: $(date -d "@$EXPIRES_AT" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$EXPIRES_AT")" +echo "============================================================" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 37746149416dc858e3286c10996271c05112e212..0000000000000000000000000000000000000000 --- a/pyproject.toml +++ /dev/null @@ -1,103 +0,0 @@ -[project] -name = "gcli2api" -version = "0.1.0" -description = "Convert GeminiCLI to OpenAI and Gemini API interfaces" -readme = "README.md" -requires-python = ">=3.12" -license = {text = "CNC-1.0"} -authors = [ - {name = "su-kaka"} -] -keywords = ["gemini", "openai", "api", "converter", "cli"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: Other/Proprietary License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.13", -] -dependencies = [ - "aiofiles>=24.1.0", - "fastapi>=0.116.1", - "httpx[socks]>=0.28.1", - "hypercorn>=0.17.3", - "motor>=3.7.1", - "oauthlib>=3.3.1", - "pydantic>=2.11.7", - "pyjwt>=2.10.1", - "python-dotenv>=1.1.1", - "python-multipart>=0.0.20", - "pypinyin>=0.51.0", - "aiosqlite>=0.20.0", - "redis>=7.2.0", - "asyncpg>=0.31.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", - "pytest-cov>=4.1.0", - "black>=24.0.0", - "flake8>=7.0.0", - "mypy>=1.8.0", - "pre-commit>=3.6.0", -] - -[tool.pytest.ini_options] -minversion = "8.0" -testpaths = ["."] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -asyncio_mode = "auto" -addopts = [ - "-v", - "--strict-markers", -] - -[tool.black] -line-length = 100 -target-version = ["py313"] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | build - | dist -)/ -''' - -[tool.mypy] -python_version = "3.13" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = false -ignore_missing_imports = true -exclude = [ - "build", - "dist", -] - -[tool.coverage.run] -source = ["src"] -omit = [ - "*/tests/*", - "*/test_*.py", -] - -[tool.coverage.report] -exclude_lines = [ - "pragma: no cover", - "def __repr__", - "raise AssertionError", - "raise NotImplementedError", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] diff --git a/render.yaml b/render.yaml deleted file mode 100644 index 7eabfe61b71296ae5ed3e6f231e738859dbd00a4..0000000000000000000000000000000000000000 --- a/render.yaml +++ /dev/null @@ -1,20 +0,0 @@ -services: - - type: web - name: gcli2api - runtime: docker - dockerfilePath: ./Dockerfile - dockerContext: . - plan: free - region: singapore - healthCheckPath: / - - envVars: - # ========== 必填:访问密码 ========== - - key: PASSWORD - sync: false # 部署时手动填写,不同步到代码库 - - # ========== 服务器配置 ========== - - key: HOST - value: 0.0.0.0 - - key: PORT - value: "10000" # Render 要求 Web 服务监听 10000 端口 diff --git a/requirements-termux.txt b/requirements-termux.txt deleted file mode 100644 index aa9bd0ea916dc7e2b9d55d1c4dac234a96cd3b1f..0000000000000000000000000000000000000000 --- a/requirements-termux.txt +++ /dev/null @@ -1,14 +0,0 @@ -fastapi -httpx[socks] -pydantic==1.10.22 -python-dotenv -hypercorn -aiofiles -python-multipart -PyJWT -oauthlib -motor -pypinyin -aiosqlite -redis -asyncpg \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fcc07407949409bf590c8e866bdd0dc9b4e55db8..0000000000000000000000000000000000000000 --- a/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -fastapi>=0.116.1 -httpx[socks]>=0.28.1 -pydantic>=2.11.7 -python-dotenv>=1.1.1 -hypercorn>=0.17.3 -aiofiles>=24.1.0 -python-multipart>=0.0.20 -PyJWT>=2.10.1 -oauthlib>=3.3.1 -motor>=3.7.1 -aiosqlite>=0.20.0 -pypinyin>=0.51.0 -redis>=4.2.0 -asyncpg diff --git a/scripts/probe_active.py b/scripts/probe_active.py new file mode 100644 index 0000000000000000000000000000000000000000..09c365ed557937ccc8b878c83d46c563f9f0986b --- /dev/null +++ b/scripts/probe_active.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""WorkBuddy 活跃上报探针 / 解冻工具. + +用途 + probe 只读:查某账号 growth 任务状态 + streak + report 写:向 /v2/report 发一条 chat_request_send(点亮活跃/连登 + 解锁 first_buddy) + unlock 写:report -> agreement -> buddy/first(无猫号领免费猫) + +用法 + python3 probe_active.py probe 00e26541 + python3 probe_active.py report 0225284f + python3 probe_active.py unlock 0225284f --yes + python3 probe_active.py report ALL --yes --gap 1.05 # 全池 + +注意 + - 默认 dry-run:写动作必须显式加 --yes + - 单账号同接口间隔 >=1.05s(--gap) + - body 必须含 userId 字段,否则服务端 200 但静默丢弃 +""" +import json, os, sys, time, glob, argparse, urllib.request, urllib.error + +AUTHS = "/root/workbuddy2api/auths" +CHAT_BASE = "https://copilot.tencent.com" # growth / report +BILL_BASE = "https://www.codebuddy.cn" # billing / report + +def cred(prefix): + hits = glob.glob(os.path.join(AUTHS, f"workbuddy-{prefix}*.json")) + if not hits: + raise SystemExit(f"no auth for {prefix}") + d = json.load(open(hits[0])) + a, acc = d["auth"], d["account"] + return {"token": a["accessToken"], "domain": a.get("domain") or "", + "uid": acc["uid"], "nick": acc.get("nickname", ""), + "file": os.path.basename(hits[0])} + +def call(c, method, path, body=None, host=CHAT_BASE, timeout=30): + url = path if path.startswith("http") else host + path + hdr = {"Authorization": "Bearer " + c["token"], "X-User-Id": c["uid"], + "X-Domain": c["domain"], "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "CLI/2.63.2 CodeBuddy/2.63.2", + "Origin": "https://www.codebuddy.cn", "Referer": "https://www.codebuddy.cn/"} + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=hdr, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + t = e.read().decode("utf-8", "replace") + try: return e.code, json.loads(t) + except Exception: return e.code, {"raw": t[:300]} + except Exception as e: + return -1, {"err": repr(e)} + +def chat_event(c, conversation_id=None): + """客户端 chat_request_send 事件形状(照抄 CLI,勿删 userId)。""" + now = int(time.time() * 1000) + cid = conversation_id or f"wb-active-{now}" + return {"eventCode": "chat_request_send", "timestamp": now, "reportDelay": 0, + "mode": "craft", "conversationId": cid, "requestId": cid, + "inputLength": 12, "requestModelId": "deepseek-v4-flash", + "requestModelName": "DeepSeek V4 Flash", "isPlan": False, + "isAutoExecuteTerminal": False, "isAutoModify": False, + "codebaseEnable": False, "maxToken": 0, "maxSteps": 0, "temperature": 0, + "maxRetries": 0, "mentionContexts": [], "knowledgeId": [], + "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, + "command": "", "expertId": "", "recommendId": "", "skillId": "", + "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, + "traceId": "", "rootRequestId": cid, "parentConversationId": cid, + "agentName": "default", "agentType": "conversation", "userId": c["uid"]} + +def report_activity(c): + """点亮活跃/连登。返回 (status, code).""" + st, r = call(c, "POST", "/v2/report", [chat_event(c)], host=BILL_BASE) + return st, r.get("code") + +def tasks(c): + st, d = call(c, "GET", "/v2/activity/growth/tasks") + out = {} + for t in (d.get("data", {}).get("tasks") or []): + out[t["task_code"]] = (t["accept_status"], t.get("progress")) + return out + +def unlock(c, n_reports=6): + """无猫号解冻:report -> agreement -> buddy/first -> chat_5""" + st, code = report_activity(c) + print(f" report -> {st} code={code}") + time.sleep(2) + st, ag = call(c, "POST", "/activity/growth/buddy/agreement", {"agree": True}) + print(f" agreement -> {st} code={ag.get('code')}") + st, bf = call(c, "POST", "/activity/growth/buddy/first", {}) + d = bf.get("data") or {} + print(f" buddy/first -> {st} {bf.get('msg')} credit={d.get('credit')} energy={d.get('energy')}") + return bf + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("action", choices=["probe", "report", "unlock"]) + ap.add_argument("account", help="uid 前缀,或 ALL") + ap.add_argument("--yes", action="store_true", help="确认执行写操作") + ap.add_argument("--gap", type=float, default=1.05) + a = ap.parse_args() + + if a.account.upper() == "ALL": + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [a.account] + + for pre in prefixes: + c = cred(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + if a.action == "probe": + t = tasks(c) + st, s = call(c, "GET", "/activity/growth/streak") + print(" first_buddy:", t.get("first_buddy"), + "| RichMeow_Chat:", t.get("RichMeow_Chat"), + "| chat_5:", t.get("chat_5")) + print(" streak.days:", (s.get("data", {}).get("streak", {}) or {}).get("days")) + elif a.action == "report": + if not a.yes: + print(" [dry-run] 将上报 1 条 chat_request_send(加 --yes 执行)") + else: + print(" report ->", report_activity(c)) + else: # unlock + if not a.yes: + print(" [dry-run] 将 report + agreement + buddy/first(加 --yes 执行)") + else: + unlock(c) + time.sleep(a.gap) + +if __name__ == "__main__": + main() diff --git a/scripts/probe_max_tokens.py b/scripts/probe_max_tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..34eb64595a241486691fba4157845877bcd3cbe6 --- /dev/null +++ b/scripts/probe_max_tokens.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +OpenAI 兼容端点的「真实输出上限」探测工具 +========================================== +背景:许多网关/中转会在 /v1/models 里透出 max_output_tokens,但它常常与实际能力不符 +(实测某网关对 deepseek-v4.1-flash 声称 393216,真实只有 32000)。更麻烦的是**静默钳制**: +请求 max_tokens 超过真实上限时不报错,而是内部截断——从 usage 只能看出"输出到某个值就停了"。 + +本工具用「强制长输出 + 阶梯上探」测出真实上限,关键判据区分两种停止: + + finish=length 且 输出 < 请求 → 上游截断,**输出值即真实上限**(可信,直接得出结果) + finish=length 且 输出 = 请求 → 支持至少这么多,继续上探更高阶梯 + finish=stop 且 输出 < 请求 → 模型自己不想写了,**不可作判据**,需强化提示重试 + +用法: + # 测某网关全部以给定前缀开头的模型(默认 cn:) + python probe_max_tokens.py --base http://211.154.25.123:7863/v1 --key sk-xxx + + # 指定模型 / 多个前缀 / 只看计划 + python probe_max_tokens.py --base ... --key ... --models flash,glm-5.3 + python probe_max_tokens.py --base ... --key ... --prefix cn: --prefix global: + python probe_max_tokens.py --base ... --key ... --dry-run + + # 控制成本与时长 + python probe_max_tokens.py --base ... --key ... --tiers 40000,100000 --retries 2 --timeout 900 + +结果: + - 控制台表格:模型 | 声称上限 | 实测上限 | 判据 | 耗时 + - JSONL 落盘(--out,默认 probe-max-tokens.jsonl),**支持 --resume 断点续测** + - --panel-out PATH 额外写面板契约文件(默认关闭;网关面板「模型与档位」的实测列读它): + {"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000, + "verdict":"clamped|at_least|inconclusive","note":"原始判据文本", + "tested_at":"...","source":"probe_max_tokens.py"}}} + 写入为合并语义:未重测的模型保留旧记录;文件放网关 state 文件同目录 + (默认部署即 data/output_probes.json),面板下次查询即展示,网关零重启。 + - --limit N 只测前 N 个模型(先小批量试跑) + +成本提示:一个模型若真有极大上限,上探到高档位会生成数十万 token,耗时长且消耗额度。 + 默认最高阶梯 40000、上限 160000 已足够识别常见的 32K/48K/64K 钳制。 +""" + +import argparse +import json +import os +import re +import socket +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +# 强制长输出的提示:目标值需与被测档位"量级相当"。 +# 实测教训:目标写得过大(数到 999999)模型会判断"不可能完成"而提前收尾(只输出 1~3k); +# 目标设为 12000~20000(能写满但看起来合理)时,模型会持续输出直到被硬截断,4/4 稳定命中。 +# 下面按档位缩放目标值:约 max_tokens/3 个数字(每数字行约 2~3 token,留余量防提前写完)。 +def long_prompt(max_tokens: int) -> str: + target = max(2000, min(200000, max_tokens // 3)) + return ( + f"请从 1 开始,每行输出一个数字,依次递增,一直数到 {target}。" + "严格只输出数字本身,每行一个;不要解释、不要总结、不要省略、不要合并、不要提前结束。" + ) + + +# 强化版(第一次被模型主动收尾时使用,仍保持量级匹配) +def long_prompt_hard(max_tokens: int) -> str: + target = max(2000, min(200000, max_tokens // 3)) + return ( + f"Count from 1 to {target}, one number per line, incrementing by 1. " + "Output digits only, one per line. Do not summarize, do not explain, " + "do not skip numbers, do not stop early — you must reach the target." + ) + + +def fetch_models(base: str, key: str, timeout: int = 20): + """取 /v1/models 列表:[{id, claimed_output, claimed_ctx}, ...]""" + url = base.rstrip("/") + "/models" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {key}", "x-api-key": key, "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + data = json.loads(r.read().decode("utf-8")) + items = data.get("data") if isinstance(data, dict) else data + out = [] + for it in items or []: + if isinstance(it, str): + out.append({"id": it.strip(), "claimed_output": None, "claimed_ctx": None}) + continue + if not isinstance(it, dict): + continue + mid = (it.get("id") or it.get("name") or "").strip() + if not mid: + continue + top = it.get("top_provider") if isinstance(it.get("top_provider"), dict) else {} + out.append({ + "id": mid, + "claimed_output": it.get("max_output_tokens") or it.get("max_completion_tokens") + or top.get("max_completion_tokens"), + "claimed_ctx": it.get("context_length") or it.get("max_input_tokens"), + }) + return out + + +def probe_once(base: str, key: str, model: str, max_tokens: int, prompt: str, timeout: int): + """发一次强制长输出请求,返回 (out_tokens, reasoning_tokens, finish, err)。""" + body = json.dumps({ + "model": model, "stream": True, "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + }).encode() + req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=body, headers={ + "Authorization": f"Bearer {key}", "x-api-key": key, + "Content-Type": "application/json", "Accept": "text/event-stream"}, method="POST") + out = None + reasoning = None + finish = None + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + buf = "" + while True: + piece = r.read(8192) + if not piece: + break + buf += piece.decode("utf-8", "replace") + while "\n" in buf: + line, buf = buf.split("\n", 1) + line = line.strip() + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if not payload or payload == "[DONE]": + continue + try: + obj = json.loads(payload) + except Exception: + continue + u = obj.get("usage") + if isinstance(u, dict): + out = u.get("completion_tokens") + det = u.get("completion_tokens_details") + if isinstance(det, dict): + reasoning = det.get("reasoning_tokens") + for c in obj.get("choices") or []: + if isinstance(c, dict) and c.get("finish_reason"): + finish = c["finish_reason"] + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", "replace")[:300] + return None, None, None, f"HTTP {e.code}: {detail}" + except Exception as e: + return None, None, None, f"{type(e).__name__}: {str(e)[:150]}" + return out, reasoning, finish, None + + +def probe_model(base: str, key: str, model: str, tiers: list[int], + retries: int, timeout: int, quiet: bool, budget: int = 0) -> dict: + """阶梯上探单个模型,返回结果字典。 + budget>0 时为单模型总时间预算(秒):超出即中止并记录"未测完",避免个别大上限模型 + 把整体拖到几十分钟(实测某模型生成 40000 token 需 720s,上探 100000 档要半小时)。""" + result = {"model": model, "measured": None, "verdict": None, "evidence": []} + started = time.time() + all_failed = True + for tier in tiers: + if budget and time.time() - started > budget: + result.update(verdict=f"未测完(超出时间预算 {budget}s;已知至少 >{result.get('max_full') or 0})") + result["elapsed"] = round(time.time() - started, 1) + return result + tier_ok = False + for attempt in range(1, retries + 1): + prompt = long_prompt(tier) if attempt == 1 else long_prompt_hard(tier) + # 单次请求也受剩余预算约束,防止单请求无上限地跑 + req_timeout = timeout + if budget: + remain = budget - (time.time() - started) + if remain <= 5: + break + req_timeout = int(min(timeout, remain)) + t0 = time.time() + out, reasoning, finish, err = probe_once(base, key, model, tier, prompt, req_timeout) + dt = time.time() - t0 + if err: + result["evidence"].append(f"tier={tier} 请求失败: {err[:120]}") + if not quiet: + print(f" [{model}] tier={tier}: 失败 {err[:70]}", flush=True) + break + tier_ok = True + all_failed = False + rs = f" 推理{reasoning}" if reasoning else "" + if not quiet: + print(f" [{model}] tier={tier} #{attempt}: 输出 {out} tok{rs} " + f"finish={finish} {dt:.0f}s", flush=True) + + if finish == "length" and out is not None: + if out < tier: + # 上游截断。注意:若上一档已满额通过(max_full),说明真实上限在 + # [max_full, out] 之间,语义是"≥out 附近被钳",用区间表达更准确。 + prev = result.get("max_full") + if prev and out < prev: + verdict = f"≈{out}(钳制;上一档满额 {prev},取值 {out})" + else: + verdict = "钳制" + result.update(measured=out, verdict=verdict, + evidence=result["evidence"] + [f"要 {tier} 只给 {out}(finish=length)"]) + result["elapsed"] = round(time.time() - started, 1) + return result + result["evidence"].append(f"tier={tier}: 输出满额 {out}(支持≥{tier})") + result["max_full"] = out + break + if finish == "stop": + result["evidence"].append(f"tier={tier} #{attempt}: finish=stop(模型主动收尾,输出 {out})") + if out is not None: + result["max_full"] = max(result.get("max_full") or 0, out) + if attempt == retries: + # 已满额通过的档位仍然有效:结论是"≥max_full",不是"无结论" + if result.get("max_full"): + result.update(measured=None, + verdict=f"≥{result['max_full']}(模型主动收尾,未测到钳制点;" + f"声称 {result.get('claimed_output') or '?'})") + else: + result.update(verdict="模型主动停止(未能测出上限,非钳制)") + result["elapsed"] = round(time.time() - started, 1) + return result + continue + result["evidence"].append(f"tier={tier}: 异常返回 out={out} finish={finish}") + break + + if all_failed: + result.update(measured=None, verdict="全部请求失败(模型/账号不可用,无结论)") + result["elapsed"] = round(time.time() - started, 1) + return result + if result.get("max_full"): + result.update(measured=result["max_full"], + verdict=f"≥{result['max_full']}(最高档位仍满额,未触顶)") + result["elapsed"] = round(time.time() - started, 1) + return result + + +def classify(res: dict): + """把一条探测记录映射为面板契约五态之三:clamped / at_least / inconclusive。 + + verdict 原文是自由中文文本,这里按前缀归类: + 钳制 / ≈N(钳制…) → clamped,measured = 截断值(可信) + ≥N(…)/ 未测完(…至少 >N)→ at_least,measured = 已知下界 + 其余(主动停止/全部失败) → inconclusive + 下界取值链:measured → max_full → 文本里的 ≥N / >N。""" + v = res.get("verdict") or "" + meas = res.get("measured") + if v.startswith("钳制") or v.startswith("≈"): + return "clamped", meas + floor = meas if isinstance(meas, int) else res.get("max_full") + if not floor: + m = re.search(r"[≥>]\s*(\d+)", v) + floor = int(m.group(1)) if m else None + if v.startswith("≥") or v.startswith("未测完"): + return ("at_least", floor) if floor else ("inconclusive", None) + return "inconclusive", None + + +def write_panel_out(path: str, results: list) -> None: + """写面板契约文件(合并语义:未重测的模型保留旧记录,上游改限后重跑即覆盖)。""" + p = Path(path) + contract = {"version": 1, "probes": {}} + if p.is_file(): + try: + old = json.loads(p.read_text(encoding="utf-8")) + if isinstance(old.get("probes"), dict): + contract["probes"].update(old["probes"]) + except Exception: + pass # 旧文件损坏则整体重建,不让坏文件卡住新结果 + for r in results: + verdict, meas = classify(r) + contract["probes"][r["model"]] = { + "claimed": r.get("claimed_output"), + "measured": meas, + "verdict": verdict, + "note": r.get("verdict") or "", + "tested_at": r.get("time") or "", + "source": "probe_max_tokens.py", + } + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps(contract, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(p) + print(f"面板探测数据已写入 {p}(共 {len(contract['probes'])} 条,合并保留未重测模型)") + + +def main() -> None: + ap = argparse.ArgumentParser( + description="探测 OpenAI 兼容端点的真实输出上限(区分静默钳制与模型主动停止)") + ap.add_argument("--base", required=True, help="接口基址,如 http://host:7863/v1") + ap.add_argument("--key", default=None, help="API Key(也可用环境变量 PROBE_API_KEY)") + ap.add_argument("--prefix", action="append", default=None, + help="只测以此前缀开头的模型(可多次;默认 cn:)") + ap.add_argument("--models", default=None, help="按子串匹配模型 id(逗号分隔,优先级高于 --prefix)") + ap.add_argument("--tiers", default="40000,100000", + help="阶梯请求值,逗号分隔(默认 40000,100000;越高越慢越费额度)") + ap.add_argument("--retries", type=int, default=3, help="finish=stop 时的重试次数(默认 3)") + ap.add_argument("--timeout", type=int, default=900, help="单次请求超时秒数(默认 900)") + ap.add_argument("--limit", type=int, default=0, help="只测前 N 个模型(0=全部)") + ap.add_argument("--out", default="probe-max-tokens.jsonl", help="结果 JSONL 路径") + ap.add_argument("--panel-out", default=None, + help="额外写网关面板契约文件(如 data/output_probes.json;默认关闭)。" + "合并写入:未重测的模型保留旧记录") + ap.add_argument("--resume", action="store_true", help="跳过已有结果的模型(断点续测)") + ap.add_argument("--jobs", type=int, default=4, + help="并行探测的模型数(默认 4;瓶颈是墙钟等待,并行可大幅缩短总时长)") + ap.add_argument("--budget", type=int, default=600, + help="单模型时间预算秒数(默认 600;超出记「未测完」,避免大上限模型拖垮整体)") + ap.add_argument("--dry-run", action="store_true", help="只列出将要测的模型与阶梯,不发请求") + ap.add_argument("--quiet", action="store_true", help="不打印每次尝试明细") + args = ap.parse_args() + + key = args.key or os.environ.get("PROBE_API_KEY") or "" + if not key and not args.dry_run: + raise SystemExit("[!] 需要 --key 或环境变量 PROBE_API_KEY") + + tiers = [int(x) for x in args.tiers.split(",") if x.strip()] + tiers.sort() + + socket.setdefaulttimeout(args.timeout) + + print(f"目标: {args.base}") + all_models = fetch_models(args.base, key) + if args.models: + kws = [k.strip().lower() for k in args.models.split(",") if k.strip()] + def _match(mid: str) -> int: + """返回匹配优先级:0=不匹配,3=带前缀的完整 id,2=后缀精确,1=子串。 + 关键词含 ':' 时按完整 id 比对(cn:xxx 只命中 cn:xxx,不含 global:xxx)。""" + low = mid.lower() + for k in kws: + if ":" in k and low == k: + return 3 + for k in kws: + if ":" not in k and (low == k or low.endswith(":" + k)): + return 2 + for k in kws: + if k in low: + return 1 + return 0 + scored = [(m, _match(m["id"])) for m in all_models] + best = max((s for _, s in scored), default=0) + targets = [m for m, s in scored if s == best and s > 0] + else: + prefixes = args.prefix or ["cn:"] + targets = [m for m in all_models if any(m["id"].startswith(p) for p in prefixes)] + if args.limit: + targets = targets[:args.limit] + if not targets: + raise SystemExit(f"[!] 没有匹配的模型(共 {len(all_models)} 个可测)") + + done = {} + out_path = Path(args.out) + if args.resume and out_path.is_file(): + for line in out_path.read_text(encoding="utf-8").splitlines(): + try: + rec = json.loads(line) + done[rec["model"]] = rec + except Exception: + pass + + print(f"待测模型 {len(targets)} 个 | 阶梯 {tiers} | 声称上限来自 /v1/models") + if args.dry_run: + print("\n[dry-run] 计划:") + for m in targets: + skip = " (已有结果,将跳过)" if m["id"] in done else "" + print(f" {m['id']:44} 声称 {str(m['claimed_output'] or '-'):>8}{skip}") + return + + print() + results = [] + todo = [m for m in targets if m["id"] not in done] + for m in targets: + if m["id"] in done: + results.append(done[m["id"]]) + + # 并行探测:墙钟时间是瓶颈(单模型生成 4 万 token 需数分钟),并发能有效压缩总时长。 + # 结果仍按模型去重,落盘用锁保护。 + import concurrent.futures as cf + import threading + write_lock = threading.Lock() + finished = {"n": 0} + + def _work(m): + res = probe_model(args.base, key, m["id"], tiers, args.retries, args.timeout, + args.quiet, budget=args.budget) + res["claimed_output"] = m["claimed_output"] + res["claimed_ctx"] = m["claimed_ctx"] + res["time"] = time.strftime("%Y-%m-%d %H:%M:%S") + with write_lock: + finished["n"] += 1 + n = finished["n"] + with out_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(res, ensure_ascii=False) + "\n") + measured = res["measured"] if res["measured"] is not None else "-" + print(f"[{n}/{len(todo)}] {m['id']}(声称 {m['claimed_output'] or '-'})" + f" → 实测 {measured} | {res['verdict']} | {res.get('elapsed','-')}s", flush=True) + return res + + if todo: + print(f"开始探测 {len(todo)} 个模型(并行 {args.jobs},单模型预算 {args.budget}s)...\n") + with cf.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex: + results.extend(ex.map(_work, todo)) + + # 汇总表 + print("=" * 96) + print(f"{'模型':44} {'声称':>9} {'实测':>9} {'判据':<28} {'耗时':>6}") + print("-" * 96) + for r in results: + claim = r.get("claimed_output") or "-" + meas = r.get("measured") if r.get("measured") is not None else "-" + verdict = (r.get("verdict") or "")[:26] + print(f"{r['model']:44} {str(claim):>9} {str(meas):>9} {verdict:<28} {str(r.get('elapsed','-')):>6}") + print("=" * 96) + + mism = [r for r in results if r.get("measured") and r.get("claimed_output") + and int(r["measured"]) < int(r["claimed_output"])] + if mism: + print(f"\n⚠ {len(mism)} 个模型的声称上限高于实测(静默钳制):") + for r in mism: + print(f" {r['model']}: 声称 {r['claimed_output']} → 实际 {r['measured']}") + print(f"\n结果已写入 {out_path}(--resume 可跳过已测模型继续)") + if args.panel_out: + write_panel_out(args.panel_out, results) + + +if __name__ == "__main__": + main() diff --git a/scripts/task_chat5.py b/scripts/task_chat5.py new file mode 100644 index 0000000000000000000000000000000000000000..2672e3a340eef18d2d6a48de1183a38491ffdf6d --- /dev/null +++ b/scripts/task_chat5.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""一次性任务脚本:chat_5(和 AI 聊天 5 次,+100 积分 +5 能量). + +通过 {billing}/v2/report 上报 chat_request_send 事件完成进度累计。 +照抄 report.go 的 chatRequestEvent 完整形状(含 userId,缺则静默丢弃)。 +默认 dry-run,--yes 才发真实上报;默认补到 5 次,支持 --count N。 + +用法 + python3 task_chat5.py # dry-run + python3 task_chat5.py --yes # 补齐到 5 次 + python3 task_chat5.py --yes --count 2 # 只补 2 次 +""" +import sys, os, argparse +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "chat_5" +TARGET = 5 + + +def main(): + ap = argparse.ArgumentParser(description="chat_5 任务:上报 N 次对话完成 5/5") + ap.add_argument("account", help="uid 前缀,或 ALL") + ap.add_argument("--yes", action="store_true", help="确认执行写操作") + ap.add_argument("--count", type=int, default=0, + help="本次上报条数(默认自动补到 5 次)") + ap.add_argument("--gap", type=float, default=1.05) + a = ap.parse_args() + + prefixes = [] + if a.account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [a.account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + prog = (st.get("progress") or {}) + cur = prog.get("current", 0) + target = prog.get("target", TARGET) + if ast == "claimed" or cur >= target: + print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") + continue + need = max(0, target - cur) + if a.count > 0: + need = min(need, a.count) + if need <= 0: + print(" [skip] 无需上报") + continue + if not a.yes: + print(f" [dry-run] 将上报 {need} 条 chat_request_send " + f"(当前 {cur}/{target},加 --yes 生效)") + continue + results = tc.report_activity(c, count=need, gap=a.gap) + # 回读确认 + st2 = tc.task_status(c, TASK_CODE) + prog2 = (st2.get("progress") or {}) if st2 else {} + print(f" report x{need} -> {results}") + print(f" 回读 {TASK_CODE}: {prog2.get('current', 0)}/{prog2.get('target', target)} " + f"accept_status={st2.get('accept_status') if st2 else '?'}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/task_common.py b/scripts/task_common.py new file mode 100644 index 0000000000000000000000000000000000000000..63f12b41e12d1cf4e09a36ac55c394391c6470f6 --- /dev/null +++ b/scripts/task_common.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""一次性积分任务脚本的共享函数库. + +与 probe_active.py 同风格:从 auths/ 读账号凭证,封装 growth 域 / report 域 +请求,供 task_*.py 复用。全部默认 dry-run(写动作由调用脚本显式 --yes 放行)。 + +端点权威来源(Go 代码实测 + 本次实测确认): + - chat 域(copilot.tencent.com):growth / tasks / buddy / streak / chat/completions + - billing 域(www.codebuddy.cn):/v2/report + - accept : POST /v2/activity/growth/tasks/accept {"task_codes":[code]} + - claim : POST /v2/activity/growth/tasks/reward/claim {"task_code":code} +""" +import json, os, time, glob, urllib.request, urllib.error + +AUTHS = "/root/workbuddy2api/auths" +CHAT_BASE = "https://copilot.tencent.com" # growth / tasks / buddy / streak / chat +BILL_BASE = "https://www.codebuddy.cn" # report / billing + +# growth 域常量(travel.go / report.go 与本次实测对齐) +PATH_LIST_TASKS = "/v2/activity/growth/tasks" +PATH_ACCEPT_TASKS = "/v2/activity/growth/tasks/accept" +PATH_CLAIM_REWARD = "/v2/activity/growth/tasks/reward/claim" +PATH_BUDDY_FIRST = "/activity/growth/buddy/first" +PATH_BUDDY_AGREEMENT = "/activity/growth/buddy/agreement" +PATH_STREAK = "/activity/growth/streak" +PATH_REPORT = "/v2/report" +PATH_CHAT = "/v2/chat/completions" + +CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2" + + +def load_auth(uid_or_file: str) -> dict: + """从 auths/ 加载账号凭证,uid_or_file 为 uid 前缀或 auths 文件名。 + + 返回 {token, uid, domain, nick, file} 五元组。 + """ + if os.path.sep in uid_or_file or uid_or_file.endswith(".json"): + p = uid_or_file + if not os.path.isabs(p): + p = os.path.join(AUTHS, p) + else: + pre = uid_or_file + hits = glob.glob(os.path.join(AUTHS, f"workbuddy-{pre}*.json")) + if not hits: + raise SystemExit(f"no auth for {pre}") + p = hits[0] + # encoding 必须显式指定:Windows 上 open() 默认用 locale 代码页(中文系统为 + # GBK),而 auth 文件是 UTF-8,非 ASCII 昵称会 UnicodeDecodeError 中断全部任务。 + d = json.load(open(p, encoding="utf-8")) + a, acc = d["auth"], d["account"] + return {"token": a["accessToken"], "domain": a.get("domain") or "", + "uid": acc["uid"], "nick": acc.get("nickname", ""), + "file": os.path.basename(p)} + + +def chat_base(auth: dict) -> str: + return auth.get("chat_base") or CHAT_BASE + + +def billing_base(auth: dict) -> str: + return auth.get("billing_base") or BILL_BASE + + +def _headers(auth: dict) -> dict: + hdr = {"Authorization": "Bearer " + auth["token"], + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": CLIENT_UA, + "Origin": "https://www.codebuddy.cn", + "Referer": "https://www.codebuddy.cn/"} + if auth.get("uid"): + hdr["X-User-Id"] = auth["uid"] + if auth.get("domain"): + hdr["X-Domain"] = auth["domain"] + return hdr + + +def _request(auth, method, base, path, body=None, headers=None, timeout=30): + url = path if path.startswith("http") else base + path + hdr = _headers(auth) + if headers: + hdr.update(headers) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=hdr, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + t = e.read().decode("utf-8", "replace") + try: + return e.code, json.loads(t) + except Exception: + return e.code, {"raw": t[:300]} + except Exception as e: + return -1, {"err": repr(e)} + + +def do_get(auth, base, path, headers=None) -> tuple: + """GET 读请求,返回 (status, dict)。""" + return _request(auth, "GET", base, path, None, headers) + + +def do_post(auth, base, path, body, headers=None) -> tuple: + """POST 写请求,返回 (status, dict)。body 为 dict。""" + return _request(auth, "POST", base, path, body, headers) + + +def list_tasks(auth) -> list: + """GET /v2/activity/growth/tasks 全量任务列表(元素为原始 dict)。""" + st, d = do_get(auth, chat_base(auth), PATH_LIST_TASKS) + if st != 200: + raise RuntimeError(f"list_tasks http={st}") + tasks = (d.get("data", {}) or {}).get("tasks") or [] + return tasks + + +def task_status(auth, task_code) -> dict | None: + """查单个任务当前状态;找不到返回 None。""" + for t in list_tasks(auth): + if t.get("task_code") == task_code: + return t + return None + + +def accept_tasks(auth, task_codes) -> tuple: + """POST accept 任务(not_accepted → accepted)。返回 (status, resp)。""" + return do_post(auth, chat_base(auth), PATH_ACCEPT_TASKS, + {"task_codes": task_codes}) + + +def claim_reward(auth, task_code) -> tuple: + """POST claim 领取奖励(任务已 complete 后可领)。重复领返回业务错误,安全。""" + return do_post(auth, chat_base(auth), PATH_CLAIM_REWARD, + {"task_code": task_code}) + + +def get_streak(auth) -> int: + """GET /activity/growth/streak 连登天数(只读 oracle)。失败返回 -1 记日志。""" + st, d = do_get(auth, chat_base(auth), PATH_STREAK) + if st != 200: + return -1 + return (d.get("data", {}).get("streak", {}) or {}).get("days", 0) + + +def chat_event(auth, conversation_id=None, model_id="deepseek-v4-flash", + model_name="DeepSeek V4 Flash", mode="craft"): + """客户端 chat_request_send 事件完整形状(照抄 report.go / probe_active.py)。 + + 必须带 userId(=账号 uid),缺失则服务端 200 但静默丢弃。 + model_id/name 可换(如 GLM-5.2),供 model_chat 对齐实际模型。 + """ + now = int(time.time() * 1000) + cid = conversation_id or f"task-{now}" + return {"eventCode": "chat_request_send", "timestamp": now, "reportDelay": 0, + "mode": mode, "conversationId": cid, "requestId": cid, + "inputLength": 12, "requestModelId": model_id, + "requestModelName": model_name, "isPlan": False, + "isAutoExecuteTerminal": False, "isAutoModify": False, + "codebaseEnable": False, "maxToken": 0, "maxSteps": 0, "temperature": 0, + "maxRetries": 0, "mentionContexts": [], "knowledgeId": [], + "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, + "command": "", "expertId": "", "recommendId": "", "skillId": "", + "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, + "traceId": "", "rootRequestId": cid, "parentConversationId": cid, + "agentName": "default", "agentType": "conversation", "userId": auth["uid"]} + + +def report_activity(auth, count=1, gap=1.05, model_id="deepseek-v4-flash", + model_name="DeepSeek V4 Flash", mode="craft") -> list: + """向 {billing}/v2/report 上报 count 条 chat_request_send。 + + 每次间隔 >= gap 秒(默认 1.05,匹配 probe_active.py 的同接口限速口径)。 + 返回 [(status, code), ...] 汇总。 + """ + out = [] + for i in range(count): + ev = chat_event(auth, model_id=model_id, model_name=model_name, mode=mode) + st, r = do_post(auth, billing_base(auth), PATH_REPORT, [ev]) + out.append((st, r.get("code") if isinstance(r, dict) else None)) + if i < count - 1: + time.sleep(gap) + return out + + +def chat_completion(auth, model_id="glm-5.2", prompt="hi", max_tokens=32, + timeout=60) -> tuple: + """POST {chat}/v2/chat/completions 真实对话一次(stream:true)。 + + 服务端强制流式(payload.go 同款口径),这里逐行读 SSE 直到 done。 + 返回 (status, first_content)。用于 Model_chat_GLM5.2 的“真实对话一次”。 + """ + body = {"model": model_id, "messages": [{"role": "user", "content": prompt}], + "stream": True, "max_tokens": max_tokens} + hdr = {"Accept": "text/event-stream"} # SSE + url = chat_base(auth) + PATH_CHAT + req_headers = _headers(auth) + req_headers.update(hdr) + req = urllib.request.Request(url, data=json.dumps(body).encode(), + headers=req_headers, method="POST") + first = "" + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + status = r.status + for raw in r: + line = raw.decode("utf-8", "replace") + if line.startswith("data: "): + payload = line[6:].strip() + if payload in ("[DONE]", ""): + continue + try: + obj = json.loads(payload) + delta = (obj.get("choices") or [{}])[0].get("delta") or {} + content = delta.get("content") or "" + if content and not first: + first = content + except Exception: + pass + return status, first + except urllib.error.HTTPError as e: + t = e.read().decode("utf-8", "replace") + return e.code, t[:200] + except Exception as e: + return -1, repr(e)[:200] \ No newline at end of file diff --git a/scripts/task_first_buddy.py b/scripts/task_first_buddy.py new file mode 100644 index 0000000000000000000000000000000000000000..4be77016ceb4bdb0ad57e48e3443aa1fccf5bfcc --- /dev/null +++ b/scripts/task_first_buddy.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""一次性任务脚本:first_buddy(领取一只 Buddy,+300 积分 +8 能量). + +链路(照抄 probe_active.py 的 unlock 模式,实测可行): + report(1 条 chat_request_send) -> buddy/agreement -> buddy/first +判据是服务端行为事件,不是 accept 状态。 + +用法 + python3 task_first_buddy.py # dry-run + python3 task_first_buddy.py --yes # 真正执行 + python3 task_first_buddy.py ALL --yes # 全池(跳过已 claimed) +""" +import sys, os, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "first_buddy" + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + account = sys.argv[1] + yes = "--yes" in sys.argv + gap = 1.05 + + prefixes = [] + if account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + if ast == "claimed": + print(f" [skip] {TASK_CODE} 已 claimed,无需处理") + continue + if not yes: + print(f" [dry-run] 将执行 report + agreement + buddy/first (加 --yes 生效;当前 accept_status={ast})") + continue + + # 写链路 + st_code, code = tc.report_activity(c, count=1, gap=gap)[0] + print(f" report -> {st_code} code={code}") + time.sleep(2) + st2, ag = tc.do_post(c, tc.chat_base(c), tc.PATH_BUDDY_AGREEMENT, {"agree": True}) + print(f" agreement -> {st2} code={ag.get('code')}") + st3, bf = tc.do_post(c, tc.chat_base(c), tc.PATH_BUDDY_FIRST, {}) + d = bf.get("data") or {} + print(f" buddy/first -> {st3} {bf.get('msg')} credit={d.get('credit')} energy={d.get('energy')}") + time.sleep(gap) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/task_model_chat.py b/scripts/task_model_chat.py new file mode 100644 index 0000000000000000000000000000000000000000..48a0e1acaef0a9aa48a78943195d2dc55b1a2b84 --- /dev/null +++ b/scripts/task_model_chat.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""一次性任务脚本:Model_chat_GLM5.2(体验「GLM-5.2」模型对话 1 次,+100 积分 +5 能量). + +步骤(实测确认): + 1. accept:POST /v2/activity/growth/tasks/accept {"task_codes":["Model_chat_GLM5.2"]} + (不 accept 也会被行为事件点亮,但进度状态更规范,先 accept) + 2. 真实对话:POST {chat}/v2/chat/completions {model:"glm-5.2", stream:true} + —— 模型列表实测存在 glm-5.2,SSE 200 正常回包 + 3. 上报一条 chat_request_send(requestModelId=glm-5.2)触发 progress + —— 若任务判据靠事件上报,这条即可点亮;真实对话是「使用 GLM-5.2 成功对话」的最直接证据 + 4. 回读 progress;current>=target 后提示 claim + +用法 + python3 task_model_chat.py # dry-run + python3 task_model_chat.py --yes # accept + 真实对话 + 上报 +""" +import sys, os, time, argparse +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "Model_chat_GLM5.2" +MODEL_ID = "glm-5.2" +MODEL_NAME = "GLM-5.2" +TARGET = 1 + + +def main(): + ap = argparse.ArgumentParser(description="Model_chat_GLM5.2 任务") + ap.add_argument("account", help="uid 前缀,或 ALL") + ap.add_argument("--yes", action="store_true", help="确认执行写操作") + ap.add_argument("--prompt", default="hi,请回复一句话", help="发给 GLM-5.2 的提示词") + a = ap.parse_args() + + prefixes = [] + if a.account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [a.account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + prog = (st.get("progress") or {}) + cur = prog.get("current", 0) + target = prog.get("target", TARGET) + if ast == "claimed" or cur >= target: + print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") + continue + if not a.yes: + print(f" [dry-run] 将 accept + GLM-5.2 真实对话一次 + 上报 chat_request_send " + f"(当前 {cur}/{target},加 --yes 生效)") + continue + + # 1. accept + st_a, r_a = tc.accept_tasks(c, [TASK_CODE]) + print(f" accept -> {st_a} {r_a.get('msg') if isinstance(r_a, dict) else r_a}") + time.sleep(1.05) + + # 2. 真实对话(GLM-5.2) + st_chat, first = tc.chat_completion(c, model_id=MODEL_ID, prompt=a.prompt) + print(f" chat glm-5.2 -> {st_chat} {first[:60]!r}") + + # 3. 上报一条 chat_request_send(模型字段对齐 glm-5.2) + results = tc.report_activity(c, count=1, gap=1.05, + model_id=MODEL_ID, model_name=MODEL_NAME) + print(f" report x1 -> {results}") + time.sleep(1.0) + + # 4. 回读 + st2 = tc.task_status(c, TASK_CODE) + prog2 = (st2.get("progress") or {}) if st2 else {} + cur2 = prog2.get("current", 0) + ast2 = st2.get("accept_status") if st2 else "?" + print(f" 回读 {TASK_CODE}: {cur2}/{prog2.get('target', target)} accept_status={ast2}") + if ast2 not in ("claimed",) and cur2 >= target: + print(" → 任务已满足,可手动或后续调用 claim_reward 领奖") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/task_richmeow.py b/scripts/task_richmeow.py new file mode 100644 index 0000000000000000000000000000000000000000..aee44d3fe804ca74bbbcd3bbd2ee3a7de384c805 --- /dev/null +++ b/scripts/task_richmeow.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""一次性任务脚本:RichMeow_Chat(桌面端对话 1 次,+100 积分 +5 能量 +限定 Buddy 盲盒). + +任务书:先查 eventCode 是否与 chat_request_send 不同(在 probe_active.py / Go 代码 +搜 RichMeow 相关——未找到专属 eventCode,REPORT §3.2 也标注「桌面端专属(未破)」)。 + +实测结论(REPORT §3.2,2026-09-11): + - accept 后 3 个账号多次 chat_request_send 上报均不计数(progress 0/1 不动)。 + - 怀疑是桌面端变体的另一套上报通道:queuePendingGrowthTelemetry → 把 growthEvent + 挂在 chat 请求的 extra_vars 里随请求上行(REPORT §1.1),而非 /v2/report。 + - 本脚本默认上线尝试 1 次 chat_request_send(成本可忽略);若仍 0/1 → 判为不可脚本化。 + +用法 + python3 task_richmeow.py # dry-run + python3 task_richmeow.py --yes # 上报 1 次并回读 +""" +import sys, os, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "RichMeow_Chat" +TARGET = 1 + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + account = sys.argv[1] + yes = "--yes" in sys.argv + + prefixes = [] + if account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + prog = (st.get("progress") or {}) + cur = prog.get("current", 0) + target = prog.get("target", TARGET) + if ast == "claimed" or cur >= target: + print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") + continue + # not_accepted 先 accept + if ast == "not_accepted" and yes: + st_a, r_a = tc.accept_tasks(c, [TASK_CODE]) + print(f" accept -> {st_a} {r_a.get('msg') if isinstance(r_a, dict) else r_a}") + time.sleep(1.05) + elif ast == "not_accepted": + print(f" [dry-run] 先 accept(当前 not_accepted),再上报 1 条") + if not yes: + print(f" [dry-run] 将上报 1 条 chat_request_send(当前 {cur}/{target},加 --yes 生效)") + continue + results = tc.report_activity(c, count=1, gap=1.05) + print(f" report x1 -> {results}") + time.sleep(1.0) + st2 = tc.task_status(c, TASK_CODE) + prog2 = (st2.get("progress") or {}) if st2 else {} + print(f" 回读 {TASK_CODE}: {prog2.get('current', 0)}/{prog2.get('target', target)} " + f"accept_status={st2.get('accept_status') if st2 else '?'}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/signin.sh b/signin.sh new file mode 100644 index 0000000000000000000000000000000000000000..d469491c500c18b1cdae32bfcd593d0add4bcc99 --- /dev/null +++ b/signin.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# 批量签到脚本:遍历 auths/ 下所有 workbuddy-*.json 账号 +# 用法: ./signin.sh [auths_dir] +set -e +cd "$(dirname "$0")" + +BIN=./signin_bin +if [ ! -x "$BIN" ]; then + echo "build signin_bin ..." + go build -o "$BIN" ./cmd/signin +fi + +exec "$BIN" "${1:-auths}" diff --git a/src/api/Response_example.txt b/src/api/Response_example.txt deleted file mode 100644 index 456ecccec35ede0c18172ce80a9f403aa62ac3ad..0000000000000000000000000000000000000000 --- a/src/api/Response_example.txt +++ /dev/null @@ -1,210 +0,0 @@ -================================================================================ -GeminiCli API 测试 -================================================================================ - -================================================================================ -【测试1】流式请求 (stream_request with native=False) -================================================================================ -请求体: { - "model": "gemini-2.5-flash", - "request": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello, tell me a joke in one sentence." - } - ] - } - ] - } -} - -流式响应数据 (每个chunk): --------------------------------------------------------------------------------- -[2026-01-10 09:55:29] [INFO] SQLite storage initialized at ./creds\credentials.db -[2026-01-10 09:55:29] [INFO] Using SQLite storage backend -[2026-01-10 09:55:31] [INFO] Token刷新成 功并已保存: my-project-9-481103-1765596755.json (mode=geminicli) -[2026-01-10 09:55:34] [INFO] [DB] 准备commit,总更新行数=1 -[2026-01-10 09:55:34] [INFO] [DB] commit 完成 -[2026-01-10 09:55:34] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1 - -Chunk #1: - 类型: str - 长度: 626 - 内容预览: 'data: {"response": {"candidates": [{"content": {"role": "model","parts": [{"text": "Why did the scarecrow win an award? Because he was outstanding in his field."}]},"finishReason": "STOP"}],"usageMeta' - 解析后的JSON: { - "response": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Why did the scarecrow win an award? Because he was outstanding in his field." - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 17, - "totalTokenCount": 51, - "trafficType": "PROVISIONED_THROUGHPUT", - "promptTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 10 - } - ], - "candidatesTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 17 - } - ], - "thoughtsTokenCount": 24 - }, - "modelVersion": "gemini-2.5-flash", - "createTime": "2026-01-10T01:55:29.168589Z", - "responseId": "kbFhaY2lCr-ZseMPqMiDmAU" - }, - "traceId": "55650653afd3c738" -} - -Chunk #2: - 类型: str - 长度: 0 - 内容预览: '' -E:\projects\gcli2api\src\api\geminicli.py:491: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited - async for chunk in stream_request(body=test_body, native=False): -RuntimeWarning: Enable tracemalloc to get the object allocation traceback - -总共收到 2 个chunk - -================================================================================ -【测试2】非流式请求 (non_stream_request) -================================================================================ -请求体: { - "model": "gemini-2.5-flash", - "request": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello, tell me a joke in one sentence." - } - ] - } - ] - } -} - -[2026-01-10 09:55:35] [INFO] Token刷新成 功并已保存: gen-lang-client-0194852792-1767296759.json (mode=geminicli) -[2026-01-10 09:55:38] [INFO] [DB] 准备commit,总更新行数=1 -[2026-01-10 09:55:38] [INFO] [DB] commit 完成 -[2026-01-10 09:55:38] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1 -E:\projects\gcli2api\src\api\geminicli.py:530: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited - response = await non_stream_request(body=test_body) -RuntimeWarning: Enable tracemalloc to get the object allocation traceback -非流式响应数据: --------------------------------------------------------------------------------- -状态码: 200 -Content-Type: application/json; charset=UTF-8 - -响应头: {'server': 'openresty', 'date': 'Sat, 10 Jan 2026 01:55:34 GMT', 'content-type': 'application/json; charset=UTF-8', 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'x-cloudaicompanion-trace-id': 'bf3a5eb6636774d2', 'vary': 'Origin, X-Origin, Referer', 'content-encoding': 'gzip', 'x-xss-protection': '0', 'x-frame-options': 'SAMEORIGIN', 'x-content-type-options': 'nosniff', 'server-timing': 'gfet4t7; dur=1377', 'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'access-control-allow-headers': 'Content-Type, Authorization, X-Requested-With', 'cache-control': 'no-cache', 'content-length': '969'} - -响应内容 (原始): -{ - "response": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Why did the scarecrow win an award? Because he was outstanding in his field!" - } - ] - }, - "finishReason": "STOP", - "avgLogprobs": -0.54438119776108684 - } - ], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 17, - "totalTokenCount": 47, - "trafficType": "PROVISIONED_THROUGHPUT", - "promptTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 10 - } - ], - "candidatesTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 17 - } - ], - "thoughtsTokenCount": 20 - }, - "modelVersion": "gemini-2.5-flash", - "createTime": "2026-01-10T01:55:33.450396Z", - "responseId": "lbFhady-G7yi694PmLOP4As" - }, - "traceId": "bf3a5eb6636774d2" -} - - -响应内容 (格式化JSON): -{ - "response": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Why did the scarecrow win an award? Because he was outstanding in his field!" - } - ] - }, - "finishReason": "STOP", - "avgLogprobs": -0.5443811977610868 - } - ], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 17, - "totalTokenCount": 47, - "trafficType": "PROVISIONED_THROUGHPUT", - "promptTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 10 - } - ], - "candidatesTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 17 - } - ], - "thoughtsTokenCount": 20 - }, - "modelVersion": "gemini-2.5-flash", - "createTime": "2026-01-10T01:55:33.450396Z", - "responseId": "lbFhady-G7yi694PmLOP4As" - }, - "traceId": "bf3a5eb6636774d2" -} - -================================================================================ -测试完成 -================================================================================ \ No newline at end of file diff --git a/src/api/antigravity.py b/src/api/antigravity.py deleted file mode 100644 index d59d4af5aba4a6129e1be6f8b78c7593ce5420d2..0000000000000000000000000000000000000000 --- a/src/api/antigravity.py +++ /dev/null @@ -1,1009 +0,0 @@ -""" -Antigravity API Client - Handles communication with Google's Antigravity API -处理与 Google Antigravity API 的通信 -""" - -import asyncio -import hashlib -import json -import uuid -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Callable, Tuple - -from fastapi import Response -from config import ( - get_antigravity_api_url, - get_antigravity_stream2nostream, - get_antigravity_switch_credential_enabled, - get_auto_ban_error_codes, -) -from log import log - -from src.credential_manager import credential_manager -from src.httpx_client import stream_post_async, post_async -from src.models import Model, model_to_dict -from src.utils import ANTIGRAVITY_USER_AGENT - -# 导入共同的基础功能 -from src.api.utils import ( - handle_error_with_retry, - check_should_auto_ban, - get_retry_config, - record_api_call_success, - record_api_call_error, - parse_and_log_cooldown, - collect_streaming_response, -) - -# ==================== 全局凭证管理器 ==================== - -# 使用全局单例 credential_manager,自动初始化 - - -# ==================== 辅助函数 ==================== - -def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[str, str]: - """ - 构建 Antigravity API 请求头 - - Args: - access_token: 访问令牌 - model_name: 模型名称,用于判断 request_type - - Returns: - 请求头字典 - """ - headers = { - 'User-Agent': ANTIGRAVITY_USER_AGENT, - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json', - 'Accept-Encoding': 'gzip', - 'requestId': f"req-{uuid.uuid4()}" - } - - # 根据模型名称判断 request_type - if model_name: - # 先判断是否是图片模型 - if "image" in model_name.lower(): - request_type = "image_gen" - headers['requestType'] = request_type - else: - request_type = "agent" - headers['requestType'] = request_type - - return headers - - -def _generate_stable_session_id(request_payload: Dict[str, Any]) -> str: - contents = request_payload.get("contents") - if isinstance(contents, list): - for content in contents: - if not isinstance(content, dict) or content.get("role") != "user": - continue - parts = content.get("parts") - if not isinstance(parts, list) or not parts: - continue - first_part = parts[0] - if not isinstance(first_part, dict): - continue - text = first_part.get("text") - if isinstance(text, str) and text: - digest = hashlib.sha256(text.encode("utf-8")).digest() - value = int.from_bytes(digest[:8], "big") & 0x7FFFFFFFFFFFFFFF - return f"-{value}" - - value = uuid.uuid4().int % 9_000_000_000_000_000_000 - return f"-{value}" - - -def _ensure_antigravity_session_id(payload: Dict[str, Any], model_name: str) -> None: - if "image" in (model_name or "").lower(): - return - - request_payload = payload.get("request") - if not isinstance(request_payload, dict): - return - - if request_payload.get("sessionId"): - return - - request_payload["sessionId"] = _generate_stable_session_id(request_payload) - - -def _empty_object_schema() -> Dict[str, Any]: - return {"type": "object", "properties": {}} - - -def _prepare_antigravity_tool(tool: Any, is_claude: bool) -> Any: - if not isinstance(tool, dict): - return tool - - normalized_tool = tool.copy() - - custom_tool = normalized_tool.get("custom") - if isinstance(custom_tool, dict): - normalized_custom = custom_tool.copy() - if "input_schema" not in normalized_custom: - schema = ( - normalized_custom.pop("parametersJsonSchema", None) - or normalized_custom.pop("parameters_json_schema", None) - or normalized_custom.get("parameters") - ) - normalized_custom["input_schema"] = schema or _empty_object_schema() - normalized_tool["custom"] = normalized_custom - - declarations_key = None - declarations = None - if isinstance(normalized_tool.get("functionDeclarations"), list): - declarations_key = "functionDeclarations" - declarations = normalized_tool.get("functionDeclarations") - elif isinstance(normalized_tool.get("function_declarations"), list): - declarations_key = "function_declarations" - declarations = normalized_tool.get("function_declarations") - - if isinstance(declarations, list) and declarations_key: - normalized_declarations = [] - for declaration in declarations: - if not isinstance(declaration, dict): - normalized_declarations.append(declaration) - continue - - normalized_declaration = declaration.copy() - schema = None - if "parametersJsonSchema" in normalized_declaration: - schema = normalized_declaration.pop("parametersJsonSchema") - elif "parameters_json_schema" in normalized_declaration: - schema = normalized_declaration.pop("parameters_json_schema") - elif "parameters" in normalized_declaration: - schema = normalized_declaration.get("parameters") - - if schema not in (None, {}, []): - normalized_declaration["parameters"] = schema - elif is_claude or "parameters" not in normalized_declaration: - normalized_declaration["parameters"] = _empty_object_schema() - - normalized_declarations.append(normalized_declaration) - - normalized_tool[declarations_key] = normalized_declarations - - return normalized_tool - - -def _prepare_antigravity_payload(payload: Dict[str, Any], model_name: str) -> Dict[str, Any]: - """Match Antigravity's upstream payload quirks before the HTTP request.""" - payload["userAgent"] = "antigravity" - if "image" in (model_name or "").lower(): - payload["requestType"] = "image_gen" - payload.setdefault( - "requestId", - f"image_gen/{int(datetime.now(timezone.utc).timestamp() * 1000)}/{uuid.uuid4()}/12", - ) - else: - payload["requestType"] = "agent" - payload.setdefault("requestId", f"agent-{uuid.uuid4()}") - - request_payload = payload.get("request") - if not isinstance(request_payload, dict): - return payload - - _ensure_antigravity_session_id(payload, model_name) - request_payload.pop("safetySettings", None) - - is_claude = "claude" in (model_name or "").lower() - tools = request_payload.get("tools") - if isinstance(tools, list): - request_payload["tools"] = [ - _prepare_antigravity_tool(tool, is_claude) - for tool in tools - ] - - if is_claude: - tool_config = request_payload.get("toolConfig") - if not isinstance(tool_config, dict): - tool_config = {} - request_payload["toolConfig"] = tool_config - - function_config = tool_config.get("functionCallingConfig") - if not isinstance(function_config, dict): - function_config = {} - tool_config["functionCallingConfig"] = function_config - - function_config["mode"] = "VALIDATED" - - return payload - - -def _is_retryable_status(status_code: int, disable_error_codes: List[int]) -> bool: - """统一判断是否属于可重试状态码。""" - return status_code in (429, 503) or status_code in disable_error_codes - - -async def _switch_credential_for_retry( - *, - next_cred_task: Optional[asyncio.Task], - retry_interval: float, - refresh_credential_fast: Callable[[], Any], - apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool], - log_prefix: str, -) -> Tuple[bool, Optional[asyncio.Task]]: - """优先使用预热凭证,失败后退回同步刷新。""" - if next_cred_task is not None: - try: - cred_result = await next_cred_task - next_cred_task = None - if cred_result and apply_cred_result(cred_result): - await asyncio.sleep(retry_interval) - return True, next_cred_task - except Exception as e: - log.warning(f"{log_prefix} 预热凭证任务失败: {e}") - next_cred_task = None - - await asyncio.sleep(retry_interval) - if await refresh_credential_fast(): - return True, next_cred_task - - return False, next_cred_task - - -# ==================== 新的流式和非流式请求函数 ==================== - -async def stream_request( - body: Dict[str, Any], - native: bool = False, - headers: Optional[Dict[str, str]] = None, -): - """ - 流式请求函数 - - Args: - body: 请求体 - native: 是否返回原生bytes流,False则返回str流 - headers: 额外的请求头 - - Yields: - Response对象(错误时)或 bytes流/str流(成功时) - """ - model_name = body.get("model", "") - switch_credential_enabled = await get_antigravity_switch_credential_enabled() - - # 1. 获取有效凭证 - cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name - ) - - if not cred_result: - # 如果返回值是None,直接返回错误500 - log.error("[ANTIGRAVITY STREAM] 当前无可用凭证") - yield Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - return - - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - - if not access_token: - log.error(f"[ANTIGRAVITY STREAM] No access token in credential: {current_file}") - yield Response( - content=json.dumps({"error": "凭证中没有访问令牌"}), - status_code=500, - media_type="application/json" - ) - return - - # 2. 构建URL和请求头 - antigravity_url = await get_antigravity_api_url() - target_url = f"{antigravity_url}/v1internal:streamGenerateContent?alt=sse" - - auth_headers = build_antigravity_headers(access_token, model_name) - - # 合并自定义headers - if headers: - auth_headers.update(headers) - - # 构建包含project的payload - final_payload = { - "model": body.get("model"), - "project": project_id, - "request": body.get("request", {}), - } - _prepare_antigravity_payload(final_payload, model_name) - - # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes - def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: - if cred_data.get("enable_credit") is True: - final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"] - else: - final_payload.pop("enabledCreditTypes", None) - - apply_enabled_credit_types(credential_data) - - # 3. 调用stream_post_async进行请求 - retry_config = await get_retry_config() - max_retries = retry_config["max_retries"] - retry_interval = retry_config["retry_interval"] - - DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码 - last_error_response = None # 记录最后一次的错误响应 - next_cred_task = None # 预热的下一个凭证任务 - - # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求) - async def refresh_credential_fast(): - nonlocal current_file, access_token, auth_headers, project_id, final_payload - cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name - ) - if not cred_result: - return None - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - if not access_token: - return None - # 只更新token和project_id,不重建整个headers和payload - auth_headers["Authorization"] = f"Bearer {access_token}" - final_payload["project"] = project_id - apply_enabled_credit_types(credential_data) - return True - - def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: - nonlocal current_file, access_token, project_id, auth_headers, final_payload - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - if not access_token or not project_id: - return False - auth_headers["Authorization"] = f"Bearer {access_token}" - final_payload["project"] = project_id - apply_enabled_credit_types(credential_data) - return True - - for attempt in range(max_retries + 1): - success_recorded = False # 标记是否已记录成功 - need_retry = False # 标记是否需要重试 - should_force_switch = False - - try: - async for chunk in stream_post_async( - url=target_url, - body=final_payload, - native=native, - headers=auth_headers - ): - # 判断是否是Response对象 - if isinstance(chunk, Response): - status_code = chunk.status_code - last_error_response = chunk # 记录最后一次错误 - - # 缓存错误解析结果,避免重复decode - error_body = None - try: - error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) - except Exception: - error_body = "" - - # 如果错误码是429、503或者在禁用码当中,做好记录后进行重试 - if _is_retryable_status(status_code, DISABLE_ERROR_CODES): - log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}") - - # 解析冷却时间 - cooldown_until = None - if (status_code == 429 or status_code == 503) and error_body: - try: - cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity") - except Exception: - pass - - if cooldown_until is not None: - should_force_switch = True - elif await check_should_auto_ban(status_code): - should_force_switch = True - - # 预热下一个凭证 - if (switch_credential_enabled or should_force_switch) and next_cred_task is None and attempt < max_retries: - next_cred_task = asyncio.create_task( - credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name - ) - ) - - # 记录错误并切换凭证 - await record_api_call_error( - credential_manager, current_file, status_code, - cooldown_until, mode="antigravity", model_name=model_name, - error_message=error_body - ) - - # 检查是否应该重试 - should_retry = await handle_error_with_retry( - credential_manager, status_code, current_file, - retry_config["retry_enabled"], attempt, max_retries, retry_interval, - mode="antigravity" - ) - - if should_retry and attempt < max_retries: - need_retry = True - break # 跳出内层循环,准备重试 - else: - # 不重试,直接返回原始错误 - log.error(f"[ANTIGRAVITY STREAM] 达到最大重试次数或不应重试,返回原始错误") - yield chunk - return - else: - # 错误码不在禁用码当中,直接返回,无需重试 - log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}") - await record_api_call_error( - credential_manager, current_file, status_code, - None, mode="antigravity", model_name=model_name, - error_message=error_body - ) - yield chunk - return - else: - # 不是Response,说明是真流,直接yield返回 - # 只在第一个chunk时记录成功 - if not success_recorded: - await record_api_call_success( - credential_manager, current_file, mode="antigravity", model_name=model_name - ) - success_recorded = True - log.debug(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}") - - # 记录原始chunk内容(用于调试) - if isinstance(chunk, bytes): - log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}") - else: - log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}") - - yield chunk - - # 流式请求完成,检查结果 - if success_recorded: - log.debug(f"[ANTIGRAVITY STREAM] 流式响应完成,模型: {model_name}") - return - elif not need_retry: - # 没有收到任何数据(空回复),需要重试 - log.warning(f"[ANTIGRAVITY STREAM] 收到空回复,无任何内容,凭证: {current_file}") - await record_api_call_error( - credential_manager, current_file, 200, - None, mode="antigravity", model_name=model_name, - error_message="Empty response from API" - ) - - if attempt < max_retries: - need_retry = True - else: - log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数") - yield Response( - content=json.dumps({"error": "服务返回空回复"}), - status_code=500, - media_type="application/json" - ) - return - - # 统一处理重试 - if need_retry: - log.info(f"[ANTIGRAVITY STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") - - if switch_credential_enabled or should_force_switch: - switched, next_cred_task = await _switch_credential_for_retry( - next_cred_task=next_cred_task, - retry_interval=retry_interval, - refresh_credential_fast=refresh_credential_fast, - apply_cred_result=apply_cred_result, - log_prefix="[ANTIGRAVITY STREAM]", - ) - if not switched: - log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌") - yield Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - return - else: - await asyncio.sleep(retry_interval) - continue # 重试 - - except Exception as e: - log.error(f"[ANTIGRAVITY STREAM] 流式请求异常: {e}, 凭证: {current_file}") - if attempt < max_retries: - log.info(f"[ANTIGRAVITY STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...") - await asyncio.sleep(retry_interval) - continue - else: - # 所有重试都失败,返回最后一次的错误(如果有) - log.error(f"[ANTIGRAVITY STREAM] 所有重试均失败,最后异常: {e}") - if last_error_response: - yield last_error_response - else: - # 如果没有记录到错误响应,返回500错误 - yield Response( - content=json.dumps({"error": f"流式请求异常: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - return - - # 所有重试均已耗尽(for循环正常结束),返回最后记录的错误 - log.error("[ANTIGRAVITY STREAM] 所有重试均失败") - if last_error_response: - yield last_error_response - else: - yield Response( - content=json.dumps({"error": "请求失败,所有重试均已耗尽"}), - status_code=429, - media_type="application/json" - ) - - -async def non_stream_request( - body: Dict[str, Any], - headers: Optional[Dict[str, str]] = None, -) -> Response: - """ - 非流式请求函数 - - Args: - body: 请求体 - headers: 额外的请求头 - - Returns: - Response对象 - """ - # 检查是否启用流式收集模式 - if await get_antigravity_stream2nostream(): - log.debug("[ANTIGRAVITY] 使用流式收集模式实现非流式请求") - - # 调用stream_request获取流 - stream = stream_request(body=body, native=False, headers=headers) - - # 收集流式响应 - # stream_request是一个异步生成器,可能yield Response(错误)或流数据 - # collect_streaming_response会自动处理这两种情况 - return await collect_streaming_response(stream) - - # 否则使用传统非流式模式 - log.debug("[ANTIGRAVITY] 使用传统非流式模式") - - model_name = body.get("model", "") - switch_credential_enabled = await get_antigravity_switch_credential_enabled() - - # 1. 获取有效凭证 - cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name - ) - - if not cred_result: - # 如果返回值是None,直接返回错误500 - log.error("[ANTIGRAVITY] 当前无可用凭证") - return Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - - if not access_token: - log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}") - return Response( - content=json.dumps({"error": "凭证中没有访问令牌"}), - status_code=500, - media_type="application/json" - ) - - # 2. 构建URL和请求头 - antigravity_url = await get_antigravity_api_url() - target_url = f"{antigravity_url}/v1internal:generateContent" - - auth_headers = build_antigravity_headers(access_token, model_name) - - # 合并自定义headers - if headers: - auth_headers.update(headers) - - # 构建包含project的payload - final_payload = { - "model": body.get("model"), - "project": project_id, - "request": body.get("request", {}), - } - _prepare_antigravity_payload(final_payload, model_name) - - # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes - def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: - if cred_data.get("enable_credit") is True: - final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"] - else: - final_payload.pop("enabledCreditTypes", None) - - apply_enabled_credit_types(credential_data) - - # 3. 调用post_async进行请求 - retry_config = await get_retry_config() - max_retries = retry_config["max_retries"] - retry_interval = retry_config["retry_interval"] - - DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码 - last_error_response = None # 记录最后一次的错误响应 - next_cred_task = None # 预热的下一个凭证任务 - - # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求) - async def refresh_credential_fast(): - nonlocal current_file, access_token, auth_headers, project_id, final_payload - cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name - ) - if not cred_result: - return None - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - if not access_token: - return None - # 只更新token和project_id,不重建整个headers和payload - auth_headers["Authorization"] = f"Bearer {access_token}" - final_payload["project"] = project_id - apply_enabled_credit_types(credential_data) - return True - - def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: - nonlocal current_file, access_token, project_id, auth_headers, final_payload - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - if not access_token or not project_id: - return False - auth_headers["Authorization"] = f"Bearer {access_token}" - final_payload["project"] = project_id - apply_enabled_credit_types(credential_data) - return True - - for attempt in range(max_retries + 1): - need_retry = False # 标记是否需要重试 - should_force_switch = False - - try: - response = await post_async( - url=target_url, - json=final_payload, - headers=auth_headers, - timeout=300.0 - ) - - status_code = response.status_code - - # 成功 - if status_code == 200: - # 检查是否为空回复 - if not response.content or len(response.content) == 0: - log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}") - - # 记录错误 - await record_api_call_error( - credential_manager, current_file, 200, - None, mode="antigravity", model_name=model_name, - error_message="Empty response from API" - ) - - if attempt < max_retries: - need_retry = True - else: - log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数") - return Response( - content=json.dumps({"error": "服务返回空回复"}), - status_code=500, - media_type="application/json" - ) - else: - # 正常响应 - await record_api_call_success( - credential_manager, current_file, mode="antigravity", model_name=model_name - ) - return Response( - content=response.content, - status_code=200, - headers=dict(response.headers) - ) - - # 失败 - 记录最后一次错误 - if status_code != 200: - last_error_response = Response( - content=response.content, - status_code=status_code, - headers=dict(response.headers) - ) - - # 判断是否需要重试 - # 缓存错误文本,避免重复解析 - error_text = "" - try: - error_text = response.text - except Exception: - pass - - if _is_retryable_status(status_code, DISABLE_ERROR_CODES): - log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}") - - # 解析冷却时间 - cooldown_until = None - if (status_code == 429 or status_code == 503) and error_text: - try: - cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity") - except Exception: - pass - - if cooldown_until is not None: - should_force_switch = True - elif await check_should_auto_ban(status_code): - should_force_switch = True - - # 并行预热下一个凭证,不阻塞当前处理 - if (switch_credential_enabled or should_force_switch) and next_cred_task is None and attempt < max_retries: - next_cred_task = asyncio.create_task( - credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name - ) - ) - - # 记录错误并切换凭证 - await record_api_call_error( - credential_manager, current_file, status_code, - cooldown_until, mode="antigravity", model_name=model_name, - error_message=error_text - ) - - # 检查是否应该重试 - should_retry = await handle_error_with_retry( - credential_manager, status_code, current_file, - retry_config["retry_enabled"], attempt, max_retries, retry_interval, - mode="antigravity" - ) - - if should_retry and attempt < max_retries: - need_retry = True - else: - # 不重试,直接返回原始错误 - log.error(f"[ANTIGRAVITY] 达到最大重试次数或不应重试,返回原始错误") - return last_error_response - else: - # 错误码不在禁用码当中,直接返回,无需重试 - log.error(f"[ANTIGRAVITY] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}") - await record_api_call_error( - credential_manager, current_file, status_code, - None, mode="antigravity", model_name=model_name, - error_message=error_text - ) - return last_error_response - - # 统一处理重试 - if need_retry: - log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") - - if switch_credential_enabled or should_force_switch: - switched, next_cred_task = await _switch_credential_for_retry( - next_cred_task=next_cred_task, - retry_interval=retry_interval, - refresh_credential_fast=refresh_credential_fast, - apply_cred_result=apply_cred_result, - log_prefix="[ANTIGRAVITY]", - ) - if not switched: - log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌") - return Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - else: - await asyncio.sleep(retry_interval) - continue # 重试 - - except Exception as e: - log.error(f"[ANTIGRAVITY] 非流式请求异常: {e}, 凭证: {current_file}") - if attempt < max_retries: - log.info(f"[ANTIGRAVITY] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...") - await asyncio.sleep(retry_interval) - continue - else: - # 所有重试都失败,返回最后一次的错误(如果有)或500错误 - log.error(f"[ANTIGRAVITY] 所有重试均失败,最后异常: {e}") - if last_error_response: - return last_error_response - else: - return Response( - content=json.dumps({"error": f"非流式请求异常: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - - # 所有重试都失败,返回最后一次的原始错误(如果有)或500错误 - log.error("[ANTIGRAVITY] 所有重试均失败") - if last_error_response: - return last_error_response - else: - return Response( - content=json.dumps({"error": "所有重试均失败"}), - status_code=500, - media_type="application/json" - ) - - -# ==================== 模型和配额查询 ==================== - -async def fetch_available_models() -> List[Dict[str, Any]]: - """ - 获取可用模型列表,返回符合 OpenAI API 规范的格式 - - Returns: - 模型列表,格式为字典列表(用于兼容现有代码) - - Raises: - 返回空列表如果获取失败 - """ - # 获取凭证管理器和可用凭证 - cred_result = await credential_manager.get_valid_credential(mode="antigravity") - if not cred_result: - log.error("[ANTIGRAVITY] No valid credentials available for fetching models") - return [] - - current_file, credential_data = cred_result - access_token = credential_data.get("access_token") or credential_data.get("token") - - if not access_token: - log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}") - return [] - - # 构建请求头 - headers = build_antigravity_headers(access_token) - - try: - # 使用 POST 请求获取模型列表 - antigravity_url = await get_antigravity_api_url() - - response = await post_async( - url=f"{antigravity_url}/v1internal:fetchAvailableModels", - json={}, # 空的请求体 - headers=headers - ) - - if response.status_code == 200: - data = response.json() - log.debug(f"[ANTIGRAVITY] Raw models response: {json.dumps(data, ensure_ascii=False)[:500]}") - - # 转换为 OpenAI 格式的模型列表,使用 Model 类 - model_list = [] - current_timestamp = int(datetime.now(timezone.utc).timestamp()) - - if 'models' in data and isinstance(data['models'], dict): - # 遍历模型字典 - for model_id in data['models'].keys(): - model = Model( - id=model_id, - object='model', - created=current_timestamp, - owned_by='google' - ) - model_list.append(model_to_dict(model)) - # 添加额外的 claude-sonnet-4-6-thinking 模型 - if "claude-sonnet-4-6" in data.get('models', {}): - model = Model( - id='claude-sonnet-4-6-thinking', - object='model', - created=current_timestamp, - owned_by='google' - ) - model_list.append(model_to_dict(model)) - # 添加额外的 claude-opus-4-6 模型 - if "claude-opus-4-6-thinking" in data.get('models', {}): - claude_opus_model = Model( - id='claude-opus-4-6', - object='model', - created=current_timestamp, - owned_by='google' - ) - model_list.append(model_to_dict(claude_opus_model)) - - log.info(f"[ANTIGRAVITY] Fetched {len(model_list)} available models") - return model_list - else: - log.error(f"[ANTIGRAVITY] Failed to fetch models ({response.status_code}): {response.text[:500]}") - return [] - - except Exception as e: - import traceback - log.error(f"[ANTIGRAVITY] Failed to fetch models: {e}") - log.error(f"[ANTIGRAVITY] Traceback: {traceback.format_exc()}") - return [] - - -async def fetch_quota_info(access_token: str) -> Dict[str, Any]: - """ - 获取指定凭证的额度信息 - - Args: - access_token: Antigravity 访问令牌 - - Returns: - 包含额度信息的字典,格式为: - { - "success": True/False, - "models": { - "model_name": { - "remaining": 0.95, - "resetTime": "12-20 10:30", - "resetTimeRaw": "2025-12-20T02:30:00Z" - } - }, - "error": "错误信息" (仅在失败时) - } - """ - - headers = build_antigravity_headers(access_token) - - try: - antigravity_url = await get_antigravity_api_url() - - response = await post_async( - url=f"{antigravity_url}/v1internal:fetchAvailableModels", - json={}, - headers=headers, - timeout=30.0 - ) - - if response.status_code == 200: - data = response.json() - log.debug(f"[ANTIGRAVITY QUOTA] Raw response: {json.dumps(data, ensure_ascii=False)[:500]}") - - quota_info = {} - - if 'models' in data and isinstance(data['models'], dict): - for model_id, model_data in data['models'].items(): - if isinstance(model_data, dict) and 'quotaInfo' in model_data: - quota = model_data['quotaInfo'] - remaining = quota.get('remainingFraction', 0) - reset_time_raw = quota.get('resetTime', '') - - # 转换为北京时间 - reset_time_beijing = 'N/A' - if reset_time_raw: - try: - utc_date = datetime.fromisoformat(reset_time_raw.replace('Z', '+00:00')) - # 转换为北京时间 (UTC+8) - from datetime import timedelta - beijing_date = utc_date + timedelta(hours=8) - reset_time_beijing = beijing_date.strftime('%m-%d %H:%M') - except Exception as e: - log.warning(f"[ANTIGRAVITY QUOTA] Failed to parse reset time: {e}") - - quota_info[model_id] = { - "remaining": remaining, - "resetTime": reset_time_beijing, - "resetTimeRaw": reset_time_raw - } - - return { - "success": True, - "models": quota_info - } - else: - log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota ({response.status_code}): {response.text[:500]}") - return { - "success": False, - "error": f"API返回错误: {response.status_code}" - } - - except Exception as e: - import traceback - log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota: {e}") - log.error(f"[ANTIGRAVITY QUOTA] Traceback: {traceback.format_exc()}") - return { - "success": False, - "error": str(e) - } diff --git a/src/api/geminicli.py b/src/api/geminicli.py deleted file mode 100644 index 4f5f4b36ac5b08ab30364d0e74b0526bc0065d82..0000000000000000000000000000000000000000 --- a/src/api/geminicli.py +++ /dev/null @@ -1,808 +0,0 @@ -""" -GeminiCli API Client - Handles all communication with GeminiCli API. -This module is used by both OpenAI compatibility layer and native Gemini endpoints. -GeminiCli API 客户端 - 处理与 GeminiCli API 的所有通信 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径(用于直接运行测试) -if __name__ == "__main__": - project_root = Path(__file__).resolve().parent.parent.parent - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -import asyncio -import json -from typing import Any, Dict, Optional, Callable, Tuple - -from fastapi import Response -from config import get_code_assist_endpoint, get_auto_ban_error_codes -from log import log - -from src.credential_manager import credential_manager -from src.httpx_client import stream_post_async, post_async - -# 导入共同的基础功能 -from src.api.utils import ( - handle_error_with_retry, - get_retry_config, - record_api_call_success, - record_api_call_error, - parse_and_log_cooldown, -) -from src.utils import get_geminicli_user_agent - -# ==================== 全局凭证管理器 ==================== - -# 使用全局单例 credential_manager,自动初始化 - - -# ==================== 请求准备 ==================== - -async def prepare_request_headers_and_payload( - payload: dict, credential_data: dict, target_url: str -): - """ - 从凭证数据准备请求头和最终payload - - Args: - payload: 原始请求payload - credential_data: 凭证数据字典 - target_url: 目标URL - - Returns: - 元组: (headers, final_payload, target_url) - - Raises: - Exception: 如果凭证中缺少必要字段 - """ - token = credential_data.get("token") or credential_data.get("access_token", "") - if not token: - raise Exception("凭证中没有找到有效的访问令牌(token或access_token字段)") - - source_request = payload.get("request", {}) - - # 内部API使用Bearer Token和项目ID - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "User-Agent": get_geminicli_user_agent(payload.get("model", "")), - } - project_id = credential_data.get("project_id", "") - if not project_id: - raise Exception("项目ID不存在于凭证数据中") - final_payload = { - "model": payload.get("model"), - "project": project_id, - "request": source_request, - } - - return headers, final_payload, target_url - - -def _is_retryable_status(status_code: int, disable_error_codes: list[int]) -> bool: - """统一判断是否属于可重试状态码。""" - return status_code in (429, 500, 503) or status_code in disable_error_codes - - -async def _switch_credential_for_retry( - *, - next_cred_task: Optional[asyncio.Task], - retry_interval: float, - refresh_credential_fast: Callable[[], Any], - apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool], - log_prefix: str, -) -> Tuple[bool, Optional[asyncio.Task]]: - """优先使用预热凭证,失败后退回同步刷新。""" - if next_cred_task is not None: - try: - cred_result = await next_cred_task - next_cred_task = None - if cred_result and apply_cred_result(cred_result): - await asyncio.sleep(retry_interval) - return True, next_cred_task - except Exception as e: - log.warning(f"{log_prefix} 预热凭证任务失败: {e}") - next_cred_task = None - - await asyncio.sleep(retry_interval) - if await refresh_credential_fast(): - return True, next_cred_task - - return False, next_cred_task - - -# ==================== 新的流式和非流式请求函数 ==================== - -async def stream_request( - body: Dict[str, Any], - native: bool = False, - headers: Optional[Dict[str, str]] = None, -): - """ - 流式请求函数 - - Args: - body: 请求体 - native: 是否返回原生bytes流,False则返回str流 - headers: 额外的请求头 - - Yields: - Response对象(错误时)或 bytes流/str流(成功时) - """ - # 获取有效凭证 - model_name = body.get("model", "") - - # 1. 获取有效凭证 - cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - - if not cred_result: - # 如果返回值是None,直接返回错误500 - yield Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - return - - current_file, credential_data = cred_result - - # 2. 构建URL和请求头 - try: - auth_headers, final_payload, target_url = await prepare_request_headers_and_payload( - body, credential_data, - f"{await get_code_assist_endpoint()}/v1internal:streamGenerateContent?alt=sse" - ) - - # 合并自定义headers - if headers: - auth_headers.update(headers) - - except Exception as e: - log.error(f"准备请求失败: {e}") - yield Response( - content=json.dumps({"error": f"准备请求失败: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - return - - # 3. 调用stream_post_async进行请求 - retry_config = await get_retry_config() - max_retries = retry_config["max_retries"] - retry_interval = retry_config["retry_interval"] - - DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码 - last_error_response = None # 记录最后一次的错误响应 - next_cred_task = None # 预热的下一个凭证任务 - - # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求) - async def refresh_credential_fast(): - nonlocal current_file, credential_data, auth_headers, final_payload - cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - if not cred_result: - return None - current_file, credential_data = cred_result - try: - # 只更新token和project_id,不重建整个headers和payload - token = credential_data.get("token") or credential_data.get("access_token", "") - project_id = credential_data.get("project_id", "") - if not token or not project_id: - return None - - # 直接更新现有的headers和payload - auth_headers["Authorization"] = f"Bearer {token}" - final_payload["project"] = project_id - return True - except Exception: - return None - - def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: - nonlocal current_file, credential_data, auth_headers, final_payload - current_file, credential_data = cred_result - token = credential_data.get("token") or credential_data.get("access_token", "") - project_id = credential_data.get("project_id", "") - if not token or not project_id: - return False - auth_headers["Authorization"] = f"Bearer {token}" - final_payload["project"] = project_id - return True - - for attempt in range(max_retries + 1): - success_recorded = False # 标记是否已记录成功 - need_retry = False # 标记是否需要重试 - - try: - async for chunk in stream_post_async( - url=target_url, - body=final_payload, - native=native, - headers=auth_headers - ): - # 判断是否是Response对象 - if isinstance(chunk, Response): - status_code = chunk.status_code - last_error_response = chunk # 记录最后一次错误 - - # 缓存错误解析结果,避免重复decode - error_body = None - try: - error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) - except Exception: - error_body = "" - - # 如果错误码是429、503或者在禁用码当中,做好记录后进行重试 - if _is_retryable_status(status_code, DISABLE_ERROR_CODES): - log.warning(f"[GEMINICLI STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}") - - # 解析冷却时间 - cooldown_until = None - if (status_code == 429 or status_code == 503) and error_body: - try: - cooldown_until = await parse_and_log_cooldown(error_body, mode="geminicli") - except Exception: - pass - - # 预热下一个凭证 - if next_cred_task is None and attempt < max_retries: - next_cred_task = asyncio.create_task( - credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - ) - - # 记录错误并切换凭证 - await record_api_call_error( - credential_manager, current_file, status_code, - cooldown_until, mode="geminicli", model_name=model_name, - error_message=error_body - ) - - # 检查是否应该重试 - should_retry = await handle_error_with_retry( - credential_manager, status_code, current_file, - retry_config["retry_enabled"], attempt, max_retries, retry_interval, - mode="geminicli" - ) - - if should_retry and attempt < max_retries: - need_retry = True - break # 跳出内层循环,准备重试 - else: - # 不重试,直接返回原始错误 - log.error(f"[GEMINICLI STREAM] 达到最大重试次数或不应重试,返回原始错误") - yield chunk - return - elif status_code == 404 and "preview" in model_name.lower(): - # 特殊处理:preview模型返回404,说明该凭证不支持preview模型 - log.warning(f"[GEMINICLI STREAM] Preview模型404错误,凭证不支持preview: {current_file}") - - # 将该凭证的preview状态设置为False - try: - await credential_manager.update_credential_state( - current_file, {"preview": False}, mode="geminicli" - ) - log.info(f"[GEMINICLI STREAM] 已将凭证 {current_file} 的preview状态设置为False") - except Exception as e: - log.error(f"[GEMINICLI STREAM] 更新凭证preview状态失败: {e}") - - # 记录404错误 - await record_api_call_error( - credential_manager, current_file, status_code, - None, mode="geminicli", model_name=model_name, - error_message=error_body - ) - - # 预热下一个凭证(会自动跳过preview=False的凭证) - if next_cred_task is None and attempt < max_retries: - next_cred_task = asyncio.create_task( - credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - ) - - # 触发重试 - if attempt < max_retries: - need_retry = True - break - else: - log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回404错误") - yield chunk - return - else: - # 错误码不在禁用码当中,直接返回,无需重试 - log.error(f"[GEMINICLI STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}") - await record_api_call_error( - credential_manager, current_file, status_code, - None, mode="geminicli", model_name=model_name, - error_message=error_body - ) - yield chunk - return - else: - # 不是Response,说明是真流,直接yield返回 - # 只在第一个chunk时记录成功 - if not success_recorded: - await record_api_call_success( - credential_manager, current_file, mode="geminicli", model_name=model_name - ) - success_recorded = True - log.debug(f"[GEMINICLI STREAM] 开始接收流式响应,模型: {model_name}") - - yield chunk - - # 流式请求完成,检查结果 - if success_recorded: - log.debug(f"[GEMINICLI STREAM] 流式响应完成,模型: {model_name}") - return - - # 统一处理重试 - if need_retry: - # 如果已经是最后一次尝试,不再重试,直接返回错误 - if attempt >= max_retries: - log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回错误") - if last_error_response: - yield last_error_response - else: - yield Response( - content=json.dumps({"error": "请求失败,所有重试均已耗尽"}), - status_code=429, - media_type="application/json" - ) - return - - log.info(f"[GEMINICLI STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") - - switched, next_cred_task = await _switch_credential_for_retry( - next_cred_task=next_cred_task, - retry_interval=retry_interval, - refresh_credential_fast=refresh_credential_fast, - apply_cred_result=apply_cred_result, - log_prefix="[GEMINICLI STREAM]", - ) - if not switched: - log.error("[GEMINICLI STREAM] 重试时无可用凭证或刷新失败") - yield Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - return - continue # 重试 - - except Exception as e: - log.error(f"[GEMINICLI STREAM] 流式请求异常: {e}, 凭证: {current_file}") - if attempt < max_retries: - log.info(f"[GEMINICLI STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...") - await asyncio.sleep(retry_interval) - continue - else: - # 所有重试都失败,返回最后一次的错误(如果有) - log.error(f"[GEMINICLI STREAM] 所有重试均失败,最后异常: {e}") - if last_error_response: - yield last_error_response - else: - # 如果没有记录到错误响应,返回500错误 - yield Response( - content=json.dumps({"error": f"流式请求异常: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - return - - # 所有重试均已耗尽(for循环正常结束),返回最后记录的错误 - log.error("[GEMINICLI STREAM] 所有重试均失败") - if last_error_response: - yield last_error_response - else: - yield Response( - content=json.dumps({"error": "请求失败,所有重试均已耗尽"}), - status_code=429, - media_type="application/json" - ) - - -async def non_stream_request( - body: Dict[str, Any], - headers: Optional[Dict[str, str]] = None, -) -> Response: - """ - 非流式请求函数 - - Args: - body: 请求体 - native: 保留参数以保持接口一致性(实际未使用) - headers: 额外的请求头 - - Returns: - Response对象 - """ - # 获取有效凭证 - model_name = body.get("model", "") - - # 1. 获取有效凭证 - cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - - if not cred_result: - # 如果返回值是None,直接返回错误500 - return Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - - current_file, credential_data = cred_result - - # 2. 构建URL和请求头 - try: - auth_headers, final_payload, target_url = await prepare_request_headers_and_payload( - body, credential_data, - f"{await get_code_assist_endpoint()}/v1internal:generateContent" - ) - - # 合并自定义headers - if headers: - auth_headers.update(headers) - - except Exception as e: - log.error(f"准备请求失败: {e}") - return Response( - content=json.dumps({"error": f"准备请求失败: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - - # 3. 调用post_async进行请求 - retry_config = await get_retry_config() - max_retries = retry_config["max_retries"] - retry_interval = retry_config["retry_interval"] - - DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码 - last_error_response = None # 记录最后一次的错误响应 - next_cred_task = None # 预热的下一个凭证任务 - - # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求) - async def refresh_credential_fast(): - nonlocal current_file, credential_data, auth_headers, final_payload - cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - if not cred_result: - return None - current_file, credential_data = cred_result - try: - # 只更新token和project_id,不重建整个headers和payload - token = credential_data.get("token") or credential_data.get("access_token", "") - project_id = credential_data.get("project_id", "") - if not token or not project_id: - return None - - # 直接更新现有的headers和payload - auth_headers["Authorization"] = f"Bearer {token}" - final_payload["project"] = project_id - return True - except Exception: - return None - - def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: - nonlocal current_file, credential_data, auth_headers, final_payload - current_file, credential_data = cred_result - token = credential_data.get("token") or credential_data.get("access_token", "") - project_id = credential_data.get("project_id", "") - if not token or not project_id: - return False - auth_headers["Authorization"] = f"Bearer {token}" - final_payload["project"] = project_id - return True - - for attempt in range(max_retries + 1): - try: - response = await post_async( - url=target_url, - json=final_payload, - headers=auth_headers, - timeout=300.0 - ) - - status_code = response.status_code - - # 成功 - if status_code == 200: - await record_api_call_success( - credential_manager, current_file, mode="geminicli", model_name=model_name - ) - # 创建响应头,移除压缩相关的header避免重复解压 - response_headers = dict(response.headers) - response_headers.pop('content-encoding', None) - response_headers.pop('content-length', None) - - return Response( - content=response.content, - status_code=200, - headers=response_headers - ) - - # 失败 - 记录最后一次错误 - # 创建响应头,移除压缩相关的header避免重复解压 - error_headers = dict(response.headers) - error_headers.pop('content-encoding', None) - error_headers.pop('content-length', None) - - last_error_response = Response( - content=response.content, - status_code=status_code, - headers=error_headers - ) - - # 判断是否需要重试 - # 缓存错误文本,避免重复解析 - error_text = "" - try: - error_text = response.text - except Exception: - pass - - # 统一处理所有需要重试的错误码(429、503、禁用码) - if _is_retryable_status(status_code, DISABLE_ERROR_CODES): - log.warning(f"[NON-STREAM] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}") - - # 解析冷却时间 - cooldown_until = None - if (status_code == 429 or status_code == 503) and error_text: - try: - cooldown_until = await parse_and_log_cooldown(error_text, mode="geminicli") - except Exception: - pass - - # 并行预热下一个凭证,不阻塞当前处理 - if next_cred_task is None and attempt < max_retries: - next_cred_task = asyncio.create_task( - credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - ) - - # 记录错误并切换凭证 - await record_api_call_error( - credential_manager, current_file, status_code, - cooldown_until, mode="geminicli", model_name=model_name, - error_message=error_text - ) - - # 检查是否应该重试(会自动处理禁用逻辑) - should_retry = await handle_error_with_retry( - credential_manager, status_code, current_file, - retry_config["retry_enabled"], attempt, max_retries, retry_interval, - mode="geminicli" - ) - - if should_retry and attempt < max_retries: - # 重新获取凭证并重试 - log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") - - switched, next_cred_task = await _switch_credential_for_retry( - next_cred_task=next_cred_task, - retry_interval=retry_interval, - refresh_credential_fast=refresh_credential_fast, - apply_cred_result=apply_cred_result, - log_prefix="[NON-STREAM]", - ) - if not switched: - log.error("[NON-STREAM] 重试时无可用凭证或刷新失败") - return Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - continue # 重试 - else: - # 不重试,直接返回原始错误 - log.error(f"[NON-STREAM] 达到最大重试次数或不应重试,返回原始错误") - return last_error_response - elif status_code == 404 and "preview" in model_name.lower(): - # 特殊处理:preview模型返回404,说明该凭证不支持preview模型 - log.warning(f"[NON-STREAM] Preview模型404错误,凭证不支持preview: {current_file}") - - # 将该凭证的preview状态设置为False - try: - await credential_manager.update_credential_state( - current_file, {"preview": False}, mode="geminicli" - ) - log.info(f"[NON-STREAM] 已将凭证 {current_file} 的preview状态设置为False") - except Exception as e: - log.error(f"[NON-STREAM] 更新凭证preview状态失败: {e}") - - # 记录404错误 - await record_api_call_error( - credential_manager, current_file, status_code, - None, mode="geminicli", model_name=model_name, - error_message=error_text - ) - - # 预热下一个凭证(会自动跳过preview=False的凭证) - if next_cred_task is None and attempt < max_retries: - next_cred_task = asyncio.create_task( - credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name - ) - ) - - # 触发重试 - if attempt < max_retries: - log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") - - switched, next_cred_task = await _switch_credential_for_retry( - next_cred_task=next_cred_task, - retry_interval=retry_interval, - refresh_credential_fast=refresh_credential_fast, - apply_cred_result=apply_cred_result, - log_prefix="[NON-STREAM]", - ) - if not switched: - log.error("[NON-STREAM] 重试时无可用凭证或刷新失败") - return Response( - content=json.dumps({"error": "当前无可用凭证"}), - status_code=500, - media_type="application/json" - ) - continue # 重试 - else: - log.error(f"[NON-STREAM] 达到最大重试次数,返回404错误") - return last_error_response - else: - # 错误码不在重试范围内,直接返回 - log.error(f"[NON-STREAM] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}") - await record_api_call_error( - credential_manager, current_file, status_code, - None, mode="geminicli", model_name=model_name, - error_message=error_text - ) - return last_error_response - - except Exception as e: - log.error(f"非流式请求异常: {e}, 凭证: {current_file}") - if attempt < max_retries: - log.info(f"[NON-STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...") - await asyncio.sleep(retry_interval) - continue - else: - # 所有重试都失败,返回最后一次的错误(如果有)或500错误 - log.error(f"[NON-STREAM] 所有重试均失败,最后异常: {e}") - if last_error_response: - return last_error_response - else: - return Response( - content=json.dumps({"error": f"请求异常: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - - # 所有重试都失败,返回最后一次的原始错误 - log.error("[NON-STREAM] 所有重试均失败") - return last_error_response - - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示API返回的流式和非流式数据格式 - 运行方式: python src/api/geminicli.py - """ - print("=" * 80) - print("GeminiCli API 测试") - print("=" * 80) - - # 测试请求体 - test_body = { - "model": "gemini-2.5-flash", - "request": { - "contents": [ - { - "role": "user", - "parts": [{"text": "Hello, tell me a joke in one sentence."}] - } - ] - } - } - - async def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试1】流式请求 (stream_request with native=False)") - print("=" * 80) - print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - chunk_count = 0 - async for chunk in stream_request(body=test_body, native=False): - chunk_count += 1 - if isinstance(chunk, Response): - # 错误响应 - print(f"\n❌ 错误响应:") - print(f" 状态码: {chunk.status_code}") - print(f" Content-Type: {chunk.headers.get('content-type', 'N/A')}") - try: - content = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) - print(f" 内容: {content}") - except Exception as e: - print(f" 内容解析失败: {e}") - else: - # 正常的流式数据块 (str类型) - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk) if hasattr(chunk, '__len__') else 'N/A'}") - print(f" 内容预览: {repr(chunk[:200] if len(chunk) > 200 else chunk)}") - - # 如果是SSE格式,尝试解析 - if isinstance(chunk, str) and chunk.startswith("data: "): - try: - data_line = chunk.strip() - if data_line.startswith("data: "): - json_str = data_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析尝试失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - async def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试2】非流式请求 (non_stream_request)") - print("=" * 80) - print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n") - - response = await non_stream_request(body=test_body) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - print(f"\n响应头: {dict(response.headers)}\n") - - try: - content = response.body.decode('utf-8') if isinstance(response.body, bytes) else str(response.body) - print(f"响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = json.loads(content) - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - async def main(): - """主测试函数""" - try: - # 测试流式请求 - await test_stream_request() - - # 测试非流式请求 - await test_non_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() - - # 运行测试 - asyncio.run(main()) diff --git a/src/api/utils.py b/src/api/utils.py deleted file mode 100644 index c90dace7d21b257c9fcf8b0fd2625b569d0ab2e0..0000000000000000000000000000000000000000 --- a/src/api/utils.py +++ /dev/null @@ -1,506 +0,0 @@ -""" -Base API Client - 共用的 API 客户端基础功能 -提供错误处理、自动封禁、重试逻辑等共同功能 -""" - -import asyncio -import json -from datetime import datetime, timezone -from typing import Any, Dict, Optional - -from fastapi import Response - -from config import ( - get_auto_ban_enabled, - get_auto_ban_error_codes, - get_retry_429_enabled, - get_retry_429_interval, - get_retry_429_max_retries, -) -from log import log -from src.credential_manager import CredentialManager - - -# ==================== 错误检查与处理 ==================== - -async def check_should_auto_ban(status_code: int) -> bool: - """ - 检查是否应该触发自动封禁 - - Args: - status_code: HTTP状态码 - - Returns: - bool: 是否应该触发自动封禁 - """ - return ( - await get_auto_ban_enabled() - and status_code in await get_auto_ban_error_codes() - ) - - -async def handle_auto_ban( - credential_manager: CredentialManager, - status_code: int, - credential_name: str, - mode: str = "geminicli" -) -> None: - """ - 处理自动封禁:直接禁用凭证 - - Args: - credential_manager: 凭证管理器实例 - status_code: HTTP状态码 - credential_name: 凭证名称 - mode: 模式(geminicli 或 antigravity) - """ - if credential_manager and credential_name: - log.warning( - f"[{mode.upper()} AUTO_BAN] Status {status_code} triggers auto-ban for credential: {credential_name}" - ) - await credential_manager.set_cred_disabled( - credential_name, True, mode=mode - ) - - -async def handle_error_with_retry( - credential_manager: CredentialManager, - status_code: int, - credential_name: str, - retry_enabled: bool, - attempt: int, - max_retries: int, - retry_interval: float, - mode: str = "geminicli" -) -> bool: - """ - 统一处理错误和重试逻辑 - - 仅在以下情况下进行自动重试: - 1. 429错误(速率限制) - 2. 503错误(服务不可用) - 3. 500错误(服务临时不可用) - 4. 导致凭证封禁的错误(AUTO_BAN_ERROR_CODES配置) - - Args: - credential_manager: 凭证管理器实例 - status_code: HTTP状态码 - credential_name: 凭证名称 - retry_enabled: 是否启用重试 - attempt: 当前重试次数 - max_retries: 最大重试次数 - retry_interval: 重试间隔 - mode: 模式(geminicli 或 antigravity) - - Returns: - bool: True表示需要继续重试,False表示不需要重试 - """ - # 优先检查自动封禁 - should_auto_ban = await check_should_auto_ban(status_code) - - if should_auto_ban: - # 触发自动封禁 - await handle_auto_ban(credential_manager, status_code, credential_name, mode) - - # 自动封禁后,仍然尝试重试(会在下次循环中自动获取新凭证) - if retry_enabled and attempt < max_retries: - log.info( - f"[{mode.upper()} RETRY] Retrying with next credential after auto-ban " - f"(status {status_code}, attempt {attempt + 1}/{max_retries})" - ) - await asyncio.sleep(retry_interval) - return True - return False - - # 如果不触发自动封禁,仅对429、503和500错误进行重试 - if status_code in (429, 500, 503) and retry_enabled and attempt < max_retries: - log.info( - f"[{mode.upper()} RETRY] {status_code} error encountered, retrying " - f"(attempt {attempt + 1}/{max_retries})" - ) - await asyncio.sleep(retry_interval) - return True - - # 其他错误不进行重试 - return False - - -# ==================== 重试配置获取 ==================== - -async def get_retry_config() -> Dict[str, Any]: - """ - 获取重试配置 - - Returns: - 包含重试配置的字典 - """ - return { - "retry_enabled": await get_retry_429_enabled(), - "max_retries": await get_retry_429_max_retries(), - "retry_interval": await get_retry_429_interval(), - } - - -# ==================== API调用结果记录 ==================== - -async def record_api_call_success( - credential_manager: CredentialManager, - credential_name: str, - mode: str = "geminicli", - model_name: Optional[str] = None -) -> None: - """ - 记录API调用成功 - - Args: - credential_manager: 凭证管理器实例 - credential_name: 凭证名称 - mode: 模式(geminicli 或 antigravity) - model_name: 模型名称(用于模型级CD) - """ - if credential_manager and credential_name: - await credential_manager.record_api_call_result( - credential_name, True, mode=mode, model_name=model_name - ) - - -async def record_api_call_error( - credential_manager: CredentialManager, - credential_name: str, - status_code: int, - cooldown_until: Optional[float] = None, - mode: str = "geminicli", - model_name: Optional[str] = None, - error_message: Optional[str] = None -) -> None: - """ - 记录API调用错误 - - Args: - credential_manager: 凭证管理器实例 - credential_name: 凭证名称 - status_code: HTTP状态码 - cooldown_until: 冷却截止时间(Unix时间戳) - mode: 模式(geminicli 或 antigravity) - model_name: 模型名称(用于模型级CD) - error_message: 错误信息(可选) - """ - if credential_manager and credential_name: - await credential_manager.record_api_call_result( - credential_name, - False, - status_code, - cooldown_until=cooldown_until, - mode=mode, - model_name=model_name, - error_message=error_message - ) - - -# ==================== 429错误处理 ==================== - -async def parse_and_log_cooldown( - error_text: str, - mode: str = "geminicli" -) -> Optional[float]: - """ - 解析并记录冷却时间 - - Args: - error_text: 错误响应文本 - mode: 模式(geminicli 或 antigravity) - - Returns: - 冷却截止时间(Unix时间戳),如果解析失败则返回None - """ - try: - error_data = json.loads(error_text) - cooldown_until = parse_quota_reset_timestamp(error_data) - if cooldown_until: - log.info( - f"[{mode.upper()}] 检测到quota冷却时间: " - f"{datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}" - ) - return cooldown_until - except Exception as parse_err: - log.debug(f"[{mode.upper()}] Failed to parse cooldown time: {parse_err}") - return None - - -# ==================== 流式响应收集 ==================== - -async def collect_streaming_response(stream_generator) -> Response: - """ - 将Gemini流式响应收集为一条完整的非流式响应 - - Args: - stream_generator: 流式响应生成器,产生 "data: {json}" 格式的行或Response对象 - - Returns: - Response: 合并后的完整响应对象 - - Example: - >>> async for line in stream_generator: - ... # line format: "data: {...}" or Response object - >>> response = await collect_streaming_response(stream_generator) - """ - # 初始化响应结构 - merged_response = { - "response": { - "candidates": [{ - "content": { - "parts": [], - "role": "model" - }, - "finishReason": None, - "safetyRatings": [], - "citationMetadata": None - }], - "usageMetadata": { - "promptTokenCount": 0, - "candidatesTokenCount": 0, - "totalTokenCount": 0 - } - } - } - - collected_text = [] # 用于收集文本内容 - collected_thought_text = [] # 用于收集思维链内容 - collected_other_parts = [] # 用于收集其他类型的parts(图片、文件、工具调用等) - collected_tool_parts_count = 0 # 记录工具调用相关part数量 - has_data = False - line_count = 0 - - log.debug("[STREAM COLLECTOR] Starting to collect streaming response") - - try: - async for line in stream_generator: - line_count += 1 - - # 如果收到的是Response对象(错误),直接返回 - if isinstance(line, Response): - log.debug(f"[STREAM COLLECTOR] 收到错误Response,状态码: {line.status_code}") - return line - - # 处理 bytes 类型 - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='ignore') - log.debug(f"[STREAM COLLECTOR] Processing bytes line {line_count}: {line_str[:200] if line_str else 'empty'}") - elif isinstance(line, str): - line_str = line - log.debug(f"[STREAM COLLECTOR] Processing line {line_count}: {line_str[:200] if line_str else 'empty'}") - else: - log.debug(f"[STREAM COLLECTOR] Skipping non-string/bytes line: {type(line)}") - continue - - # 解析流式数据行 - if not line_str.startswith("data: "): - log.debug(f"[STREAM COLLECTOR] Skipping line without 'data: ' prefix: {line_str[:100]}") - continue - - raw = line_str[6:].strip() - if raw == "[DONE]": - log.debug("[STREAM COLLECTOR] Received [DONE] marker") - break - - try: - log.debug(f"[STREAM COLLECTOR] Parsing JSON: {raw[:200]}") - chunk = json.loads(raw) - has_data = True - log.debug(f"[STREAM COLLECTOR] Chunk keys: {chunk.keys() if isinstance(chunk, dict) else type(chunk)}") - - # 提取响应对象 - response_obj = chunk.get("response", {}) - if not response_obj: - log.debug("[STREAM COLLECTOR] No 'response' key in chunk, trying direct access") - response_obj = chunk # 尝试直接使用chunk - - candidates = response_obj.get("candidates", []) - log.debug(f"[STREAM COLLECTOR] Found {len(candidates)} candidates") - if not candidates: - log.debug(f"[STREAM COLLECTOR] No candidates in chunk, chunk structure: {list(chunk.keys()) if isinstance(chunk, dict) else type(chunk)}") - continue - - candidate = candidates[0] - - # 收集文本内容 - content = candidate.get("content", {}) - parts = content.get("parts", []) - log.debug(f"[STREAM COLLECTOR] Processing {len(parts)} parts from candidate") - - for part in parts: - if not isinstance(part, dict): - continue - - # 优先保留工具调用相关 part(functionCall / functionResponse) - # 避免在 stream2nostream 模式下工具调用丢失 - if "functionCall" in part or "functionResponse" in part or "function_call" in part: - collected_other_parts.append(part) - collected_tool_parts_count += 1 - log.debug(f"[STREAM COLLECTOR] Collected tool part: {list(part.keys())}") - continue - - # 处理文本内容 - text = part.get("text", "") - if text: - # 区分普通文本和思维链 - if part.get("thought", False): - collected_thought_text.append(text) - log.debug(f"[STREAM COLLECTOR] Collected thought text: {text[:100]}") - else: - collected_text.append(text) - log.debug(f"[STREAM COLLECTOR] Collected regular text: {text[:100]}") - # 处理非文本内容(图片、文件等) - elif "inlineData" in part or "fileData" in part or "executableCode" in part or "codeExecutionResult" in part: - collected_other_parts.append(part) - log.debug(f"[STREAM COLLECTOR] Collected non-text part: {list(part.keys())}") - - # 收集其他信息(使用最后一个块的值) - if candidate.get("finishReason"): - merged_response["response"]["candidates"][0]["finishReason"] = candidate["finishReason"] - - if candidate.get("safetyRatings"): - merged_response["response"]["candidates"][0]["safetyRatings"] = candidate["safetyRatings"] - - if candidate.get("citationMetadata"): - merged_response["response"]["candidates"][0]["citationMetadata"] = candidate["citationMetadata"] - - # 更新使用元数据 - usage = response_obj.get("usageMetadata", {}) - if usage: - merged_response["response"]["usageMetadata"].update(usage) - - except json.JSONDecodeError as e: - log.debug(f"[STREAM COLLECTOR] Failed to parse JSON chunk: {e}") - continue - except Exception as e: - log.debug(f"[STREAM COLLECTOR] Error processing chunk: {e}") - continue - - except Exception as e: - log.error(f"[STREAM COLLECTOR] Error collecting stream after {line_count} lines: {e}") - return Response( - content=json.dumps({"error": f"收集流式响应失败: {str(e)}"}), - status_code=500, - media_type="application/json" - ) - - log.debug(f"[STREAM COLLECTOR] Finished iteration, has_data={has_data}, line_count={line_count}") - - # 如果没有收集到任何数据,返回错误 - if not has_data: - log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines") - return Response( - content=json.dumps({"error": "No data collected from stream"}), - status_code=500, - media_type="application/json" - ) - - # 组装最终的parts - final_parts = [] - - # 先添加思维链内容(如果有) - if collected_thought_text: - final_parts.append({ - "text": "".join(collected_thought_text), - "thought": True - }) - - # 再添加普通文本内容 - if collected_text: - final_parts.append({ - "text": "".join(collected_text) - }) - - # 添加其他类型的parts(图片、文件等) - final_parts.extend(collected_other_parts) - - # 如果没有任何内容,添加空文本 - if not final_parts: - final_parts.append({"text": ""}) - - merged_response["response"]["candidates"][0]["content"]["parts"] = final_parts - - log.info( - f"[STREAM COLLECTOR] Collected {len(collected_text)} text chunks, " - f"{len(collected_thought_text)} thought chunks, {len(collected_other_parts)} other parts " - f"(tool parts: {collected_tool_parts_count})" - ) - - # 去掉嵌套的 "response" 包装(Antigravity格式 -> 标准Gemini格式) - if "response" in merged_response and "candidates" not in merged_response: - log.debug(f"[STREAM COLLECTOR] 展开response包装") - merged_response = merged_response["response"] - - # 返回纯JSON格式 - return Response( - content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'), - status_code=200, - headers={}, - media_type="application/json" - ) - - -RESOURCE_EXHAUSTED_COOLDOWN_HOURS = 4 # RESOURCE_EXHAUSTED 错误的默认冷却时间(小时) - - -def parse_quota_reset_timestamp(error_response: dict) -> Optional[float]: - """ - 从Google API错误响应中提取quota重置时间戳 - - Args: - error_response: Google API返回的错误响应字典 - - Returns: - Unix时间戳(秒),如果无法解析则返回None - - 示例错误响应: - { - "error": { - "code": 429, - "message": "You have exhausted your capacity...", - "status": "RESOURCE_EXHAUSTED", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "QUOTA_EXHAUSTED", - "metadata": { - "quotaResetTimeStamp": "2025-11-30T14:57:24Z", - "quotaResetDelay": "13h19m1.20964964s" - } - } - ] - } - } - """ - try: - error_obj = error_response.get("error", {}) - details = error_obj.get("details", []) - - for detail in details: - if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo": - reset_timestamp_str = detail.get("metadata", {}).get("quotaResetTimeStamp") - - if reset_timestamp_str: - if reset_timestamp_str.endswith("Z"): - reset_timestamp_str = reset_timestamp_str.replace("Z", "+00:00") - - reset_dt = datetime.fromisoformat(reset_timestamp_str) - if reset_dt.tzinfo is None: - reset_dt = reset_dt.replace(tzinfo=timezone.utc) - - return reset_dt.astimezone(timezone.utc).timestamp() - - # 如果是 RESOURCE_EXHAUSTED 错误且消息完全匹配,设置默认4小时冷却时间 - if ( - error_obj.get("status") == "RESOURCE_EXHAUSTED" - and error_obj.get("message") == "Resource has been exhausted (e.g. check quota)." - ): - import time - cooldown_until = time.time() + RESOURCE_EXHAUSTED_COOLDOWN_HOURS * 3600 - return cooldown_until - - return None - - except Exception: - return None diff --git a/src/auth.py b/src/auth.py deleted file mode 100644 index 21c44e05947dbdcab48d6c232bdabf381efbb966..0000000000000000000000000000000000000000 --- a/src/auth.py +++ /dev/null @@ -1,1089 +0,0 @@ -""" -认证API模块 -""" - -import asyncio -import socket -import threading -import time -import uuid -from datetime import timezone -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any, Dict, Optional -from urllib.parse import parse_qs, urlparse - -from config import get_config_value, get_antigravity_api_url, get_code_assist_endpoint -from log import log - -from .google_oauth_api import ( - Credentials, - Flow, - enable_required_apis, - fetch_project_id_and_tier, - get_user_projects, - select_default_project, -) -from .storage_adapter import get_storage_adapter -from .utils import ( - ANTIGRAVITY_CLIENT_ID, - ANTIGRAVITY_CLIENT_SECRET, - ANTIGRAVITY_SCOPES, - ANTIGRAVITY_USER_AGENT, - CALLBACK_HOST, - CLIENT_ID, - CLIENT_SECRET, - SCOPES, - GEMINICLI_USER_AGENT, - TOKEN_URL, -) - - -async def get_callback_port(): - """获取OAuth回调端口""" - return int(await get_config_value("oauth_callback_port", "11451", "OAUTH_CALLBACK_PORT")) - - -def _prepare_credentials_data(credentials: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> Dict[str, Any]: - """准备凭证数据字典(统一函数)""" - if mode == "antigravity": - creds_data = { - "client_id": ANTIGRAVITY_CLIENT_ID, - "client_secret": ANTIGRAVITY_CLIENT_SECRET, - "token": credentials.access_token, - "refresh_token": credentials.refresh_token, - "scopes": ANTIGRAVITY_SCOPES, - "token_uri": TOKEN_URL, - "project_id": project_id, - } - else: - creds_data = { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "token": credentials.access_token, - "refresh_token": credentials.refresh_token, - "scopes": SCOPES, - "token_uri": TOKEN_URL, - "project_id": project_id, - } - - if credentials.expires_at: - if credentials.expires_at.tzinfo is None: - expiry_utc = credentials.expires_at.replace(tzinfo=timezone.utc) - else: - expiry_utc = credentials.expires_at - creds_data["expiry"] = expiry_utc.isoformat() - - return creds_data - - -def _cleanup_auth_flow_server(state: str): - """清理认证流程的服务器资源""" - if state in auth_flows: - flow_data_to_clean = auth_flows[state] - try: - if flow_data_to_clean.get("server"): - server = flow_data_to_clean["server"] - port = flow_data_to_clean.get("callback_port") - async_shutdown_server(server, port) - except Exception as e: - log.debug(f"关闭服务器时出错: {e}") - del auth_flows[state] - - -class _OAuthLibPatcher: - """oauthlib参数验证补丁的上下文管理器""" - def __init__(self): - import oauthlib.oauth2.rfc6749.parameters - self.module = oauthlib.oauth2.rfc6749.parameters - self.original_validate = None - - def __enter__(self): - self.original_validate = self.module.validate_token_parameters - - def patched_validate(params): - try: - return self.original_validate(params) - except Warning: - pass - - self.module.validate_token_parameters = patched_validate - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.original_validate: - self.module.validate_token_parameters = self.original_validate - - -# 全局状态管理 - 严格限制大小 -auth_flows = {} # 存储进行中的认证流程 -MAX_AUTH_FLOWS = 20 # 严格限制最大认证流程数 -DEFAULT_PROJECT_ID = "gemini-pro-1751713012-07fc4dfd" - - -def cleanup_auth_flows_for_memory(): - """清理认证流程以释放内存""" - global auth_flows - cleanup_expired_flows() - # 如果还是太多,强制清理一些旧的流程 - if len(auth_flows) > 10: - # 按创建时间排序,保留最新的10个 - sorted_flows = sorted( - auth_flows.items(), key=lambda x: x[1].get("created_at", 0), reverse=True - ) - new_auth_flows = dict(sorted_flows[:10]) - - # 清理被移除的流程 - for state, flow_data in auth_flows.items(): - if state not in new_auth_flows: - try: - if flow_data.get("server"): - server = flow_data["server"] - port = flow_data.get("callback_port") - async_shutdown_server(server, port) - except Exception: - pass - flow_data.clear() - - auth_flows = new_auth_flows - log.info(f"强制清理认证流程,保留 {len(auth_flows)} 个最新流程") - - return len(auth_flows) - - -async def find_available_port(start_port: int = None) -> int: - """动态查找可用端口""" - if start_port is None: - start_port = await get_callback_port() - - # 首先尝试默认端口 - for port in range(start_port, start_port + 100): # 尝试100个端口 - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind(("0.0.0.0", port)) - log.info(f"找到可用端口: {port}") - return port - except OSError: - continue - - # 如果都不可用,让系统自动分配端口 - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("0.0.0.0", 0)) - port = s.getsockname()[1] - log.info(f"系统分配可用端口: {port}") - return port - except OSError as e: - log.error(f"无法找到可用端口: {e}") - raise RuntimeError("无法找到可用端口") - - -def create_callback_server(port: int) -> HTTPServer: - """创建指定端口的回调服务器,优化快速关闭""" - try: - # 服务器监听0.0.0.0 - server = HTTPServer(("0.0.0.0", port), AuthCallbackHandler) - - # 设置socket选项以支持快速关闭 - server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - # 设置较短的超时时间 - server.timeout = 1.0 - - log.info(f"创建OAuth回调服务器,监听端口: {port}") - return server - except OSError as e: - log.error(f"创建端口{port}的服务器失败: {e}") - raise - - -class AuthCallbackHandler(BaseHTTPRequestHandler): - """OAuth回调处理器""" - - def do_GET(self): - query_components = parse_qs(urlparse(self.path).query) - code = query_components.get("code", [None])[0] - state = query_components.get("state", [None])[0] - - log.info(f"收到OAuth回调: code={'已获取' if code else '未获取'}, state={state}") - - if code and state and state in auth_flows: - # 更新流程状态 - auth_flows[state]["code"] = code - auth_flows[state]["completed"] = True - - log.info(f"OAuth回调成功处理: state={state}") - - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - # 成功页面 - self.wfile.write( - b"

OAuth authentication successful!

You can close this window. Please return to the original page and click 'Get Credentials' button.

" - ) - else: - self.send_response(400) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write(b"

Authentication failed.

Please try again.

") - - def log_message(self, format, *args): - # 减少日志噪音 - pass - - -async def create_auth_url( - project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli" -) -> Dict[str, Any]: - """创建认证URL,支持动态端口分配""" - try: - # 动态分配端口 - callback_port = await find_available_port() - callback_url = f"http://{CALLBACK_HOST}:{callback_port}" - - # 立即启动回调服务器 - try: - callback_server = create_callback_server(callback_port) - # 在后台线程中运行服务器 - server_thread = threading.Thread( - target=callback_server.serve_forever, - daemon=True, - name=f"OAuth-Server-{callback_port}", - ) - server_thread.start() - log.info(f"OAuth回调服务器已启动,端口: {callback_port}") - except Exception as e: - log.error(f"启动回调服务器失败: {e}") - return { - "success": False, - "error": f"无法启动OAuth回调服务器,端口{callback_port}: {str(e)}", - } - - # 创建OAuth流程 - # 根据模式选择配置 - if mode == "antigravity": - client_id = ANTIGRAVITY_CLIENT_ID - client_secret = ANTIGRAVITY_CLIENT_SECRET - scopes = ANTIGRAVITY_SCOPES - else: - client_id = CLIENT_ID - client_secret = CLIENT_SECRET - scopes = SCOPES - - flow = Flow( - client_id=client_id, - client_secret=client_secret, - scopes=scopes, - redirect_uri=callback_url, - ) - - # 生成状态标识符,包含用户会话信息 - if user_session: - state = f"{user_session}_{str(uuid.uuid4())}" - else: - state = str(uuid.uuid4()) - - # 生成认证URL - auth_url = flow.get_auth_url(state=state) - - # 严格控制认证流程数量 - 超过限制时立即清理最旧的 - if len(auth_flows) >= MAX_AUTH_FLOWS: - # 清理最旧的认证流程 - oldest_state = min(auth_flows.keys(), key=lambda k: auth_flows[k].get("created_at", 0)) - try: - # 清理服务器资源 - old_flow = auth_flows[oldest_state] - if old_flow.get("server"): - server = old_flow["server"] - port = old_flow.get("callback_port") - async_shutdown_server(server, port) - except Exception as e: - log.warning(f"Failed to cleanup old auth flow {oldest_state}: {e}") - - del auth_flows[oldest_state] - log.debug(f"Removed oldest auth flow: {oldest_state}") - - # 保存流程状态 - auth_flows[state] = { - "flow": flow, - "project_id": project_id, # 可能为None,稍后在回调时确定 - "user_session": user_session, - "callback_port": callback_port, # 存储分配的端口 - "callback_url": callback_url, # 存储完整回调URL - "server": callback_server, # 存储服务器实例 - "server_thread": server_thread, # 存储服务器线程 - "code": None, - "completed": False, - "created_at": time.time(), - "auto_project_detection": project_id is None, # 标记是否需要自动检测项目ID - "mode": mode, # 凭证模式 - } - - # 清理过期的流程(30分钟) - cleanup_expired_flows() - - log.info(f"OAuth流程已创建: state={state}, project_id={project_id}") - log.info(f"用户需要访问认证URL,然后OAuth会回调到 {callback_url}") - log.info(f"为此认证流程分配的端口: {callback_port}") - - return { - "auth_url": auth_url, - "state": state, - "callback_port": callback_port, - "success": True, - "auto_project_detection": project_id is None, - "detected_project_id": project_id, - } - - except Exception as e: - log.error(f"创建认证URL失败: {e}") - return {"success": False, "error": str(e)} - - -def wait_for_callback_sync(state: str, timeout: int = 300) -> Optional[str]: - """同步等待OAuth回调完成,使用对应流程的专用服务器""" - if state not in auth_flows: - log.error(f"未找到状态为 {state} 的认证流程") - return None - - flow_data = auth_flows[state] - callback_port = flow_data["callback_port"] - - # 服务器已经在create_auth_url时启动了,这里只需要等待 - log.info(f"等待OAuth回调完成,端口: {callback_port}") - - # 等待回调完成 - start_time = time.time() - while time.time() - start_time < timeout: - if flow_data.get("code"): - log.info("OAuth回调成功完成") - return flow_data["code"] - time.sleep(0.5) # 每0.5秒检查一次 - - # 刷新flow_data引用 - if state in auth_flows: - flow_data = auth_flows[state] - - log.warning(f"等待OAuth回调超时 ({timeout}秒)") - return None - - -async def complete_auth_flow( - project_id: Optional[str] = None, user_session: str = None -) -> Dict[str, Any]: - """完成认证流程并保存凭证,支持自动检测项目ID""" - try: - # 查找对应的认证流程 - state = None - flow_data = None - - # 如果指定了project_id,先尝试匹配指定的项目 - if project_id: - for s, data in auth_flows.items(): - if data["project_id"] == project_id: - # 如果指定了用户会话,优先匹配相同会话的流程 - if user_session and data.get("user_session") == user_session: - state = s - flow_data = data - break - # 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的 - elif not state: - state = s - flow_data = data - - # 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程 - if not state: - for s, data in auth_flows.items(): - if data.get("auto_project_detection", False): - # 如果指定了用户会话,优先匹配相同会话的流程 - if user_session and data.get("user_session") == user_session: - state = s - flow_data = data - break - # 使用第一个找到的需要自动检测的流程 - elif not state: - state = s - flow_data = data - - if not state or not flow_data: - return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"} - - if not project_id: - project_id = flow_data.get("project_id") - if not project_id: - project_id = DEFAULT_PROJECT_ID - log.warning(f"未获取到project_id,使用默认project_id: {project_id}") - - flow = flow_data["flow"] - - # 如果还没有授权码,需要等待回调 - if not flow_data.get("code"): - log.info(f"等待用户完成OAuth授权 (state: {state})") - auth_code = wait_for_callback_sync(state) - - if not auth_code: - return { - "success": False, - "error": "未接收到授权回调,请确保完成了浏览器中的OAuth认证", - } - - # 更新流程数据 - auth_flows[state]["code"] = auth_code - auth_flows[state]["completed"] = True - else: - auth_code = flow_data["code"] - - # 使用认证代码获取凭证 - with _OAuthLibPatcher(): - try: - credentials = await flow.exchange_code(auth_code) - # credentials 已经在 exchange_code 中获得 - - # 如果需要自动检测项目ID且没有提供项目ID - if flow_data.get("auto_project_detection", False) and not project_id: - log.info("尝试通过API获取用户项目列表...") - log.info(f"使用的token: {credentials.access_token[:20]}...") - log.info(f"Token过期时间: {credentials.expires_at}") - user_projects = await get_user_projects(credentials) - - if user_projects: - # 如果只有一个项目,自动使用 - if len(user_projects) == 1: - # Google API returns projectId in camelCase - project_id = user_projects[0].get("projectId") - if project_id: - flow_data["project_id"] = project_id - log.info(f"自动选择唯一项目: {project_id}") - # 如果有多个项目,尝试选择默认项目 - else: - project_id = await select_default_project(user_projects) - if project_id: - flow_data["project_id"] = project_id - log.info(f"自动选择默认项目: {project_id}") - else: - # 返回项目列表让用户选择 - return { - "success": False, - "error": "请从以下项目中选择一个", - "requires_project_selection": True, - "available_projects": [ - { - # Google API returns projectId in camelCase - "project_id": p.get("projectId"), - "name": p.get("displayName") or p.get("projectId"), - "projectNumber": p.get("projectNumber"), - } - for p in user_projects - ], - } - else: - # 如果无法获取项目列表,使用默认project_id - project_id = DEFAULT_PROJECT_ID - flow_data["project_id"] = project_id - log.warning(f"无法获取项目列表,使用默认project_id: {project_id}") - - # 如果仍然没有项目ID,返回错误 - if not project_id: - project_id = DEFAULT_PROJECT_ID - flow_data["project_id"] = project_id - log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}") - - # 保存凭证 - saved_filename = await save_credentials(credentials, project_id) - - # 准备返回的凭证数据 - creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli") - - # 清理使用过的流程 - _cleanup_auth_flow_server(state) - - log.info("OAuth认证成功,凭证已保存") - return { - "success": True, - "credentials": creds_data, - "file_path": saved_filename, - "auto_detected_project": flow_data.get("auto_project_detection", False), - } - - except Exception as e: - log.error(f"获取凭证失败: {e}") - return {"success": False, "error": f"获取凭证失败: {str(e)}"} - - except Exception as e: - log.error(f"完成认证流程失败: {e}") - return {"success": False, "error": str(e)} - - -async def asyncio_complete_auth_flow( - project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli" -) -> Dict[str, Any]: - """异步完成认证流程,支持自动检测项目ID""" - try: - log.info( - f"asyncio_complete_auth_flow开始执行: project_id={project_id}, user_session={user_session}" - ) - - # 查找对应的认证流程 - state = None - flow_data = None - - log.debug(f"当前所有auth_flows: {list(auth_flows.keys())}") - - # 如果指定了project_id,先尝试匹配指定的项目 - if project_id: - log.info(f"尝试匹配指定的项目ID: {project_id}") - for s, data in auth_flows.items(): - if data["project_id"] == project_id: - # 如果指定了用户会话,优先匹配相同会话的流程 - if user_session and data.get("user_session") == user_session: - state = s - flow_data = data - log.info(f"找到匹配的用户会话: {s}") - break - # 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的 - elif not state: - state = s - flow_data = data - log.info(f"找到匹配的项目ID: {s}") - - # 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程 - if not state: - log.info("没有找到指定项目的流程,查找自动检测流程") - # 首先尝试找到已完成的流程(有授权码的) - completed_flows = [] - for s, data in auth_flows.items(): - if data.get("auto_project_detection", False): - if user_session and data.get("user_session") == user_session: - if data.get("code"): # 优先选择已完成的 - completed_flows.append((s, data, data.get("created_at", 0))) - - # 如果有已完成的流程,选择最新的 - if completed_flows: - completed_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序 - state, flow_data, _ = completed_flows[0] - log.info(f"找到已完成的最新认证流程: {state}") - else: - # 如果没有已完成的,找最新的未完成流程 - pending_flows = [] - for s, data in auth_flows.items(): - if data.get("auto_project_detection", False): - if user_session and data.get("user_session") == user_session: - pending_flows.append((s, data, data.get("created_at", 0))) - elif not user_session: - pending_flows.append((s, data, data.get("created_at", 0))) - - if pending_flows: - pending_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序 - state, flow_data, _ = pending_flows[0] - log.info(f"找到最新的待完成认证流程: {state}") - - if not state or not flow_data: - log.error(f"未找到认证流程: state={state}, flow_data存在={bool(flow_data)}") - log.debug(f"当前所有flow_data: {list(auth_flows.keys())}") - return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"} - - log.info(f"找到认证流程: state={state}") - log.info( - f"flow_data内容: project_id={flow_data.get('project_id')}, auto_project_detection={flow_data.get('auto_project_detection')}" - ) - log.info(f"传入的project_id参数: {project_id}") - - # 如果需要自动检测项目ID且没有提供项目ID - log.info( - f"检查auto_project_detection条件: auto_project_detection={flow_data.get('auto_project_detection', False)}, not project_id={not project_id}" - ) - if flow_data.get("auto_project_detection", False) and not project_id: - log.info("跳过自动检测项目ID,进入等待阶段") - elif not project_id: - log.info("进入project_id检查分支") - project_id = flow_data.get("project_id") - if not project_id: - project_id = DEFAULT_PROJECT_ID - flow_data["project_id"] = project_id - log.warning(f"缺少项目ID,使用默认project_id: {project_id}") - else: - log.info(f"使用提供的项目ID: {project_id}") - - # 检查是否已经有授权码 - log.info("开始检查OAuth授权码...") - log.info(f"等待state={state}的授权回调,回调端口: {flow_data.get('callback_port')}") - log.info(f"当前flow_data状态: completed={flow_data.get('completed')}, code存在={bool(flow_data.get('code'))}") - max_wait_time = 60 # 最多等待60秒 - wait_interval = 1 # 每秒检查一次 - waited = 0 - - while waited < max_wait_time: - if flow_data.get("code"): - log.info(f"检测到OAuth授权码,开始处理凭证 (等待时间: {waited}秒)") - break - - # 每5秒输出一次提示 - if waited % 5 == 0 and waited > 0: - log.info(f"仍在等待OAuth授权... ({waited}/{max_wait_time}秒)") - log.debug(f"当前state: {state}, flow_data keys: {list(flow_data.keys())}") - - # 异步等待 - await asyncio.sleep(wait_interval) - waited += wait_interval - - # 刷新flow_data引用,因为可能被回调更新了 - if state in auth_flows: - flow_data = auth_flows[state] - - if not flow_data.get("code"): - log.error(f"等待OAuth回调超时,等待了{waited}秒") - return { - "success": False, - "error": "等待OAuth回调超时,请确保完成了浏览器中的认证并看到成功页面", - } - - flow = flow_data["flow"] - auth_code = flow_data["code"] - - log.info(f"开始使用授权码获取凭证: code={'***' + auth_code[-4:] if auth_code else 'None'}") - - # 使用认证代码获取凭证 - with _OAuthLibPatcher(): - try: - log.info("调用flow.exchange_code...") - credentials = await flow.exchange_code(auth_code) - log.info( - f"成功获取凭证,token前缀: {credentials.access_token[:20] if credentials.access_token else 'None'}..." - ) - - log.info( - f"检查是否需要项目检测: auto_project_detection={flow_data.get('auto_project_detection')}, project_id={project_id}" - ) - - # 检查凭证模式 - cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode - if cred_mode == "antigravity": - log.info("Antigravity模式:从API获取project_id...") - # 使用API获取project_id - antigravity_url = await get_antigravity_api_url() - project_id, subscription_tier = await fetch_project_id_and_tier( - credentials.access_token, - ANTIGRAVITY_USER_AGENT, - antigravity_url - ) - if project_id: - log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}") - else: - project_id = DEFAULT_PROJECT_ID - log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}") - - # 保存antigravity凭证 - saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier) - - # 准备返回的凭证数据 - creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier) - - # 清理使用过的流程 - _cleanup_auth_flow_server(state) - - log.info("Antigravity OAuth认证成功,凭证已保存") - return { - "success": True, - "credentials": creds_data, - "file_path": saved_filename, - "auto_detected_project": False, - "mode": "antigravity", - } - - # 如果需要自动检测项目ID且没有提供项目ID(标准模式) - if flow_data.get("auto_project_detection", False) and not project_id: - log.info("标准模式:从API获取project_id...") - # 使用API获取project_id(使用标准模式的User-Agent) - code_assist_url = await get_code_assist_endpoint() - project_id, subscription_tier = await fetch_project_id_and_tier( - credentials.access_token, - GEMINICLI_USER_AGENT, - code_assist_url - ) - if project_id: - flow_data["project_id"] = project_id - log.info(f"成功从API获取project_id: {project_id}") - # 自动启用必需的API服务 - log.info("正在自动启用必需的API服务...") - await enable_required_apis(credentials, project_id) - else: - log.warning("无法从API获取project_id,回退到项目列表获取方式") - # 回退到原来的项目列表获取方式 - user_projects = await get_user_projects(credentials) - - if user_projects: - # 如果只有一个项目,自动使用 - if len(user_projects) == 1: - # Google API returns projectId in camelCase - project_id = user_projects[0].get("projectId") - if project_id: - flow_data["project_id"] = project_id - log.info(f"自动选择唯一项目: {project_id}") - # 自动启用必需的API服务 - log.info("正在自动启用必需的API服务...") - await enable_required_apis(credentials, project_id) - # 如果有多个项目,尝试选择默认项目 - else: - project_id = await select_default_project(user_projects) - if project_id: - flow_data["project_id"] = project_id - log.info(f"自动选择默认项目: {project_id}") - # 自动启用必需的API服务 - log.info("正在自动启用必需的API服务...") - await enable_required_apis(credentials, project_id) - else: - # 返回项目列表让用户选择 - return { - "success": False, - "error": "请从以下项目中选择一个", - "requires_project_selection": True, - "available_projects": [ - { - # Google API returns projectId in camelCase - "project_id": p.get("projectId"), - "name": p.get("displayName") or p.get("projectId"), - "projectNumber": p.get("projectNumber"), - } - for p in user_projects - ], - } - else: - # 如果无法获取项目列表,使用默认project_id - project_id = DEFAULT_PROJECT_ID - flow_data["project_id"] = project_id - log.warning(f"无法获取项目列表,使用默认project_id: {project_id}") - elif project_id: - # 如果已经有项目ID(手动提供或环境检测),也尝试启用API服务 - log.info("正在为已提供的项目ID自动启用必需的API服务...") - await enable_required_apis(credentials, project_id) - - # 如果仍然没有项目ID,返回错误 - if not project_id: - project_id = DEFAULT_PROJECT_ID - flow_data["project_id"] = project_id - log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}") - - # 保存凭证 - saved_filename = await save_credentials(credentials, project_id) - - # 准备返回的凭证数据 - creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli") - - # 清理使用过的流程 - _cleanup_auth_flow_server(state) - - log.info("OAuth认证成功,凭证已保存") - return { - "success": True, - "credentials": creds_data, - "file_path": saved_filename, - "auto_detected_project": flow_data.get("auto_project_detection", False), - } - - except Exception as e: - log.error(f"获取凭证失败: {e}") - return {"success": False, "error": f"获取凭证失败: {str(e)}"} - - except Exception as e: - log.error(f"异步完成认证流程失败: {e}") - return {"success": False, "error": str(e)} - - -async def complete_auth_flow_from_callback_url( - callback_url: str, project_id: Optional[str] = None, mode: str = "geminicli" -) -> Dict[str, Any]: - """从回调URL直接完成认证流程,无需启动本地服务器""" - try: - log.info(f"开始从回调URL完成认证: {callback_url}") - - # 解析回调URL - parsed_url = urlparse(callback_url) - query_params = parse_qs(parsed_url.query) - - # 验证必要参数 - if "state" not in query_params or "code" not in query_params: - return {"success": False, "error": "回调URL缺少必要参数 (state 或 code)"} - - state = query_params["state"][0] - code = query_params["code"][0] - - log.info(f"从URL解析到: state={state}, code=xxx...") - - # 检查是否有对应的认证流程 - if state not in auth_flows: - return { - "success": False, - "error": f"未找到对应的认证流程,请先启动认证 (state: {state})", - } - - flow_data = auth_flows[state] - flow = flow_data["flow"] - - # 构造回调URL(使用flow中存储的redirect_uri) - redirect_uri = flow.redirect_uri - log.info(f"使用redirect_uri: {redirect_uri}") - - try: - # 使用authorization code获取token - credentials = await flow.exchange_code(code) - log.info("成功获取访问令牌") - - # 检查凭证模式 - cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode - if cred_mode == "antigravity": - log.info("Antigravity模式(从回调URL):从API获取project_id...") - # 使用API获取project_id - antigravity_url = await get_antigravity_api_url() - project_id, subscription_tier = await fetch_project_id_and_tier( - credentials.access_token, - ANTIGRAVITY_USER_AGENT, - antigravity_url - ) - if project_id: - log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}") - else: - project_id = DEFAULT_PROJECT_ID - log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}") - - # 保存antigravity凭证 - saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier) - - # 准备返回的凭证数据 - creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier) - - # 清理使用过的流程 - _cleanup_auth_flow_server(state) - - log.info("从回调URL完成Antigravity OAuth认证成功,凭证已保存") - return { - "success": True, - "credentials": creds_data, - "file_path": saved_filename, - "auto_detected_project": False, - "mode": "antigravity", - } - - # 标准模式的项目ID处理逻辑 - detected_project_id = None - auto_detected = False - subscription_tier = None - - if not project_id: - # 尝试使用fetch_project_id_and_tier自动获取项目ID - try: - log.info("标准模式:从API获取project_id...") - code_assist_url = await get_code_assist_endpoint() - detected_project_id, subscription_tier = await fetch_project_id_and_tier( - credentials.access_token, - GEMINICLI_USER_AGENT, - code_assist_url - ) - if detected_project_id: - auto_detected = True - log.info(f"成功从API获取project_id: {detected_project_id}, tier: {subscription_tier}") - else: - log.warning("无法从API获取project_id,回退到项目列表获取方式") - # 回退到原来的项目列表获取方式 - projects = await get_user_projects(credentials) - if projects: - if len(projects) == 1: - # 只有一个项目,自动使用 - # Google API returns projectId in camelCase - detected_project_id = projects[0]["projectId"] - auto_detected = True - log.info(f"自动检测到唯一项目ID: {detected_project_id}") - else: - # 多个项目,自动选择第一个 - # Google API returns projectId in camelCase - detected_project_id = projects[0]["projectId"] - auto_detected = True - log.info( - f"检测到{len(projects)}个项目,自动选择第一个: {detected_project_id}" - ) - log.debug(f"其他可用项目: {[p['projectId'] for p in projects[1:]]}") - else: - # 没有项目访问权限,使用默认project_id - detected_project_id = DEFAULT_PROJECT_ID - auto_detected = False - log.warning(f"未检测到可访问项目,使用默认project_id: {detected_project_id}") - except Exception as e: - log.warning(f"自动检测项目ID失败: {e},使用默认project_id") - detected_project_id = DEFAULT_PROJECT_ID - auto_detected = False - else: - detected_project_id = project_id - - # 启用必需的API服务 - if detected_project_id: - try: - log.info(f"正在为项目 {detected_project_id} 启用必需的API服务...") - await enable_required_apis(credentials, detected_project_id) - except Exception as e: - log.warning(f"启用API服务失败: {e}") - - # 保存凭证 - saved_filename = await save_credentials(credentials, detected_project_id, subscription_tier=subscription_tier) - - # 准备返回的凭证数据 - creds_data = _prepare_credentials_data(credentials, detected_project_id, mode="geminicli", subscription_tier=subscription_tier) - - # 清理使用过的流程 - _cleanup_auth_flow_server(state) - - log.info("从回调URL完成OAuth认证成功,凭证已保存") - return { - "success": True, - "credentials": creds_data, - "file_path": saved_filename, - "auto_detected_project": auto_detected, - } - - except Exception as e: - log.error(f"从回调URL获取凭证失败: {e}") - return {"success": False, "error": f"获取凭证失败: {str(e)}"} - - except Exception as e: - log.error(f"从回调URL完成认证流程失败: {e}") - return {"success": False, "error": str(e)} - - -async def save_credentials(creds: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> str: - """通过统一存储系统保存凭证""" - # 生成文件名(使用project_id和时间戳) - timestamp = int(time.time()) - - # antigravity模式使用特殊前缀 - if mode == "antigravity": - filename = f"ag_{project_id}-{timestamp}.json" - else: - filename = f"{project_id}-{timestamp}.json" - - # 准备凭证数据 - creds_data = _prepare_credentials_data(creds, project_id, mode, subscription_tier) - - # 通过存储适配器保存 - storage_adapter = await get_storage_adapter() - success = await storage_adapter.store_credential(filename, creds_data, mode=mode) - - if success: - # 创建默认状态记录 - try: - default_state = { - "error_codes": [], - "disabled": False, - "last_success": time.time(), - "user_email": None, - "tier": subscription_tier, - } - await storage_adapter.update_credential_state(filename, default_state, mode=mode) - log.info(f"凭证和状态已保存到: {filename} (mode={mode})") - except Exception as e: - log.warning(f"创建默认状态记录失败 {filename}: {e}") - - return filename - else: - raise Exception(f"保存凭证失败: {filename}") - - -def async_shutdown_server(server, port): - """异步关闭OAuth回调服务器,避免阻塞主流程""" - - def shutdown_server_async(): - try: - # 设置一个标志来跟踪关闭状态 - shutdown_completed = threading.Event() - - def do_shutdown(): - try: - server.shutdown() - server.server_close() - shutdown_completed.set() - log.info(f"已关闭端口 {port} 的OAuth回调服务器") - except Exception as e: - shutdown_completed.set() - log.debug(f"关闭服务器时出错: {e}") - - # 在单独线程中执行关闭操作 - shutdown_worker = threading.Thread(target=do_shutdown, daemon=True) - shutdown_worker.start() - - # 等待最多5秒,如果超时就放弃等待 - if shutdown_completed.wait(timeout=5): - log.debug(f"端口 {port} 服务器关闭完成") - else: - log.warning(f"端口 {port} 服务器关闭超时,但不阻塞主流程") - - except Exception as e: - log.debug(f"异步关闭服务器时出错: {e}") - - # 在后台线程中关闭服务器,不阻塞主流程 - shutdown_thread = threading.Thread(target=shutdown_server_async, daemon=True) - shutdown_thread.start() - log.debug(f"开始异步关闭端口 {port} 的OAuth回调服务器") - - -def cleanup_expired_flows(): - """清理过期的认证流程""" - current_time = time.time() - EXPIRY_TIME = 600 # 10分钟过期 - - # 直接遍历删除,避免创建额外列表 - states_to_remove = [ - state - for state, flow_data in auth_flows.items() - if current_time - flow_data["created_at"] > EXPIRY_TIME - ] - - # 批量清理,提高效率 - cleaned_count = 0 - for state in states_to_remove: - flow_data = auth_flows.get(state) - if flow_data: - # 快速关闭可能存在的服务器 - try: - if flow_data.get("server"): - server = flow_data["server"] - port = flow_data.get("callback_port") - async_shutdown_server(server, port) - except Exception as e: - log.debug(f"清理过期流程时启动异步关闭服务器失败: {e}") - - # 显式清理流程数据,释放内存 - flow_data.clear() - del auth_flows[state] - cleaned_count += 1 - - if cleaned_count > 0: - log.info(f"清理了 {cleaned_count} 个过期的认证流程") - - # 更积极的垃圾回收触发条件 - if len(auth_flows) > 20: # 降低阈值 - import gc - - gc.collect() - log.debug(f"触发垃圾回收,当前活跃认证流程数: {len(auth_flows)}") - - -def get_auth_status(project_id: str) -> Dict[str, Any]: - """获取认证状态""" - for state, flow_data in auth_flows.items(): - if flow_data["project_id"] == project_id: - return { - "status": "completed" if flow_data["completed"] else "pending", - "state": state, - "created_at": flow_data["created_at"], - } - - return {"status": "not_found"} - - -# 鉴权功能 - 使用更小的数据结构 -auth_tokens = {} # 存储有效的认证令牌 -TOKEN_EXPIRY = 3600 # 1小时令牌过期时间 - - -async def verify_password(password: str) -> bool: - """验证密码(面板登录使用)""" - from config import get_panel_password - - correct_password = await get_panel_password() - return password == correct_password diff --git a/src/converter/anthropic2gemini.py b/src/converter/anthropic2gemini.py deleted file mode 100644 index 5c537b11c97b6bc25dad84df8a452cf9d2867e39..0000000000000000000000000000000000000000 --- a/src/converter/anthropic2gemini.py +++ /dev/null @@ -1,1288 +0,0 @@ -""" -Anthropic 到 Gemini 格式转换器 - -提供请求体、响应和流式转换的完整功能。 -""" -from __future__ import annotations - -import json -import os -import uuid -from typing import Any, AsyncIterator, Dict, List, Optional - -from fastapi import Response -from log import log -from src.converter.utils import merge_system_messages - -from src.converter.thoughtSignature_fix import ( - decode_tool_id_and_signature, - is_internal_placeholder_text, - is_skip_thought_signature_placeholder, - SKIP_THOUGHT_SIGNATURE_VALIDATOR, -) - -DEFAULT_TEMPERATURE = 0.4 -_DEBUG_TRUE = {"1", "true", "yes", "on"} - -# ============================================================================ -# Thinking 块验证和清理 -# ============================================================================ - -# 最小有效签名长度 -MIN_SIGNATURE_LENGTH = 10 - - -def has_valid_thoughtsignature(block: Dict[str, Any]) -> bool: - """ - 检查 thinking 块是否有有效签名 - - Args: - block: content block 字典 - - Returns: - bool: 是否有有效签名 - """ - if not isinstance(block, dict): - return True - - block_type = block.get("type") - if block_type not in ("thinking", "redacted_thinking"): - return True # 非 thinking 块默认有效 - - thinking = block.get("thinking", "") - thoughtsignature = block.get("thoughtSignature") - - # 空 thinking + 任意 thoughtsignature = 有效 (trailing signature case) - if not thinking and thoughtsignature is not None: - return True - - # 有内容 + 足够长度的 thoughtsignature = 有效 - if thoughtsignature and isinstance(thoughtsignature, str) and len(thoughtsignature) >= MIN_SIGNATURE_LENGTH: - return True - - return False - - -def sanitize_thinking_block(block: Dict[str, Any]) -> Dict[str, Any]: - """ - 清理 thinking 块,只保留必要字段(移除 cache_control 等) - - Args: - block: content block 字典 - - Returns: - 清理后的 block 字典 - """ - if not isinstance(block, dict): - return block - - block_type = block.get("type") - if block_type not in ("thinking", "redacted_thinking"): - return block - - # 重建块,移除额外字段 - sanitized: Dict[str, Any] = { - "type": block_type, - "thinking": block.get("thinking", "") - } - - thoughtsignature = block.get("thoughtSignature") - if thoughtsignature: - sanitized["thoughtSignature"] = thoughtsignature - - return sanitized - - -def remove_trailing_unsigned_thinking(blocks: List[Dict[str, Any]]) -> None: - """ - 移除尾部的无签名 thinking 块 - - Args: - blocks: content blocks 列表 (会被修改) - """ - if not blocks: - return - - # 从后向前扫描 - end_index = len(blocks) - for i in range(len(blocks) - 1, -1, -1): - block = blocks[i] - if not isinstance(block, dict): - break - - block_type = block.get("type") - if block_type in ("thinking", "redacted_thinking"): - if not has_valid_thoughtsignature(block): - end_index = i - else: - break # 遇到有效签名的 thinking 块,停止 - else: - break # 遇到非 thinking 块,停止 - - if end_index < len(blocks): - removed = len(blocks) - end_index - del blocks[end_index:] - log.debug(f"Removed {removed} trailing unsigned thinking block(s)") - - -def filter_invalid_thinking_blocks(messages: List[Dict[str, Any]]) -> None: - """ - 过滤消息中的无效 thinking 块,并清理所有 thinking 块的额外字段(如 cache_control) - - Args: - messages: Anthropic messages 列表 (会被修改) - """ - total_filtered = 0 - - for msg in messages: - # 只处理 assistant 和 model 消息 - role = msg.get("role", "") - if role not in ("assistant", "model"): - continue - - content = msg.get("content") - if not isinstance(content, list): - continue - - original_len = len(content) - new_blocks: List[Dict[str, Any]] = [] - - for block in content: - if not isinstance(block, dict): - new_blocks.append(block) - continue - - block_type = block.get("type") - if block_type not in ("thinking", "redacted_thinking"): - new_blocks.append(block) - continue - - # 所有 thinking 块都需要清理(移除 cache_control 等额外字段) - # 检查 thinking 块的有效性 - if has_valid_thoughtsignature(block): - # 有效签名,清理后保留 - new_blocks.append(sanitize_thinking_block(block)) - else: - # 无效签名,将内容转换为 text 块 - thinking_text = block.get("thinking", "") - if thinking_text and str(thinking_text).strip(): - log.info( - f"[Claude-Handler] Converting thinking block with invalid thoughtSignature to text. " - f"Content length: {len(thinking_text)} chars" - ) - new_blocks.append({"type": "text", "text": thinking_text}) - else: - log.debug("[Claude-Handler] Dropping empty thinking block with invalid thoughtSignature") - - msg["content"] = new_blocks - filtered_count = original_len - len(new_blocks) - total_filtered += filtered_count - - # 如果过滤后为空,添加一个空文本块以保持消息有效 - if not new_blocks: - msg["content"] = [{"type": "text", "text": ""}] - - if total_filtered > 0: - log.debug(f"Filtered {total_filtered} invalid thinking block(s) from history") - - -# ============================================================================ -# 请求验证和提取 -# ============================================================================ - - -def _anthropic_debug_enabled() -> bool: - """检查是否启用 Anthropic 调试模式""" - return str(os.getenv("ANTHROPIC_DEBUG", "true")).strip().lower() in _DEBUG_TRUE - - -def _cached_content_token_count(usage_metadata: Any) -> int: - if not isinstance(usage_metadata, dict): - return 0 - return int(usage_metadata.get("cachedContentTokenCount", 0) or 0) - - -def _anthropic_usage_from_metadata(usage_metadata: Any) -> Dict[str, int]: - if not isinstance(usage_metadata, dict): - return {"input_tokens": 0, "output_tokens": 0} - - prompt_tokens_total = int(usage_metadata.get("promptTokenCount", 0) or 0) - cached_tokens = _cached_content_token_count(usage_metadata) - usage = { - "input_tokens": max(prompt_tokens_total - cached_tokens, 0), - "output_tokens": int(usage_metadata.get("candidatesTokenCount", 0) or 0), - } - - if cached_tokens > 0: - usage["cache_read_input_tokens"] = cached_tokens - - return usage - - -def _is_non_whitespace_text(value: Any) -> bool: - """ - 判断文本是否包含"非空白"内容。 - - 说明:下游(Antigravity/Claude 兼容层)会对纯 text 内容块做校验: - - text 不能为空字符串 - - text 不能仅由空白字符(空格/换行/制表等)组成 - """ - if value is None: - return False - try: - return bool(str(value).strip()) - except Exception: - return False - - -def _remove_nulls_for_tool_input(value: Any) -> Any: - """ - 递归移除 dict/list 中值为 null/None 的字段/元素。 - - 背景:Roo/Kilo 在 Anthropic native tool 路径下,若收到 tool_use.input 中包含 null, - 可能会把 null 当作真实入参执行(例如"在 null 中搜索")。 - """ - if isinstance(value, dict): - cleaned: Dict[str, Any] = {} - for k, v in value.items(): - if v is None: - continue - cleaned[k] = _remove_nulls_for_tool_input(v) - return cleaned - - if isinstance(value, list): - cleaned_list = [] - for item in value: - if item is None: - continue - cleaned_list.append(_remove_nulls_for_tool_input(item)) - return cleaned_list - - return value - -# ============================================================================ -# 2. JSON Schema 清理 -# ============================================================================ - -def clean_json_schema(schema: Any) -> Any: - """ - 清理 JSON Schema,移除下游不支持的字段,并把验证要求追加到 description。 - """ - if not isinstance(schema, dict): - return schema - - # 下游不支持的字段 - unsupported_keys = { - "$schema", "$id", "$ref", "$defs", "definitions", "title", - "example", "examples", "readOnly", "writeOnly", "default", - "exclusiveMaximum", "exclusiveMinimum", "oneOf", "anyOf", "allOf", - "const", "additionalItems", "contains", "patternProperties", - "dependencies", "propertyNames", "if", "then", "else", - "contentEncoding", "contentMediaType", "nullable", - } - - validation_fields = { - "minLength": "minLength", - "maxLength": "maxLength", - "minimum": "minimum", - "maximum": "maximum", - "minItems": "minItems", - "maxItems": "maxItems", - } - fields_to_remove = {"additionalProperties"} - - validations: List[str] = [] - for field, label in validation_fields.items(): - if field in schema: - validations.append(f"{label}: {schema[field]}") - - cleaned: Dict[str, Any] = {} - for key, value in schema.items(): - if key in unsupported_keys or key in fields_to_remove or key in validation_fields: - continue - - if key == "type" and isinstance(value, list): - # type: ["string", "null"] -> type: "string", nullable: true - has_null = any( - isinstance(t, str) and t.strip() and t.strip().lower() == "null" for t in value - ) - non_null_types = [ - t.strip() - for t in value - if isinstance(t, str) and t.strip() and t.strip().lower() != "null" - ] - - cleaned[key] = non_null_types[0] if non_null_types else "string" - continue - - if key == "description" and validations: - cleaned[key] = f"{value} ({', '.join(validations)})" - elif isinstance(value, dict): - cleaned[key] = clean_json_schema(value) - elif isinstance(value, list): - cleaned[key] = [clean_json_schema(item) if isinstance(item, dict) else item for item in value] - else: - cleaned[key] = value - - if validations and "description" not in cleaned: - cleaned["description"] = f"Validation: {', '.join(validations)}" - - # 如果有 properties 但没有显式 type,则补齐为 object - if "properties" in cleaned and "type" not in cleaned: - cleaned["type"] = "object" - - if ( - isinstance(schema.get("properties"), dict) - and isinstance(cleaned.get("required"), list) - ): - nullable_fields = { - name - for name, prop in schema["properties"].items() - if isinstance(prop, dict) - and isinstance(prop.get("type"), list) - and any(str(t).lower() == "null" for t in prop["type"]) - } - if nullable_fields: - cleaned["required"] = [ - item for item in cleaned["required"] - if item not in nullable_fields - ] - if not cleaned["required"]: - cleaned.pop("required", None) - - return cleaned - - -# ============================================================================ -# 4. Tools 转换 -# ============================================================================ - -def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]: - """ - 将 Anthropic tools[] 转换为下游 tools(functionDeclarations)结构。 - """ - if not anthropic_tools: - return None - - gemini_tools: List[Dict[str, Any]] = [] - for tool in anthropic_tools: - name = tool.get("name", "nameless_function") - description = tool.get("description", "") - input_schema = tool.get("input_schema", {}) or {} - parameters = clean_json_schema(input_schema) - - gemini_tools.append( - { - "functionDeclarations": [ - { - "name": name, - "description": description, - "parametersJsonSchema": parameters, - } - ] - } - ) - - return gemini_tools or None - - -# ============================================================================ -# 5. Messages 转换 -# ============================================================================ - -def _extract_tool_result_output(content: Any) -> str: - """从 tool_result.content 中提取输出字符串""" - if isinstance(content, list): - if not content: - return "" - first = content[0] - if isinstance(first, dict) and first.get("type") == "text": - return str(first.get("text", "")) - return str(first) - if content is None: - return "" - return str(content) - - -def convert_messages_to_contents( - messages: List[Dict[str, Any]], - *, - include_thinking: bool = True -) -> List[Dict[str, Any]]: - """ - 将 Anthropic messages[] 转换为下游 contents[](role: user/model, parts: [])。 - - Args: - messages: Anthropic 格式的消息列表 - include_thinking: 是否包含 thinking 块 - """ - contents: List[Dict[str, Any]] = [] - - # 第一遍:构建 tool_use_id -> (name, thoughtsignature) 的映射 - # 注意:存储的是编码后的 ID(可能包含签名) - tool_use_info: Dict[str, tuple[str, Optional[str]]] = {} - for msg in messages: - raw_content = msg.get("content", "") - if isinstance(raw_content, list): - for item in raw_content: - if isinstance(item, dict) and item.get("type") == "tool_use": - encoded_tool_id = item.get("id") - tool_name = item.get("name") - if encoded_tool_id and tool_name: - # 解码获取原始ID和签名 - original_id, thoughtsignature = decode_tool_id_and_signature(encoded_tool_id) - # 存储映射:编码ID -> (name, thoughtsignature) - tool_use_info[str(encoded_tool_id)] = (tool_name, thoughtsignature) - - for msg in messages: - role = msg.get("role", "user") - - # system 消息已经由 merge_system_messages 处理,这里跳过 - if role == "system": - continue - - # 支持 'assistant' 和 'model' 角色(Google history usage) - gemini_role = "model" if role in ("assistant", "model") else "user" - raw_content = msg.get("content", "") - - parts: List[Dict[str, Any]] = [] - if isinstance(raw_content, str): - if _is_non_whitespace_text(raw_content): - parts = [{"text": str(raw_content)}] - elif isinstance(raw_content, list): - for item in raw_content: - if not isinstance(item, dict): - if _is_non_whitespace_text(item): - parts.append({"text": str(item)}) - continue - - item_type = item.get("type") - if item_type == "thinking": - # 不把客户端回传的 thinking signature 再送给 Google。 - # 这些签名很容易在中转/换号/裁剪后变成 Corrupted thought signature。 - continue - elif item_type == "redacted_thinking": - continue - elif item_type == "text": - text = item.get("text", "") - if _is_non_whitespace_text(text): - parts.append({"text": str(text)}) - elif item_type == "image": - source = item.get("source", {}) or {} - if source.get("type") == "base64": - parts.append( - { - "inlineData": { - "mimeType": source.get("media_type", "image/png"), - "data": source.get("data", ""), - } - } - ) - elif item_type == "tool_use": - encoded_id = item.get("id") or "" - original_id, _ = decode_tool_id_and_signature(encoded_id) - - fc_part: Dict[str, Any] = { - "functionCall": { - "id": original_id, # 使用原始ID,不带签名 - "name": item.get("name"), - "args": item.get("input", {}) or {}, - } - } - - fc_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR - - parts.append(fc_part) - elif item_type == "tool_result": - output = _extract_tool_result_output(item.get("content")) - encoded_tool_use_id = item.get("tool_use_id") or "" - - # 解码获取原始ID(functionResponse不需要签名) - original_tool_use_id, _ = decode_tool_id_and_signature(encoded_tool_use_id) - - # 从 tool_result 获取 name,如果没有则从映射中查找 - func_name = item.get("name") - if not func_name and encoded_tool_use_id: - # 使用编码ID查找映射 - tool_info = tool_use_info.get(str(encoded_tool_use_id)) - if tool_info: - func_name = tool_info[0] # 获取 name - if not func_name: - func_name = "unknown_function" - - parts.append( - { - "functionResponse": { - "id": original_tool_use_id, # 使用解码后的原始ID以匹配functionCall - "name": func_name, - "response": {"output": output}, - } - } - ) - else: - parts.append({"text": json.dumps(item, ensure_ascii=False)}) - else: - if _is_non_whitespace_text(raw_content): - parts = [{"text": str(raw_content)}] - - if not parts: - continue - - contents.append({"role": gemini_role, "parts": parts}) - - return contents - - -def reorganize_tool_messages(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - 重新组织消息,满足 tool_use/tool_result 约束。 - """ - tool_results: Dict[str, Dict[str, Any]] = {} - - for msg in contents: - for part in msg.get("parts", []) or []: - if isinstance(part, dict) and "functionResponse" in part: - tool_id = (part.get("functionResponse") or {}).get("id") - if tool_id: - tool_results[str(tool_id)] = part - - flattened: List[Dict[str, Any]] = [] - for msg in contents: - role = msg.get("role") - for part in msg.get("parts", []) or []: - flattened.append({"role": role, "parts": [part]}) - - new_contents: List[Dict[str, Any]] = [] - i = 0 - while i < len(flattened): - msg = flattened[i] - part = msg["parts"][0] - - if isinstance(part, dict) and "functionResponse" in part: - i += 1 - continue - - if isinstance(part, dict) and "functionCall" in part: - tool_id = (part.get("functionCall") or {}).get("id") - new_contents.append({"role": "model", "parts": [part]}) - - if tool_id is not None and str(tool_id) in tool_results: - new_contents.append({"role": "user", "parts": [tool_results[str(tool_id)]]}) - - i += 1 - continue - - new_contents.append(msg) - i += 1 - - return new_contents - - -# ============================================================================ -# 7. Tool Choice 转换 -# ============================================================================ - -def convert_tool_choice_to_tool_config(tool_choice: Any) -> Optional[Dict[str, Any]]: - """ - 将 Anthropic tool_choice 转换为 Gemini toolConfig - - Args: - tool_choice: Anthropic 格式的 tool_choice - - {"type": "auto"}: 模型自动决定是否使用工具 - - {"type": "any"}: 模型必须使用工具 - - {"type": "tool", "name": "tool_name"}: 模型必须使用指定工具 - - Returns: - Gemini 格式的 toolConfig,如果无效则返回 None - """ - if not tool_choice: - return None - - if isinstance(tool_choice, dict): - choice_type = tool_choice.get("type") - - if choice_type == "auto": - return {"functionCallingConfig": {"mode": "AUTO"}} - elif choice_type == "any": - return {"functionCallingConfig": {"mode": "ANY"}} - elif choice_type == "tool": - tool_name = tool_choice.get("name") - if tool_name: - return { - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": [tool_name], - } - } - - # 无效或不支持的 tool_choice,返回 None - return None - - -# ============================================================================ -# 8. Generation Config 构建 -# ============================================================================ - -def build_generation_config(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - 根据 Anthropic Messages 请求构造下游 generationConfig。 - - Returns: - generation_config: 生成配置字典 - """ - config: Dict[str, Any] = { - "topP": 1, - "candidateCount": 1, - "stopSequences": [ - "<|user|>", - "<|bot|>", - "<|context_request|>", - "<|endoftext|>", - "<|end_of_turn|>", - ], - } - - temperature = payload.get("temperature", None) - config["temperature"] = DEFAULT_TEMPERATURE if temperature is None else temperature - - top_p = payload.get("top_p", None) - if top_p is not None: - config["topP"] = top_p - - top_k = payload.get("top_k", None) - if top_k is not None: - config["topK"] = top_k - - max_tokens = payload.get("max_tokens") - if max_tokens is not None: - config["maxOutputTokens"] = max_tokens - - # 处理 extended thinking 参数 (plan mode) - thinking = payload.get("thinking") - is_plan_mode = False - if thinking and isinstance(thinking, dict): - thinking_type = thinking.get("type") - budget_tokens = thinking.get("budget_tokens") - - # 如果启用了 extended thinking,设置 thinkingConfig - if thinking_type == "enabled": - is_plan_mode = True - thinking_config: Dict[str, Any] = {} - - # 设置思考预算,默认使用较大的值以支持计划模式 - if budget_tokens is not None: - thinking_config["thinkingBudget"] = budget_tokens - else: - # 默认给一个较大的思考预算以支持完整的计划生成 - thinking_config["thinkingBudget"] = 48000 - - # 始终包含思考内容,这样才能看到计划 - thinking_config["includeThoughts"] = True - - config["thinkingConfig"] = thinking_config - log.info(f"[ANTHROPIC2GEMINI] Extended thinking enabled with budget: {thinking_config['thinkingBudget']}") - elif thinking_type == "disabled": - # 明确禁用思考模式 - config["thinkingConfig"] = { - "includeThoughts": False - } - log.info("[ANTHROPIC2GEMINI] Extended thinking explicitly disabled") - - stop_sequences = payload.get("stop_sequences") - if isinstance(stop_sequences, list) and stop_sequences: - config["stopSequences"] = config["stopSequences"] + [str(s) for s in stop_sequences] - elif is_plan_mode: - # Plan mode 时清空默认 stop sequences,避免过早停止 - # 默认的 stop sequences 可能会导致模型在生成计划时过早停止 - config["stopSequences"] = [] - log.info("[ANTHROPIC2GEMINI] Plan mode: cleared default stop sequences to prevent premature stopping") - - # 如果不是 plan mode 且没有自定义 stop_sequences,保持默认值 - # (默认值已经在 config 初始化时设置) - - return config - - -# ============================================================================ -# 8. 主要转换函数 -# ============================================================================ - -async def anthropic_to_gemini_request(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - 将 Anthropic 格式请求体转换为 Gemini 格式请求体 - - 注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理 - (如 thinking config 自动设置、search tools、参数范围限制等) - - Args: - payload: Anthropic 格式的请求体字典 - - Returns: - Gemini 格式的请求体字典,包含: - - contents: 转换后的消息内容 - - generationConfig: 生成配置 - - systemInstruction: 系统指令 (如果有) - - tools: 工具定义 (如果有) - - toolConfig: 工具调用配置 (如果有 tool_choice) - """ - # 处理连续的system消息(兼容性模式) - payload = await merge_system_messages(payload) - - # 提取和转换基础信息 - messages = payload.get("messages") or [] - if not isinstance(messages, list): - messages = [] - - # [CRITICAL FIX] 过滤并修复 Thinking 块签名 - # 在转换前先过滤无效的 thinking 块 - filter_invalid_thinking_blocks(messages) - - # 构建生成配置 - generation_config = build_generation_config(payload) - - # 转换消息内容(始终包含thinking块,由响应端处理) - contents = convert_messages_to_contents(messages, include_thinking=True) - - # [CRITICAL FIX] 移除尾部无签名的 thinking 块 - # 对真实请求应用额外的清理 - for content in contents: - role = content.get("role", "") - if role == "model": # 只处理 model/assistant 消息 - parts = content.get("parts", []) - if isinstance(parts, list): - remove_trailing_unsigned_thinking(parts) - - contents = reorganize_tool_messages(contents) - - # 转换工具 - tools = convert_tools(payload.get("tools")) - - # 转换 tool_choice - tool_config = convert_tool_choice_to_tool_config(payload.get("tool_choice")) - - # 构建基础请求数据 - gemini_request = { - "contents": contents, - "generationConfig": generation_config, - } - - # 如果 merge_system_messages 已经添加了 systemInstruction,使用它 - if "systemInstruction" in payload: - gemini_request["systemInstruction"] = payload["systemInstruction"] - - if tools: - gemini_request["tools"] = tools - - # 添加 toolConfig(如果有 tool_choice) - if tool_config: - gemini_request["toolConfig"] = tool_config - - # 透传图片生成的 size 参数(如 "1024x1536") - if "size" in payload and payload["size"]: - gemini_request["size"] = payload["size"] - - return gemini_request - - -def gemini_to_anthropic_response( - gemini_response: Dict[str, Any], - model: str, - status_code: int = 200 -) -> Dict[str, Any]: - """ - 将 Gemini 格式非流式响应转换为 Anthropic 格式非流式响应 - - 注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发 - - Args: - gemini_response: Gemini 格式的响应体字典 - model: 模型名称 - status_code: HTTP 状态码 (默认 200) - - Returns: - Anthropic 格式的响应体字典,或原始响应 (如果状态码不是 2xx) - """ - # 非 2xx 状态码直接返回原始响应 - if not (200 <= status_code < 300): - return gemini_response - - # 处理 GeminiCLI 的 response 包装格式 - if "response" in gemini_response: - response_data = gemini_response["response"] - else: - response_data = gemini_response - - # 提取候选结果 - candidate = response_data.get("candidates", [{}])[0] or {} - parts = candidate.get("content", {}).get("parts", []) or [] - - # 获取 usage metadata - usage_metadata = {} - if "usageMetadata" in response_data: - usage_metadata = response_data["usageMetadata"] - elif "usageMetadata" in candidate: - usage_metadata = candidate["usageMetadata"] - - # 转换内容块 - content = [] - has_tool_use = False - - for part in parts: - if not isinstance(part, dict): - continue - - # 处理 thinking 块 - if part.get("thought") is True: - if is_skip_thought_signature_placeholder(part): - continue - thinking_text = part.get("text", "") - if thinking_text is None: - thinking_text = "" - - block: Dict[str, Any] = {"type": "thinking", "thinking": str(thinking_text)} - - # 如果有 thoughtsignature 则添加 - thoughtsignature = part.get("thoughtSignature") - if thoughtsignature: - block["thoughtSignature"] = thoughtsignature - - content.append(block) - continue - - # 处理文本块 - if "text" in part: - text = part.get("text", "") - if ( - is_skip_thought_signature_placeholder(part) - or is_internal_placeholder_text(text) - ): - continue - content.append({"type": "text", "text": text}) - continue - - # 处理工具调用 - if "functionCall" in part: - has_tool_use = True - fc = part.get("functionCall", {}) or {} - original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}" - content.append( - { - "type": "tool_use", - "id": original_id, - "name": fc.get("name") or "", - "input": _remove_nulls_for_tool_input(fc.get("args", {}) or {}), - } - ) - continue - - # 处理图片 - if "inlineData" in part: - inline = part.get("inlineData", {}) or {} - content.append( - { - "type": "image", - "source": { - "type": "base64", - "media_type": inline.get("mimeType", "image/png"), - "data": inline.get("data", ""), - }, - } - ) - continue - - # 确定停止原因 - finish_reason = candidate.get("finishReason") - - # 只有在正常停止(STOP)且有工具调用时才设为 tool_use - # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环 - if has_tool_use and finish_reason == "STOP": - stop_reason = "tool_use" - elif finish_reason == "MAX_TOKENS": - stop_reason = "max_tokens" - else: - # 其他情况(SAFETY、RECITATION 等)默认为 end_turn - stop_reason = "end_turn" - - # 提取 token 使用情况 - usage = _anthropic_usage_from_metadata(usage_metadata) - - # 构建 Anthropic 响应 - message_id = f"msg_{uuid.uuid4().hex}" - - return { - "id": message_id, - "type": "message", - "role": "assistant", - "model": model, - "content": content, - "stop_reason": stop_reason, - "stop_sequence": None, - "usage": usage, - } - - -async def gemini_stream_to_anthropic_stream( - gemini_stream: AsyncIterator[bytes], - model: str, - status_code: int = 200 -) -> AsyncIterator[bytes]: - """ - 将 Gemini 格式流式响应转换为 Anthropic SSE 格式流式响应 - - 注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发 - - Args: - gemini_stream: Gemini 格式的流式响应 (bytes 迭代器) - model: 模型名称 - status_code: HTTP 状态码 (默认 200) - - Yields: - Anthropic SSE 格式的响应块 (bytes) - """ - # 非 2xx 状态码直接转发原始流 - if not (200 <= status_code < 300): - async for chunk in gemini_stream: - yield chunk - return - - # 初始化状态 - message_id = f"msg_{uuid.uuid4().hex}" - message_start_sent = False - current_block_type: Optional[str] = None - current_block_index = -1 - current_thinking_signature: Optional[str] = None - has_tool_use = False - input_tokens = 0 - output_tokens = 0 - cached_input_tokens = 0 - finish_reason: Optional[str] = None - - def _sse_event(event: str, data: Dict[str, Any]) -> bytes: - """生成 SSE 事件""" - payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) - return f"event: {event}\ndata: {payload}\n\n".encode("utf-8") - - def _close_block() -> Optional[bytes]: - """关闭当前内容块""" - nonlocal current_block_type - if current_block_type is None: - return None - event = _sse_event( - "content_block_stop", - {"type": "content_block_stop", "index": current_block_index}, - ) - current_block_type = None - return event - - def _usage_payload() -> Dict[str, int]: - usage = {"input_tokens": input_tokens, "output_tokens": output_tokens} - if cached_input_tokens > 0: - usage["cache_read_input_tokens"] = cached_input_tokens - return usage - - # 处理流式数据 - try: - async for chunk in gemini_stream: - # 检查是否是 Response 对象(错误情况) - if isinstance(chunk, Response): - log.warning(f"[GEMINI_TO_ANTHROPIC] 收到 Response 对象,状态码: {chunk.status_code},直接转发错误") - # 直接转发错误响应内容,不做格式转换 - error_content = chunk.body if isinstance(chunk.body, bytes) else chunk.body.encode('utf-8') - yield error_content - return - - # 记录接收到的原始chunk - log.debug(f"[GEMINI_TO_ANTHROPIC] Raw chunk: {chunk[:200] if chunk else b''}") - - # 解析 Gemini 流式块 - if not chunk or not chunk.startswith(b"data: "): - log.debug(f"[GEMINI_TO_ANTHROPIC] Skipping chunk (not SSE format or empty)") - continue - - raw = chunk[6:].strip() - if raw == b"[DONE]": - log.debug(f"[GEMINI_TO_ANTHROPIC] Received [DONE] marker") - break - - log.debug(f"[GEMINI_TO_ANTHROPIC] Parsing JSON: {raw[:200]}") - - try: - data = json.loads(raw.decode('utf-8', errors='ignore')) - log.debug(f"[GEMINI_TO_ANTHROPIC] Parsed data: {json.dumps(data, ensure_ascii=False)[:300]}") - except Exception as e: - log.warning(f"[GEMINI_TO_ANTHROPIC] JSON parse error: {e}") - continue - - # 处理 GeminiCLI 的 response 包装格式 - if "response" in data: - response = data["response"] - else: - response = data - - candidate = (response.get("candidates", []) or [{}])[0] or {} - parts = (candidate.get("content", {}) or {}).get("parts", []) or [] - - # 更新 usage metadata - if "usageMetadata" in response: - usage = response["usageMetadata"] - if isinstance(usage, dict): - if "promptTokenCount" in usage: - prompt_tokens_total = int(usage.get("promptTokenCount", 0) or 0) - input_tokens = max(prompt_tokens_total - cached_input_tokens, 0) - if "candidatesTokenCount" in usage: - output_tokens = int(usage.get("candidatesTokenCount", 0) or 0) - if "cachedContentTokenCount" in usage: - cached_input_tokens = int(usage.get("cachedContentTokenCount", 0) or 0) - input_tokens = max( - int(usage.get("promptTokenCount", 0) or 0) - cached_input_tokens, - 0, - ) - - # 发送 message_start(仅一次) - if not message_start_sent: - message_start_sent = True - yield _sse_event( - "message_start", - { - "type": "message_start", - "message": { - "id": message_id, - "type": "message", - "role": "assistant", - "model": model, - "content": [], - "stop_reason": None, - "stop_sequence": None, - "usage": _usage_payload(), - }, - }, - ) - - # 处理各种 parts - for part in parts: - if not isinstance(part, dict): - continue - - # 处理 thinking 块 - if part.get("thought") is True: - if is_skip_thought_signature_placeholder(part): - continue - thinking_text = part.get("text", "") - thoughtsignature = part.get("thoughtSignature") - - # 检查是否需要关闭上一个块并开启新的 thinking 块 - if current_block_type != "thinking": - close_evt = _close_block() - if close_evt: - yield close_evt - - current_block_index += 1 - current_block_type = "thinking" - current_thinking_signature = thoughtsignature - - block: Dict[str, Any] = {"type": "thinking", "thinking": ""} - if thoughtsignature: - block["thoughtSignature"] = thoughtsignature - yield _sse_event( - "content_block_start", - { - "type": "content_block_start", - "index": current_block_index, - "content_block": block, - }, - ) - elif thoughtsignature and thoughtsignature != current_thinking_signature: - # 签名变化,需要开启新的 thinking 块 - close_evt = _close_block() - if close_evt: - yield close_evt - - current_block_index += 1 - current_block_type = "thinking" - current_thinking_signature = thoughtsignature - - block_new: Dict[str, Any] = {"type": "thinking", "thinking": ""} - if thoughtsignature: - block_new["thoughtSignature"] = thoughtsignature - - yield _sse_event( - "content_block_start", - { - "type": "content_block_start", - "index": current_block_index, - "content_block": block_new, - }, - ) - - # 发送 thinking 文本增量 - if thinking_text: - yield _sse_event( - "content_block_delta", - { - "type": "content_block_delta", - "index": current_block_index, - "delta": {"type": "thinking_delta", "thinking": thinking_text}, - }, - ) - continue - - # 处理文本块 - if "text" in part: - if ( - is_skip_thought_signature_placeholder(part) - or is_internal_placeholder_text(part.get("text")) - ): - continue - text = part.get("text", "") - if isinstance(text, str) and not text.strip(): - continue - - if current_block_type != "text": - close_evt = _close_block() - if close_evt: - yield close_evt - - current_block_index += 1 - current_block_type = "text" - - yield _sse_event( - "content_block_start", - { - "type": "content_block_start", - "index": current_block_index, - "content_block": {"type": "text", "text": ""}, - }, - ) - - if text: - yield _sse_event( - "content_block_delta", - { - "type": "content_block_delta", - "index": current_block_index, - "delta": {"type": "text_delta", "text": text}, - }, - ) - continue - - # 处理工具调用 - if "functionCall" in part: - close_evt = _close_block() - if close_evt: - yield close_evt - - has_tool_use = True - fc = part.get("functionCall", {}) or {} - original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}" - tool_id = original_id - tool_name = fc.get("name") or "" - tool_args = _remove_nulls_for_tool_input(fc.get("args", {}) or {}) - - if _anthropic_debug_enabled(): - log.info( - f"[ANTHROPIC][tool_use] 处理工具调用: name={tool_name}, " - f"id={tool_id}" - ) - - current_block_index += 1 - # 注意:工具调用不设置 current_block_type,因为它是独立完整的块 - - yield _sse_event( - "content_block_start", - { - "type": "content_block_start", - "index": current_block_index, - "content_block": { - "type": "tool_use", - "id": tool_id, - "name": tool_name, - "input": {}, - }, - }, - ) - - input_json = json.dumps(tool_args, ensure_ascii=False, separators=(",", ":")) - yield _sse_event( - "content_block_delta", - { - "type": "content_block_delta", - "index": current_block_index, - "delta": {"type": "input_json_delta", "partial_json": input_json}, - }, - ) - - yield _sse_event( - "content_block_stop", - {"type": "content_block_stop", "index": current_block_index}, - ) - # 工具调用块已完全关闭,current_block_type 保持为 None - - if _anthropic_debug_enabled(): - log.info(f"[ANTHROPIC][tool_use] 工具调用块已关闭: index={current_block_index}") - - continue - - # 检查是否结束 - if candidate.get("finishReason"): - finish_reason = candidate.get("finishReason") - break - - # 关闭最后的内容块 - close_evt = _close_block() - if close_evt: - yield close_evt - - # 确定停止原因 - # 只有在正常停止(STOP)且有工具调用时才设为 tool_use - # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环 - if has_tool_use and finish_reason == "STOP": - stop_reason = "tool_use" - elif finish_reason == "MAX_TOKENS": - stop_reason = "max_tokens" - else: - # 其他情况(SAFETY、RECITATION 等)默认为 end_turn - stop_reason = "end_turn" - - if _anthropic_debug_enabled(): - log.info( - f"[ANTHROPIC][stream_end] 流式结束: stop_reason={stop_reason}, " - f"has_tool_use={has_tool_use}, finish_reason={finish_reason}, " - f"input_tokens={input_tokens}, output_tokens={output_tokens}" - ) - - # 发送 message_delta 和 message_stop - yield _sse_event( - "message_delta", - { - "type": "message_delta", - "delta": {"stop_reason": stop_reason, "stop_sequence": None}, - "usage": _usage_payload(), - }, - ) - - yield _sse_event("message_stop", {"type": "message_stop"}) - - except Exception as e: - log.error(f"[ANTHROPIC] 流式转换失败: {e}") - # 发送错误事件 - if not message_start_sent: - yield _sse_event( - "message_start", - { - "type": "message_start", - "message": { - "id": message_id, - "type": "message", - "role": "assistant", - "model": model, - "content": [], - "stop_reason": None, - "stop_sequence": None, - "usage": _usage_payload(), - }, - }, - ) - yield _sse_event( - "error", - {"type": "error", "error": {"type": "api_error", "message": str(e)}}, - ) diff --git a/src/converter/anti_truncation.py b/src/converter/anti_truncation.py deleted file mode 100644 index 578d1cdf101583861cb26147109ddd802d3fe9c9..0000000000000000000000000000000000000000 --- a/src/converter/anti_truncation.py +++ /dev/null @@ -1,731 +0,0 @@ -""" -Anti-Truncation Module - Ensures complete streaming output -保持一个流式请求内完整输出的反截断模块 -""" - -import io -import json -import re -from typing import Any, AsyncGenerator, Dict, List, Tuple - -from fastapi.responses import StreamingResponse - -from log import log - -# 反截断配置 -DONE_MARKER = "[done]" -CONTINUATION_PROMPT = f"""请从刚才被截断的地方继续输出剩余的所有内容。 - -重要提醒: -1. 不要重复前面已经输出的内容 -2. 直接继续输出,无需任何前言或解释 -3. 当你完整完成所有内容输出后,必须在最后一行单独输出:{DONE_MARKER} -4. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记 - -现在请继续输出:""" - -# 正则替换配置 -REGEX_REPLACEMENTS: List[Tuple[str, str, str]] = [ - ( - "age_pattern", # 替换规则名称 - r"(?:[1-9]|1[0-8])岁(?:的)?|(?:十一|十二|十三|十四|十五|十六|十七|十八|十|一|二|三|四|五|六|七|八|九)岁(?:的)?", # 正则模式 - "", # 替换文本 - ), - # 可在此处添加更多替换规则 - # ("rule_name", r"pattern", "replacement"), -] - - -def apply_regex_replacements(text: str) -> str: - """ - 对文本应用正则替换规则 - - Args: - text: 要处理的文本 - - Returns: - 处理后的文本 - """ - if not text: - return text - - processed_text = text - replacement_count = 0 - - for rule_name, pattern, replacement in REGEX_REPLACEMENTS: - try: - # 编译正则表达式,使用IGNORECASE标志 - regex = re.compile(pattern, re.IGNORECASE) - - # 执行替换 - new_text, count = regex.subn(replacement, processed_text) - - if count > 0: - log.debug(f"Regex replacement '{rule_name}': {count} matches replaced") - processed_text = new_text - replacement_count += count - - except re.error as e: - log.error(f"Invalid regex pattern in rule '{rule_name}': {e}") - continue - - if replacement_count > 0: - log.info(f"Applied {replacement_count} regex replacements to text") - - return processed_text - - -def apply_regex_replacements_to_payload(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - 对请求payload中的文本内容应用正则替换 - - Args: - payload: 请求payload - - Returns: - 应用替换后的payload - """ - if not REGEX_REPLACEMENTS: - return payload - - modified_payload = payload.copy() - request_data = modified_payload.get("request", {}) - - # 处理contents中的文本 - contents = request_data.get("contents", []) - if contents: - new_contents = [] - for content in contents: - if isinstance(content, dict): - new_content = content.copy() - parts = new_content.get("parts", []) - if parts: - new_parts = [] - for part in parts: - if isinstance(part, dict) and "text" in part: - new_part = part.copy() - new_part["text"] = apply_regex_replacements(part["text"]) - new_parts.append(new_part) - else: - new_parts.append(part) - new_content["parts"] = new_parts - new_contents.append(new_content) - else: - new_contents.append(content) - - request_data["contents"] = new_contents - modified_payload["request"] = request_data - log.debug("Applied regex replacements to request contents") - - return modified_payload - - -def apply_anti_truncation(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - 对请求payload应用反截断处理和正则替换 - 在systemInstruction中添加提醒,要求模型在结束时输出DONE_MARKER标记 - - Args: - payload: 原始请求payload - - Returns: - 添加了反截断指令并应用了正则替换的payload - """ - # 首先应用正则替换 - modified_payload = apply_regex_replacements_to_payload(payload) - request_data = modified_payload.get("request", {}) - - # 获取或创建systemInstruction - system_instruction = request_data.get("systemInstruction", {}) - if not system_instruction: - system_instruction = {"parts": []} - elif "parts" not in system_instruction: - system_instruction["parts"] = [] - - # 添加反截断指令 - anti_truncation_instruction = { - "text": f"""严格执行以下输出结束规则: - -1. 当你完成完整回答时,必须在输出的最后单独一行输出:{DONE_MARKER} -2. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记 -3. 只有输出了 {DONE_MARKER} 标记,系统才认为你的回答是完整的 -4. 如果你的回答被截断,系统会要求你继续输出剩余内容 -5. 无论回答长短,都必须以 {DONE_MARKER} 标记结束 - -示例格式: -``` -你的回答内容... -更多回答内容... -{DONE_MARKER} -``` - -注意:{DONE_MARKER} 必须单独占一行,前面不要有任何其他字符。 - -这个规则对于确保输出完整性极其重要,请严格遵守。""" - } - - # 检查是否已经包含反截断指令 - has_done_instruction = any( - part.get("text", "").find(DONE_MARKER) != -1 - for part in system_instruction["parts"] - if isinstance(part, dict) - ) - - if not has_done_instruction: - system_instruction["parts"].append(anti_truncation_instruction) - request_data["systemInstruction"] = system_instruction - modified_payload["request"] = request_data - - log.debug("Applied anti-truncation instruction to request") - - return modified_payload - - -class AntiTruncationStreamProcessor: - """反截断流式处理器""" - - def __init__( - self, - original_request_func, - payload: Dict[str, Any], - max_attempts: int = 3, - enable_prefill_mode: bool = False, - ): - self.original_request_func = original_request_func - self.base_payload = payload.copy() - self.max_attempts = max_attempts - self.enable_prefill_mode = enable_prefill_mode - # 使用 StringIO 避免字符串拼接的内存问题 - self.collected_content = io.StringIO() - self.current_attempt = 0 - - def _get_collected_text(self) -> str: - """获取收集的文本内容""" - return self.collected_content.getvalue() - - def _append_content(self, content: str): - """追加内容到收集器""" - if content: - self.collected_content.write(content) - - def _clear_content(self): - """清空收集的内容,释放内存""" - self.collected_content.close() - self.collected_content = io.StringIO() - - async def process_stream(self) -> AsyncGenerator[bytes, None]: - """处理流式响应,检测并处理截断""" - - while self.current_attempt < self.max_attempts: - self.current_attempt += 1 - - # 构建当前请求payload - current_payload = self._build_current_payload() - - log.debug(f"Anti-truncation attempt {self.current_attempt}/{self.max_attempts}") - - # 发送请求 - try: - response = await self.original_request_func(current_payload) - - if not isinstance(response, StreamingResponse): - # 非流式响应,直接处理 - yield await self._handle_non_streaming_response(response) - return - - # 处理流式响应(按行处理) - chunk_buffer = io.StringIO() # 使用 StringIO 缓存当前轮次的chunk - found_done_marker = False - - async for line in response.body_iterator: - if not line: - yield line - continue - - # 处理上游生成器 yield 出 Response 对象的情况(错误响应) - from fastapi import Response as FastAPIResponse - if isinstance(line, FastAPIResponse): - log.error(f"Anti-truncation: Received Response object from stream (status={line.status_code}), treating as error") - error_chunk = { - "error": { - "message": line.body.decode('utf-8', errors='ignore') if hasattr(line, 'body') and line.body else "Upstream error", - "type": "api_error", - "code": line.status_code, - } - } - yield f"data: {json.dumps(error_chunk)}\n\n".encode() - yield b"data: [DONE]\n\n" - return - - # 处理 bytes 类型的流式数据 - if isinstance(line, bytes): - # 解码 bytes 为字符串 - line_str = line.decode('utf-8', errors='ignore').strip() - else: - line_str = str(line).strip() - - # 跳过空行 - if not line_str: - yield line - continue - - # 处理 SSE 格式的数据行 - if line_str.startswith("data: "): - payload_str = line_str[6:] # 去掉 "data: " 前缀 - - # 检查是否是 [DONE] 标记 - if payload_str.strip() == "[DONE]": - if found_done_marker: - log.info("Anti-truncation: Found [done] marker, output complete") - yield line - # 清理内存 - chunk_buffer.close() - self._clear_content() - return - else: - log.warning("Anti-truncation: Stream ended without [done] marker") - # 不发送[DONE],准备继续 - break - - # 尝试解析 JSON 数据 - try: - data = json.loads(payload_str) - content = self._extract_content_from_chunk(data) - - log.debug(f"Anti-truncation: Extracted content: {repr(content[:100] if content else '')}") - - if content: - chunk_buffer.write(content) - - # 检查是否包含done标记 - has_marker = self._check_done_marker_in_chunk_content(content) - log.debug(f"Anti-truncation: Check done marker result: {has_marker}, DONE_MARKER='{DONE_MARKER}'") - if has_marker: - found_done_marker = True - log.debug(f"Anti-truncation: Found [done] marker in chunk, content: {content[:200]}") - - # 清理行中的[done]标记后再发送 - cleaned_line = self._remove_done_marker_from_line(line, line_str, data) - yield cleaned_line - - except (json.JSONDecodeError, ValueError): - # 无法解析的行,直接传递 - yield line - continue - else: - # 非 data: 开头的行,直接传递 - yield line - - # 更新收集的内容 - 使用 StringIO 高效处理 - chunk_text = chunk_buffer.getvalue() - if chunk_text: - self._append_content(chunk_text) - chunk_buffer.close() - - log.debug(f"Anti-truncation: After processing stream, found_done_marker={found_done_marker}") - - # 如果找到了done标记,结束 - if found_done_marker: - # 立即清理内容释放内存 - self._clear_content() - yield b"data: [DONE]\n\n" - return - - # 只有在单个chunk中没有找到done标记时,才检查累积内容(防止done标记跨chunk出现) - if not found_done_marker: - accumulated_text = self._get_collected_text() - if self._check_done_marker_in_text(accumulated_text): - log.info("Anti-truncation: Found [done] marker in accumulated content") - # 立即清理内容释放内存 - self._clear_content() - yield b"data: [DONE]\n\n" - return - - # 如果没找到done标记且不是最后一次尝试,准备续传 - if self.current_attempt < self.max_attempts: - accumulated_text = self._get_collected_text() - total_length = len(accumulated_text) - log.info( - f"Anti-truncation: No [done] marker found in output (length: {total_length}), preparing continuation (attempt {self.current_attempt + 1})" - ) - if total_length > 100: - log.debug( - f"Anti-truncation: Current collected content ends with: ...{accumulated_text[-100:]}" - ) - # 在下一次循环中会继续 - continue - else: - # 最后一次尝试,直接结束 - log.warning("Anti-truncation: Max attempts reached, ending stream") - # 立即清理内容释放内存 - self._clear_content() - yield b"data: [DONE]\n\n" - return - - except Exception as e: - log.error(f"Anti-truncation error in attempt {self.current_attempt}: {str(e)}") - if self.current_attempt >= self.max_attempts: - # 发送错误chunk - error_chunk = { - "error": { - "message": f"Anti-truncation failed: {str(e)}", - "type": "api_error", - "code": 500, - } - } - yield f"data: {json.dumps(error_chunk)}\n\n".encode() - yield b"data: [DONE]\n\n" - return - # 否则继续下一次尝试 - - # 如果所有尝试都失败了 - log.error("Anti-truncation: All attempts failed") - # 清理内存 - self._clear_content() - yield b"data: [DONE]\n\n" - - def _build_current_payload(self) -> Dict[str, Any]: - """构建当前请求的payload""" - if self.current_attempt == 1: - # 第一次请求,使用原始payload(已经包含反截断指令) - return self.base_payload - - # 后续请求,添加续传指令 - continuation_payload = self.base_payload.copy() - request_data = continuation_payload.get("request", {}) - - # 获取原始对话内容 - contents = request_data.get("contents", []) - new_contents = contents.copy() - - # 如果有收集到的内容,添加到对话中 - accumulated_text = self._get_collected_text() - if accumulated_text: - new_contents.append({"role": "model", "parts": [{"text": accumulated_text}]}) - - # 预填充模式:直接用拼接内容作为末尾 model 预填充,不再增加 user 续写指令 - if self.enable_prefill_mode: - log.debug("Anti-truncation: Using prefill continuation mode (no user continuation prompt)") - request_data["contents"] = new_contents - continuation_payload["request"] = request_data - return continuation_payload - - # 构建具体的续写指令,包含前面的内容摘要 - content_summary = "" - if accumulated_text: - if len(accumulated_text) > 200: - content_summary = f'\n\n前面你已经输出了约 {len(accumulated_text)} 个字符的内容,结尾是:\n"...{accumulated_text[-100:]}"' - else: - content_summary = f'\n\n前面你已经输出的内容是:\n"{accumulated_text}"' - - detailed_continuation_prompt = f"""{CONTINUATION_PROMPT}{content_summary}""" - - # 添加继续指令 - continuation_message = {"role": "user", "parts": [{"text": detailed_continuation_prompt}]} - new_contents.append(continuation_message) - - request_data["contents"] = new_contents - continuation_payload["request"] = request_data - - return continuation_payload - - def _extract_content_from_chunk(self, data: Dict[str, Any]) -> str: - """从chunk数据中提取文本内容""" - content = "" - - # 先尝试解包 response 字段(Gemini API 格式) - if "response" in data: - data = data["response"] - - # 处理 Gemini 格式 - if "candidates" in data: - for candidate in data["candidates"]: - if "content" in candidate: - parts = candidate["content"].get("parts", []) - for part in parts: - if "text" in part: - content += part["text"] - - # 处理 OpenAI 流式格式(choices/delta) - elif "choices" in data: - for choice in data["choices"]: - if "delta" in choice and "content" in choice["delta"]: - delta_content = choice["delta"]["content"] - if delta_content: - content += delta_content - - return content - - async def _handle_non_streaming_response(self, response) -> bytes: - """处理非流式响应 - 使用循环代替递归避免栈溢出""" - # 使用循环代替递归 - while True: - try: - # 特殊处理:如果返回的是StreamingResponse,需要读取其body_iterator - if isinstance(response, StreamingResponse): - log.error("Anti-truncation: Received StreamingResponse in non-streaming handler - this should not happen") - # 尝试读取流式响应的内容 - chunks = [] - async for chunk in response.body_iterator: - chunks.append(chunk) - content = b"".join(chunks).decode() if chunks else "" - # 提取响应内容 - elif hasattr(response, "body"): - content = ( - response.body.decode() if isinstance(response.body, bytes) else response.body - ) - elif hasattr(response, "content"): - content = ( - response.content.decode() - if isinstance(response.content, bytes) - else response.content - ) - else: - log.error(f"Anti-truncation: Unknown response type: {type(response)}") - content = str(response) - - # 验证内容不为空 - if not content or not content.strip(): - log.error("Anti-truncation: Received empty response content") - return json.dumps( - { - "error": { - "message": "Empty response from server", - "type": "api_error", - "code": 500, - } - } - ).encode() - - # 尝试解析 JSON - try: - response_data = json.loads(content) - except json.JSONDecodeError as json_err: - log.error(f"Anti-truncation: Failed to parse JSON response: {json_err}, content: {content[:200]}") - # 如果不是 JSON,直接返回原始内容 - return content.encode() if isinstance(content, str) else content - - # 检查是否包含done标记 - text_content = self._extract_content_from_response(response_data) - has_done_marker = self._check_done_marker_in_text(text_content) - - if has_done_marker or self.current_attempt >= self.max_attempts: - # 找到done标记或达到最大尝试次数,返回结果 - return content.encode() if isinstance(content, str) else content - - # 需要继续,收集内容并构建下一个请求 - if text_content: - self._append_content(text_content) - - log.info("Anti-truncation: Non-streaming response needs continuation") - - # 增加尝试次数 - self.current_attempt += 1 - - # 构建续传payload并发送下一个请求 - next_payload = self._build_current_payload() - response = await self.original_request_func(next_payload) - - # 继续循环处理下一个响应 - - except Exception as e: - log.error(f"Anti-truncation non-streaming error: {str(e)}") - return json.dumps( - { - "error": { - "message": f"Anti-truncation failed: {str(e)}", - "type": "api_error", - "code": 500, - } - } - ).encode() - - def _check_done_marker_in_text(self, text: str) -> bool: - """检测文本中是否包含DONE_MARKER(只检测指定标记)""" - if not text: - return False - - # 只要文本中出现DONE_MARKER即可 - return DONE_MARKER in text - - def _check_done_marker_in_chunk_content(self, content: str) -> bool: - """检查单个chunk内容中是否包含done标记""" - return self._check_done_marker_in_text(content) - - def _extract_content_from_response(self, data: Dict[str, Any]) -> str: - """从响应数据中提取文本内容""" - content = "" - - # 先尝试解包 response 字段(Gemini API 格式) - if "response" in data: - data = data["response"] - - # 处理Gemini格式 - if "candidates" in data: - for candidate in data["candidates"]: - if "content" in candidate: - parts = candidate["content"].get("parts", []) - for part in parts: - if "text" in part: - content += part["text"] - - # 处理OpenAI格式 - elif "choices" in data: - for choice in data["choices"]: - if "message" in choice and "content" in choice["message"]: - content += choice["message"]["content"] - - return content - - def _remove_done_marker_from_line(self, line: bytes, line_str: str, data: Dict[str, Any]) -> bytes: - """从行中移除[done]标记""" - try: - # 首先检查是否真的包含[done]标记 - if "[done]" not in line_str.lower(): - return line # 没有[done]标记,直接返回原始行 - - log.info(f"Anti-truncation: Attempting to remove [done] marker from line") - log.debug(f"Anti-truncation: Original line (first 200 chars): {line_str[:200]}") - - # 编译正则表达式,匹配[done]标记(忽略大小写,包括可能的空白字符) - done_pattern = re.compile(r"\s*\[done\]\s*", re.IGNORECASE) - - # 检查是否有 response 包裹层 - has_response_wrapper = "response" in data - log.debug(f"Anti-truncation: has_response_wrapper={has_response_wrapper}, data keys={list(data.keys())}") - if has_response_wrapper: - # 需要保留外层的 response 字段 - inner_data = data["response"] - else: - inner_data = data - - log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}") - - log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}") - - # 处理Gemini格式 - if "candidates" in inner_data: - log.info(f"Anti-truncation: Processing Gemini format to remove [done] marker") - modified_inner = inner_data.copy() - modified_inner["candidates"] = [] - - for i, candidate in enumerate(inner_data["candidates"]): - modified_candidate = candidate.copy() - # 只在最后一个candidate中清理[done]标记 - is_last_candidate = i == len(inner_data["candidates"]) - 1 - - if "content" in candidate: - modified_content = candidate["content"].copy() - if "parts" in modified_content: - modified_parts = [] - for part in modified_content["parts"]: - if "text" in part and isinstance(part["text"], str): - modified_part = part.copy() - original_text = part["text"] - # 只在最后一个candidate中清理[done]标记 - if is_last_candidate: - modified_part["text"] = done_pattern.sub("", part["text"]) - if "[done]" in original_text.lower(): - log.debug(f"Anti-truncation: Removed [done] from text: '{original_text[:100]}' -> '{modified_part['text'][:100]}'") - modified_parts.append(modified_part) - else: - modified_parts.append(part) - modified_content["parts"] = modified_parts - modified_candidate["content"] = modified_content - modified_inner["candidates"].append(modified_candidate) - - # 如果有 response 包裹层,需要重新包装 - if has_response_wrapper: - modified_data = data.copy() - modified_data["response"] = modified_inner - else: - modified_data = modified_inner - - # 重新编码为行格式 - SSE格式需要两个换行符 - json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False) - result = f"data: {json_str}\n\n".encode("utf-8") - log.debug(f"Anti-truncation: Modified line (first 200 chars): {result.decode('utf-8', errors='ignore')[:200]}") - return result - - # 处理OpenAI格式 - elif "choices" in inner_data: - modified_inner = inner_data.copy() - modified_inner["choices"] = [] - - for choice in inner_data["choices"]: - modified_choice = choice.copy() - if "delta" in choice and "content" in choice["delta"]: - modified_delta = choice["delta"].copy() - modified_delta["content"] = done_pattern.sub("", choice["delta"]["content"]) - modified_choice["delta"] = modified_delta - elif "message" in choice and "content" in choice["message"]: - modified_message = choice["message"].copy() - modified_message["content"] = done_pattern.sub("", choice["message"]["content"]) - modified_choice["message"] = modified_message - modified_inner["choices"].append(modified_choice) - - # 如果有 response 包裹层,需要重新包装 - if has_response_wrapper: - modified_data = data.copy() - modified_data["response"] = modified_inner - else: - modified_data = modified_inner - - # 重新编码为行格式 - SSE格式需要两个换行符 - json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False) - return f"data: {json_str}\n\n".encode("utf-8") - - # 如果没有找到支持的格式,返回原始行 - return line - - except Exception as e: - log.warning(f"Failed to remove [done] marker from line: {str(e)}") - return line - - -async def apply_anti_truncation_to_stream( - request_func, - payload: Dict[str, Any], - max_attempts: int = 3, - enable_prefill_mode: bool = False, -) -> StreamingResponse: - """ - 对流式请求应用反截断处理 - - Args: - request_func: 原始请求函数 - payload: 请求payload - max_attempts: 最大续传尝试次数 - enable_prefill_mode: 是否启用预填充模式。启用后续传请求不再添加 user 续写指令, - 而是将已收集内容作为末尾 model 内容进行预填充 - - Returns: - 处理后的StreamingResponse - """ - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(payload) - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - lambda p: request_func(p), - anti_truncation_payload, - max_attempts, - enable_prefill_mode, - ) - - # 返回包装后的流式响应 - return StreamingResponse(processor.process_stream(), media_type="text/event-stream") - - -def is_anti_truncation_enabled(request_data: Dict[str, Any]) -> bool: - """ - 检查请求是否启用了反截断功能 - - Args: - request_data: 请求数据 - - Returns: - 是否启用反截断 - """ - return request_data.get("enable_anti_truncation", False) \ No newline at end of file diff --git a/src/converter/fake_stream.py b/src/converter/fake_stream.py deleted file mode 100644 index 7fb0ba1062df7874a7ba3976730e1a51cca47a54..0000000000000000000000000000000000000000 --- a/src/converter/fake_stream.py +++ /dev/null @@ -1,537 +0,0 @@ -from typing import Any, Dict, List, Tuple -import json -from src.converter.utils import extract_content_and_reasoning -from log import log -from src.converter.openai2gemini import _convert_usage_metadata - -def safe_get_nested(obj: Any, *keys: str, default: Any = None) -> Any: - """安全获取嵌套字典值 - - Args: - obj: 字典对象 - *keys: 嵌套键路径 - default: 默认值 - - Returns: - 获取到的值或默认值 - """ - for key in keys: - if not isinstance(obj, dict): - return default - obj = obj.get(key, default) - if obj is default: - return default - return obj - -def parse_response_for_fake_stream(response_data: Dict[str, Any]) -> tuple: - """从完整响应中提取内容和推理内容(用于假流式) - - Args: - response_data: Gemini API 响应数据 - - Returns: - (content, reasoning_content, finish_reason, images): 内容、推理内容、结束原因和图片数据的元组 - """ - import json - - # 处理GeminiCLI的response包装格式 - if "response" in response_data and "candidates" not in response_data: - log.debug(f"[FAKE_STREAM] Unwrapping response field") - response_data = response_data["response"] - - candidates = response_data.get("candidates", []) - log.debug(f"[FAKE_STREAM] Found {len(candidates)} candidates") - if not candidates: - return "", "", "STOP", [] - - candidate = candidates[0] - finish_reason = candidate.get("finishReason", "STOP") - parts = safe_get_nested(candidate, "content", "parts", default=[]) - log.debug(f"[FAKE_STREAM] Extracted {len(parts)} parts: {json.dumps(parts, ensure_ascii=False)}") - content, reasoning_content, images = extract_content_and_reasoning(parts) - log.debug(f"[FAKE_STREAM] Content length: {len(content)}, Reasoning length: {len(reasoning_content)}, Images count: {len(images)}") - - return content, reasoning_content, finish_reason, images - -def extract_fake_stream_content(response: Any) -> Tuple[str, str, Dict[str, int]]: - """ - 从 Gemini 非流式响应中提取内容,用于假流式处理 - - Args: - response: Gemini API 响应对象 - - Returns: - (content, reasoning_content, usage) 元组 - """ - from src.converter.utils import extract_content_and_reasoning - - # 解析响应体 - if hasattr(response, "body"): - body_str = ( - response.body.decode() - if isinstance(response.body, bytes) - else str(response.body) - ) - elif hasattr(response, "content"): - body_str = ( - response.content.decode() - if isinstance(response.content, bytes) - else str(response.content) - ) - else: - body_str = str(response) - - try: - response_data = json.loads(body_str) - - # GeminiCLI 返回的格式是 {"response": {...}, "traceId": "..."} - # 需要先提取 response 字段 - if "response" in response_data: - gemini_response = response_data["response"] - else: - gemini_response = response_data - - # 从Gemini响应中提取内容,使用思维链分离逻辑 - content = "" - reasoning_content = "" - images = [] - if "candidates" in gemini_response and gemini_response["candidates"]: - # Gemini格式响应 - 使用思维链分离 - candidate = gemini_response["candidates"][0] - if "content" in candidate and "parts" in candidate["content"]: - parts = candidate["content"]["parts"] - content, reasoning_content, images = extract_content_and_reasoning(parts) - elif "choices" in gemini_response and gemini_response["choices"]: - # OpenAI格式响应 - content = gemini_response["choices"][0].get("message", {}).get("content", "") - - # 如果没有正常内容但有思维内容,给出警告 - if not content and reasoning_content: - log.warning("Fake stream response contains only thinking content") - content = "[模型正在思考中,请稍后再试或重新提问]" - - # 如果完全没有内容,提供默认回复 - if not content: - log.warning(f"No content found in response: {gemini_response}") - content = "[响应为空,请重新尝试]" - - # 转换usageMetadata为OpenAI格式 - usage = _convert_usage_metadata(gemini_response.get("usageMetadata")) - - return content, reasoning_content, usage - - except json.JSONDecodeError: - # 如果不是JSON,直接返回原始文本 - return body_str, "", None - -def _build_candidate(parts: List[Dict[str, Any]], finish_reason: str = "STOP") -> Dict[str, Any]: - """构建标准候选响应结构 - - Args: - parts: parts 列表 - finish_reason: 结束原因 - - Returns: - 候选响应字典 - """ - return { - "candidates": [{ - "content": {"parts": parts, "role": "model"}, - "finishReason": finish_reason, - "index": 0, - }] - } - -def create_openai_heartbeat_chunk() -> Dict[str, Any]: - """ - 创建 OpenAI 格式的心跳块(用于假流式) - - Returns: - 心跳响应块字典 - """ - return { - "choices": [ - { - "index": 0, - "delta": {"role": "assistant", "content": ""}, - "finish_reason": None, - } - ] - } - -def build_gemini_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]: - """构建假流式响应的数据块 - - Args: - content: 主要内容 - reasoning_content: 推理内容 - finish_reason: 结束原因 - images: 图片数据列表(可选) - chunk_size: 每个chunk的字符数(默认50) - - Returns: - 响应数据块列表 - """ - if images is None: - images = [] - - log.debug(f"[build_gemini_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}") - chunks = [] - - # 如果没有正常内容但有思维内容,提供默认回复 - if not content: - default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]" - return [_build_candidate([{"text": default_text}], finish_reason)] - - # 分块发送主要内容 - first_chunk = True - for i in range(0, len(content), chunk_size): - chunk_text = content[i:i + chunk_size] - is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content - chunk_finish_reason = finish_reason if is_last_chunk else None - - # 如果是第一个chunk且有图片,将图片包含在parts中 - parts = [] - if first_chunk and images: - # 在Gemini格式中,需要将image_url格式转换为inlineData格式 - for img in images: - if img.get("type") == "image_url": - url = img.get("image_url", {}).get("url", "") - # 解析 data URL: data:{mime_type};base64,{data} - if url.startswith("data:"): - parts_of_url = url.split(";base64,") - if len(parts_of_url) == 2: - mime_type = parts_of_url[0].replace("data:", "") - base64_data = parts_of_url[1] - parts.append({ - "inlineData": { - "mimeType": mime_type, - "data": base64_data - } - }) - first_chunk = False - - parts.append({"text": chunk_text}) - chunk_data = _build_candidate(parts, chunk_finish_reason) - log.debug(f"[build_gemini_fake_stream_chunks] Generated chunk: {chunk_data}") - chunks.append(chunk_data) - - # 如果有推理内容,分块发送 - if reasoning_content: - for i in range(0, len(reasoning_content), chunk_size): - chunk_text = reasoning_content[i:i + chunk_size] - is_last_chunk = i + chunk_size >= len(reasoning_content) - chunk_finish_reason = finish_reason if is_last_chunk else None - chunks.append(_build_candidate([{"text": chunk_text, "thought": True}], chunk_finish_reason)) - - log.debug(f"[build_gemini_fake_stream_chunks] Total chunks generated: {len(chunks)}") - return chunks - - -def create_gemini_heartbeat_chunk() -> Dict[str, Any]: - """创建 Gemini 格式的心跳数据块 - - Returns: - 心跳数据块 - """ - chunk = _build_candidate([{"text": ""}]) - chunk["candidates"][0]["finishReason"] = None - return chunk - - -def build_openai_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]: - """构建 OpenAI 格式的假流式响应数据块 - - Args: - content: 主要内容 - reasoning_content: 推理内容 - finish_reason: 结束原因(如 "STOP", "MAX_TOKENS") - model: 模型名称 - images: 图片数据列表(可选) - chunk_size: 每个chunk的字符数(默认50) - - Returns: - OpenAI 格式的响应数据块列表 - """ - import time - import uuid - - if images is None: - images = [] - - log.debug(f"[build_openai_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}") - chunks = [] - response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" - created = int(time.time()) - - # 映射 Gemini finish_reason 到 OpenAI 格式 - openai_finish_reason = None - if finish_reason == "STOP": - openai_finish_reason = "stop" - elif finish_reason == "MAX_TOKENS": - openai_finish_reason = "length" - elif finish_reason in ["SAFETY", "RECITATION"]: - openai_finish_reason = "content_filter" - - # 如果没有正常内容但有思维内容,提供默认回复 - if not content: - default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]" - return [{ - "id": response_id, - "object": "chat.completion.chunk", - "created": created, - "model": model, - "choices": [{ - "index": 0, - "delta": {"content": default_text}, - "finish_reason": openai_finish_reason, - }] - }] - - # 分块发送主要内容 - first_chunk = True - for i in range(0, len(content), chunk_size): - chunk_text = content[i:i + chunk_size] - is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content - chunk_finish = openai_finish_reason if is_last_chunk else None - - delta_content = {} - - # 如果是第一个chunk且有图片,构建包含图片的content数组 - if first_chunk and images: - delta_content["content"] = images + [{"type": "text", "text": chunk_text}] - first_chunk = False - else: - delta_content["content"] = chunk_text - - chunk_data = { - "id": response_id, - "object": "chat.completion.chunk", - "created": created, - "model": model, - "choices": [{ - "index": 0, - "delta": delta_content, - "finish_reason": chunk_finish, - }] - } - log.debug(f"[build_openai_fake_stream_chunks] Generated chunk: {chunk_data}") - chunks.append(chunk_data) - - # 如果有推理内容,分块发送(使用 reasoning_content 字段) - if reasoning_content: - for i in range(0, len(reasoning_content), chunk_size): - chunk_text = reasoning_content[i:i + chunk_size] - is_last_chunk = i + chunk_size >= len(reasoning_content) - chunk_finish = openai_finish_reason if is_last_chunk else None - - chunks.append({ - "id": response_id, - "object": "chat.completion.chunk", - "created": created, - "model": model, - "choices": [{ - "index": 0, - "delta": {"reasoning_content": chunk_text}, - "finish_reason": chunk_finish, - }] - }) - - log.debug(f"[build_openai_fake_stream_chunks] Total chunks generated: {len(chunks)}") - return chunks - - -def create_anthropic_heartbeat_chunk() -> Dict[str, Any]: - """ - 创建 Anthropic 格式的心跳块(用于假流式) - - Returns: - 心跳响应块字典 - """ - return { - "type": "ping" - } - - -def build_anthropic_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]: - """构建 Anthropic 格式的假流式响应数据块 - - Args: - content: 主要内容 - reasoning_content: 推理内容(thinking content) - finish_reason: 结束原因(如 "STOP", "MAX_TOKENS") - model: 模型名称 - images: 图片数据列表(可选) - chunk_size: 每个chunk的字符数(默认50) - - Returns: - Anthropic SSE 格式的响应数据块列表 - """ - import uuid - - if images is None: - images = [] - - log.debug(f"[build_anthropic_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}") - chunks = [] - message_id = f"msg_{uuid.uuid4().hex}" - - # 映射 Gemini finish_reason 到 Anthropic 格式 - anthropic_stop_reason = "end_turn" - if finish_reason == "MAX_TOKENS": - anthropic_stop_reason = "max_tokens" - elif finish_reason in ["SAFETY", "RECITATION"]: - anthropic_stop_reason = "end_turn" - - # 1. 发送 message_start 事件 - chunks.append({ - "type": "message_start", - "message": { - "id": message_id, - "type": "message", - "role": "assistant", - "model": model, - "content": [], - "stop_reason": None, - "stop_sequence": None, - "usage": {"input_tokens": 0, "output_tokens": 0} - } - }) - - # 如果没有正常内容但有思维内容,提供默认回复 - if not content: - default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]" - - # content_block_start - chunks.append({ - "type": "content_block_start", - "index": 0, - "content_block": {"type": "text", "text": ""} - }) - - # content_block_delta - chunks.append({ - "type": "content_block_delta", - "index": 0, - "delta": {"type": "text_delta", "text": default_text} - }) - - # content_block_stop - chunks.append({ - "type": "content_block_stop", - "index": 0 - }) - - # message_delta - chunks.append({ - "type": "message_delta", - "delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None}, - "usage": {"output_tokens": 0} - }) - - # message_stop - chunks.append({ - "type": "message_stop" - }) - - return chunks - - block_index = 0 - - # 2. 如果有推理内容,先发送 thinking 块 - if reasoning_content: - # thinking content_block_start - chunks.append({ - "type": "content_block_start", - "index": block_index, - "content_block": {"type": "thinking", "thinking": ""} - }) - - # 分块发送推理内容 - for i in range(0, len(reasoning_content), chunk_size): - chunk_text = reasoning_content[i:i + chunk_size] - chunks.append({ - "type": "content_block_delta", - "index": block_index, - "delta": {"type": "thinking_delta", "thinking": chunk_text} - }) - - # thinking content_block_stop - chunks.append({ - "type": "content_block_stop", - "index": block_index - }) - - block_index += 1 - - # 3. 如果有图片,发送图片块 - if images: - for img in images: - if img.get("type") == "image_url": - url = img.get("image_url", {}).get("url", "") - # 解析 data URL: data:{mime_type};base64,{data} - if url.startswith("data:"): - parts_of_url = url.split(";base64,") - if len(parts_of_url) == 2: - mime_type = parts_of_url[0].replace("data:", "") - base64_data = parts_of_url[1] - - # image content_block_start - chunks.append({ - "type": "content_block_start", - "index": block_index, - "content_block": { - "type": "image", - "source": { - "type": "base64", - "media_type": mime_type, - "data": base64_data - } - } - }) - - # image content_block_stop - chunks.append({ - "type": "content_block_stop", - "index": block_index - }) - - block_index += 1 - - # 4. 发送主要内容(text 块) - # text content_block_start - chunks.append({ - "type": "content_block_start", - "index": block_index, - "content_block": {"type": "text", "text": ""} - }) - - # 分块发送主要内容 - for i in range(0, len(content), chunk_size): - chunk_text = content[i:i + chunk_size] - chunks.append({ - "type": "content_block_delta", - "index": block_index, - "delta": {"type": "text_delta", "text": chunk_text} - }) - - # text content_block_stop - chunks.append({ - "type": "content_block_stop", - "index": block_index - }) - - # 5. 发送 message_delta - chunks.append({ - "type": "message_delta", - "delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None}, - "usage": {"output_tokens": len(content) + len(reasoning_content)} - }) - - # 6. 发送 message_stop - chunks.append({ - "type": "message_stop" - }) - - log.debug(f"[build_anthropic_fake_stream_chunks] Total chunks generated: {len(chunks)}") - return chunks \ No newline at end of file diff --git a/src/converter/gemini_fix.py b/src/converter/gemini_fix.py deleted file mode 100644 index c27ca5667a936e3c20967809046f31cc79f87ecd..0000000000000000000000000000000000000000 --- a/src/converter/gemini_fix.py +++ /dev/null @@ -1,859 +0,0 @@ -""" -Gemini Format Utilities - 统一的 Gemini 格式处理和转换工具 -提供对 Gemini API 请求体和响应的标准化处理 -──────────────────────────────────────────────────────────────── -""" -import json -from math import e -from typing import Any, Dict, Optional - -from log import log -from src.converter.thoughtSignature_fix import SKIP_THOUGHT_SIGNATURE_VALIDATOR - -# ==================== Gemini API 配置 ==================== - -# ====================== Model Configuration ====================== - -# Default Safety Settings for Google API -DEFAULT_SAFETY_SETTINGS = [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}, -] - -LITE_SAFETY_SETTINGS = [ - {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}, -] - -def _append_schema_hint(schema: Dict[str, Any], hint: str) -> None: - """Move fragile validation details into description instead of sending them raw.""" - if not hint: - return - desc = schema.get("description") - schema["description"] = f"{desc} ({hint})" if desc else hint - - -def _resolve_schema_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if not isinstance(ref, str) or not ref.startswith("#/"): - return None - - node: Any = root_schema - for part in ref[2:].split("/"): - part = part.replace("~1", "/").replace("~0", "~") - if not isinstance(node, dict) or part not in node: - return None - node = node[part] - - return node if isinstance(node, dict) else None - - -def _clean_parameters_json_schema( - schema: Any, - root_schema: Optional[Dict[str, Any]] = None, - visited: Optional[set] = None, -) -> Any: - """Clean a tool schema for Code Assist's parametersJsonSchema field.""" - if isinstance(schema, list): - return [_clean_parameters_json_schema(item, root_schema, visited) for item in schema] - if not isinstance(schema, dict): - return schema - - if root_schema is None: - root_schema = schema - if visited is None: - visited = set() - - schema_id = id(schema) - if schema_id in visited: - return {"type": "object", "description": "circular reference"} - visited.add(schema_id) - - ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None) - if ref_key: - resolved = _resolve_schema_ref(schema[ref_key], root_schema) - if resolved: - merged = dict(resolved) - for key in ("description", "default"): - if key in schema: - merged[key] = schema[key] - schema = merged - - if "allOf" in schema: - result: Dict[str, Any] = {} - for item in schema.get("allOf") or []: - cleaned_item = _clean_parameters_json_schema(item, root_schema, visited) - if not isinstance(cleaned_item, dict): - continue - if "properties" in cleaned_item: - result.setdefault("properties", {}).update(cleaned_item["properties"]) - if "required" in cleaned_item: - result.setdefault("required", []).extend(cleaned_item["required"]) - for key, value in cleaned_item.items(): - if key not in ("properties", "required"): - result[key] = value - for key, value in schema.items(): - if key not in ("allOf", "properties", "required"): - result[key] = value - elif key in ("properties", "required") and key not in result: - result[key] = value - else: - result = dict(schema) - - if result.get("nullable") is True: - _append_schema_hint(result, "nullable") - - if "type" in result: - type_value = result["type"] - if isinstance(type_value, list): - non_null_types = [ - str(t).lower() - for t in type_value - if isinstance(t, str) and t.lower() != "null" - ] - if non_null_types: - result["type"] = non_null_types[0] - if any(str(t).lower() == "null" for t in type_value): - _append_schema_hint(result, "nullable") - else: - result["type"] = "string" - elif isinstance(type_value, str): - lower_type = type_value.lower() - if lower_type in {"string", "number", "integer", "boolean", "array", "object"}: - result["type"] = lower_type - elif lower_type == "null": - result["type"] = "string" - _append_schema_hint(result, "nullable") - else: - result.pop("type", None) - - if "anyOf" in result or "oneOf" in result: - union_key = "anyOf" if "anyOf" in result else "oneOf" - union_items = result.get(union_key) or [] - cleaned_items = [ - item for item in ( - _clean_parameters_json_schema(item, root_schema, visited) - for item in union_items - ) - if isinstance(item, dict) - ] - enum_values = [ - item.get("const") - for item in union_items - if isinstance(item, dict) and item.get("const") not in ("", None) - ] - if enum_values and len(enum_values) == len(union_items): - result["type"] = "string" - result["enum"] = [str(v) for v in enum_values] - else: - preferred = next( - ( - item for item in cleaned_items - if item.get("type") in ("object", "array") or item.get("properties") - ), - None, - ) - if preferred is None: - preferred = next((item for item in cleaned_items if item.get("type") or item.get("enum")), None) - if preferred: - original_description = result.get("description") - result.update(preferred) - if original_description: - _append_schema_hint(result, original_description) - result.pop("anyOf", None) - result.pop("oneOf", None) - - if result.get("type") == "array": - items = result.get("items") - if isinstance(items, list): - if items: - result["items"] = _clean_parameters_json_schema(items[0], root_schema, visited) - _append_schema_hint(result, "tuple schema simplified") - else: - result.pop("items", None) - elif isinstance(items, dict): - result["items"] = _clean_parameters_json_schema(items, root_schema, visited) - - validation_keys = { - "default", "minLength", "maxLength", "minimum", "maximum", - "minItems", "maxItems", "pattern", "format", "uniqueItems", - } - for key in list(result.keys()): - if key in validation_keys: - value = result.pop(key) - if value not in (None, "", {}, []): - _append_schema_hint(result, f"{key}: {json.dumps(value, ensure_ascii=False)}") - - unsupported_keys = { - "title", "$schema", "$id", "$ref", "ref", "strict", "nullable", - "exclusiveMaximum", "exclusiveMinimum", "additionalProperties", - "allOf", "anyOf", "oneOf", "$defs", "definitions", "example", - "examples", "readOnly", "writeOnly", "const", "additionalItems", - "contains", "patternProperties", "dependencies", "propertyNames", - "if", "then", "else", "contentEncoding", "contentMediaType", - } - for key in list(result.keys()): - if key in unsupported_keys or key.startswith("x-"): - del result[key] - - nullable_props = set() - if isinstance(result.get("properties"), dict): - cleaned_props = {} - for prop_name, prop_schema in result["properties"].items(): - if isinstance(prop_schema, dict): - prop_type = prop_schema.get("type") - if ( - prop_schema.get("nullable") is True - or ( - isinstance(prop_type, list) - and any(str(t).lower() == "null" for t in prop_type) - ) - ): - nullable_props.add(prop_name) - cleaned_props[prop_name] = _clean_parameters_json_schema(prop_schema, root_schema, visited) - result["properties"] = cleaned_props - - if "properties" in result and "type" not in result: - result["type"] = "object" - - if isinstance(result.get("required"), list): - prop_names = set(result.get("properties", {}).keys()) if isinstance(result.get("properties"), dict) else None - required = [] - for item in result["required"]: - if not isinstance(item, str): - continue - if prop_names is not None and item not in prop_names: - continue - if item in nullable_props: - continue - if item not in required: - required.append(item) - if required: - result["required"] = required - else: - result.pop("required", None) - - return result - - -def _normalize_tools_for_internal_api(tools: Any) -> Any: - if not isinstance(tools, list): - return tools - - normalized_tools = [] - for tool in tools: - if not isinstance(tool, dict): - normalized_tools.append(tool) - continue - - normalized_tool = tool.copy() - declarations = normalized_tool.get("functionDeclarations") - if declarations is None: - declarations = normalized_tool.get("function_declarations") - if isinstance(declarations, list): - normalized_declarations = [] - for declaration in declarations: - if not isinstance(declaration, dict): - normalized_declarations.append(declaration) - continue - - normalized_declaration = declaration.copy() - if "parametersJsonSchema" in normalized_declaration: - schema = normalized_declaration["parametersJsonSchema"] - elif "parameters_json_schema" in normalized_declaration: - schema = normalized_declaration.pop("parameters_json_schema", None) - else: - schema = normalized_declaration.pop("parameters", None) - - normalized_declaration.pop("parameters", None) - normalized_declaration.pop("parameters_json_schema", None) - if schema not in (None, {}, []): - normalized_declaration["parametersJsonSchema"] = _clean_parameters_json_schema(schema) - else: - normalized_declaration.pop("parametersJsonSchema", None) - - normalized_declarations.append(normalized_declaration) - - normalized_tool.pop("function_declarations", None) - normalized_tool["functionDeclarations"] = normalized_declarations - - normalized_tools.append(normalized_tool) - - return normalized_tools - - -def _ensure_empty_tool_schema_for_claude(tools: Any, model_name: str) -> Any: - if "claude" not in (model_name or "").lower() or not isinstance(tools, list): - return tools - - normalized_tools = [] - for tool in tools: - if not isinstance(tool, dict): - normalized_tools.append(tool) - continue - - normalized_tool = tool.copy() - custom_tool = normalized_tool.get("custom") - if isinstance(custom_tool, dict) and "input_schema" not in custom_tool: - normalized_custom = custom_tool.copy() - normalized_custom["input_schema"] = {"type": "object", "properties": {}} - normalized_tool["custom"] = normalized_custom - - declarations = normalized_tool.get("functionDeclarations") - if declarations is None: - declarations = normalized_tool.get("function_declarations") - if isinstance(declarations, list): - normalized_declarations = [] - for declaration in declarations: - if not isinstance(declaration, dict): - normalized_declarations.append(declaration) - continue - normalized_declaration = declaration.copy() - if ( - "parametersJsonSchema" not in normalized_declaration - and "parameters_json_schema" in normalized_declaration - ): - normalized_declaration["parametersJsonSchema"] = normalized_declaration.pop("parameters_json_schema") - - if "parametersJsonSchema" not in normalized_declaration: - normalized_declaration["parametersJsonSchema"] = { - "type": "object", - "properties": {}, - } - normalized_declarations.append(normalized_declaration) - normalized_tool.pop("function_declarations", None) - normalized_tool["functionDeclarations"] = normalized_declarations - - normalized_tools.append(normalized_tool) - - return normalized_tools - - -def _should_skip_thought_signature(part: Dict[str, Any], model_name: str) -> bool: - if "claude" in (model_name or "").lower(): - return False - - return ( - "functionCall" in part - or "function_call" in part - or part.get("thought") is True - or "thoughtSignature" in part - or "thought_signature" in part - ) - - -def _normalize_part_thought_signature(part: Dict[str, Any], model_name: str) -> Dict[str, Any]: - normalized = part.copy() - if _should_skip_thought_signature(normalized, model_name): - normalized.pop("thought_signature", None) - normalized["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR - return normalized - - -SUPPORTED_ASPECT_RATIOS = [ - (1, 1), (2, 3), (3, 2), (3, 4), (4, 3), - (4, 5), (5, 4), (9, 16), (16, 9), (21, 9), -] - - -def _parse_size_to_image_config(size_str: str) -> Dict[str, str]: - """ - 解析用户传入的 size 参数为 Gemini imageConfig 参数 - - 支持格式: "1024x1536", "1024*1536", "1024X1536" - - Returns: - 包含 aspectRatio 和/或 imageSize 的字典 - """ - import re - - config = {} - size_str = size_str.strip() - - match = re.match(r"^(\d+)\s*[xX*×]\s*(\d+)$", size_str) - if not match: - return config - - width, height = int(match.group(1)), int(match.group(2)) - - if width <= 0 or height <= 0: - return config - - # 计算最接近的支持宽高比 - target_ratio = width / height - best_ratio = None - best_diff = float("inf") - for w, h in SUPPORTED_ASPECT_RATIOS: - diff = abs(target_ratio - w / h) - if diff < best_diff: - best_diff = diff - best_ratio = f"{w}:{h}" - if best_ratio: - config["aspectRatio"] = best_ratio - - # 根据最大边长确定 imageSize(使用最接近的档位) - max_dim = max(width, height) - if max_dim <= 1280: - config["imageSize"] = "1K" - elif max_dim <= 2560: - config["imageSize"] = "2K" - else: - config["imageSize"] = "4K" - - return config - - -def prepare_image_generation_request( - request_body: Dict[str, Any], - model: str -) -> Dict[str, Any]: - """ - 图像生成模型请求体后处理 - - 支持三种方式指定图片参数(优先级从高到低): - 1. size 参数: 如 "1024x1536",自动计算 aspectRatio 和 imageSize - 2. 模型名后缀: 如 -4k, -2k, -16x9, -1x1 - 3. 默认值: 不设置额外参数 - - Args: - request_body: 原始请求体 - model: 模型名称 - - Returns: - 处理后的请求体 - """ - request_body = request_body.copy() - model_lower = model.lower() - - # 优先使用 size 参数 - size_str = request_body.pop("size", None) - if size_str: - image_config = _parse_size_to_image_config(size_str) - log.debug(f"[IMAGE] 从 size 参数 '{size_str}' 解析: {image_config}") - else: - # 从模型名后缀解析 - image_size = "4K" if "-4k" in model_lower else "2K" if "-2k" in model_lower else None - - aspect_ratio = None - for suffix, ratio in [ - ("-21x9", "21:9"), ("-16x9", "16:9"), ("-9x16", "9:16"), - ("-4x3", "4:3"), ("-3x4", "3:4"), ("-1x1", "1:1") - ]: - if suffix in model_lower: - aspect_ratio = ratio - break - - image_config = {} - if aspect_ratio: - image_config["aspectRatio"] = aspect_ratio - if image_size: - image_config["imageSize"] = image_size - - request_body["model"] = "gemini-3.1-flash-image" # 统一使用基础模型名 - request_body["generationConfig"] = { - "candidateCount": 1, - "imageConfig": image_config - } - - # 移除不需要的字段 - for key in ("systemInstruction", "tools", "toolConfig"): - request_body.pop(key, None) - - return request_body - - -# ==================== 模型特性辅助函数 ==================== - -def get_base_model_name(model_name: str) -> str: - """移除模型名称中的后缀,返回基础模型名""" - # 按照从长到短的顺序排列,避免短后缀先于长后缀被匹配 - suffixes = [ - "-maxthinking", "-nothinking", # 兼容旧模式 - "-minimal", "-medium", "-search", "-think", # 中等长度后缀 - "-high", "-max", "-low" # 短后缀 - ] - result = model_name - changed = True - # 持续循环直到没有任何后缀可以移除 - while changed: - changed = False - for suffix in suffixes: - if result.endswith(suffix): - result = result[:-len(suffix)] - changed = True - # 不使用 break,继续检查是否还有其他后缀 - return result - - -def get_thinking_settings(model_name: str) -> tuple[Optional[int], Optional[str]]: - """ - 根据模型名称获取思考配置 - - 支持两种模式: - 1. CLI 模式思考预算 (Gemini 2.5 系列): -max, -high, -medium, -low, -minimal - 2. CLI 模式思考等级 (Gemini 3 Preview 系列): -high, -medium, -low, -minimal (仅 3-flash) - 3. 兼容旧模式: -maxthinking, -nothinking (不返回给用户) - - Returns: - (thinking_budget, thinking_level): 思考预算和思考等级 - """ - base_model = get_base_model_name(model_name) - - # ========== 兼容旧模式 (不返回给用户) ========== - if "-nothinking" in model_name: - # nothinking 模式: 限制思考 - if "flash" in base_model: - return 0, None - return 128, None - elif "-maxthinking" in model_name: - # maxthinking 模式: 最大思考预算 - budget = 24576 if "flash" in base_model else 32768 - if "gemini-3" in base_model: - # Gemini 3 系列不支持 thinkingBudget,返回 high 等级 - return None, "high" - else: - return budget, None - - # ========== 新 CLI 模式: 基于思考预算/等级 ========== - - # Gemini 3 Preview 系列: 使用 thinkingLevel - if "gemini-3" in base_model: - if "-high" in model_name: - return None, "high" - elif "-medium" in model_name: - # 仅 3-flash-preview 支持 medium - if "flash" in base_model: - return None, "medium" - # pro 系列不支持 medium,返回 Default - return None, None - elif "-low" in model_name: - return None, "low" - elif "-minimal" in model_name: - return None, None - else: - # Default: 不设置 thinking 配置 - return None, None - - # Gemini 2.5 系列: 使用 thinkingBudget - elif "gemini-2.5" in base_model: - if "-max" in model_name: - # 2.5-flash-max: 24576, 2.5-pro-max: 32768 - budget = 24576 if "flash" in base_model else 32768 - return budget, None - elif "-high" in model_name: - # 2.5-flash-high: 16000, 2.5-pro-high: 16000 - return 16000, None - elif "-medium" in model_name: - # 2.5-flash-medium: 8192, 2.5-pro-medium: 8192 - return 8192, None - elif "-low" in model_name: - # 2.5-flash-low: 1024, 2.5-pro-low: 1024 - return 1024, None - elif "-minimal" in model_name: - # 2.5-flash-minimal: 0, 2.5-pro-minimal: 128 - budget = 0 if "flash" in base_model else 128 - return budget, None - else: - # Default: 不设置 thinking budget - return None, None - - # 其他模型: 不设置 thinking 配置 - return None, None - - -def is_search_model(model_name: str) -> bool: - """检查是否为搜索模型""" - return "-search" in model_name - - -# ==================== 统一的 Gemini 请求后处理 ==================== - -def is_thinking_model(model_name: str) -> bool: - """检查是否为思考模型 (包含 -thinking 或 pro)""" - return "think" in model_name or "pro" in model_name.lower() - - -async def normalize_gemini_request( - request: Dict[str, Any], - mode: str = "geminicli" -) -> Dict[str, Any]: - """ - 规范化 Gemini 请求 - - 处理逻辑: - 1. 模型特性处理 (thinking config, search tools) - 3. 参数范围限制 (maxOutputTokens, topK) - 4. 工具清理 - - Args: - request: 原始请求字典 - mode: 模式 ("geminicli" 或 "antigravity") - - Returns: - 规范化后的请求 - """ - # 导入配置函数 - from config import get_return_thoughts_to_frontend - - result = request.copy() - model = result.get("model", "") - generation_config = (result.get("generationConfig") or {}).copy() # 创建副本避免修改原对象 - tools = result.get("tools") - system_instruction = result.get("systemInstruction") or result.get("system_instructions") - - # 记录原始请求 - log.debug(f"[GEMINI_FIX] 原始请求 - 模型: {model}, mode: {mode}, generationConfig: {generation_config}") - - # 获取配置值 - return_thoughts = await get_return_thoughts_to_frontend() - - # ========== 模式特定处理 ========== - if mode == "geminicli": - # 1. 思考设置 - # 优先使用 get_thinking_settings 获取的思考预算和等级 - thinking_budget, thinking_level = get_thinking_settings(model) - - # 其次使用传入的思考预算(如果未从模型名称获取) - if thinking_budget is None and thinking_level is None: - thinking_budget = generation_config.get("thinkingConfig", {}).get("thinkingBudget") - thinking_level = generation_config.get("thinkingConfig", {}).get("thinkingLevel") - - # 假如 is_thinking_model 为真或者思考预算/等级不为空,设置 thinkingConfig - if is_thinking_model(model) or thinking_budget is not None or thinking_level is not None: - # 确保 thinkingConfig 存在 - if "thinkingConfig" not in generation_config: - generation_config["thinkingConfig"] = {} - - thinking_config = generation_config["thinkingConfig"] - - # 设置思考预算或等级(互斥) - if thinking_budget is not None: - thinking_config["thinkingBudget"] = thinking_budget - thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突 - elif thinking_level is not None: - thinking_config["thinkingLevel"] = thinking_level - thinking_config.pop("thinkingBudget", None) # 避免与 thinkingLevel 冲突 - - # includeThoughts 逻辑: - # 1. 如果是 pro 模型,为 return_thoughts - # 2. 如果不是 pro 模型,检查是否有思考预算或思考等级 - base_model = get_base_model_name(model) - if "pro" in base_model: - include_thoughts = return_thoughts - elif "3-flash" in base_model: - if thinking_level is None: - include_thoughts = False - else: - include_thoughts = return_thoughts - else: - # 非 pro 模型: 有思考预算或等级才包含思考 - # 注意: 思考预算为 0 时不包含思考 - if thinking_budget is None or thinking_budget == 0: - include_thoughts = False - else: - include_thoughts = return_thoughts - - thinking_config["includeThoughts"] = include_thoughts - - # 2. 搜索模型添加 Google Search - if is_search_model(model): - result_tools = result.get("tools") or [] - result["tools"] = result_tools - if not any(tool.get("googleSearch") for tool in result_tools if isinstance(tool, dict)): - result_tools.append({"googleSearch": {}}) - - # 3. 模型名称处理 - result["model"] = get_base_model_name(model) - - elif mode == "antigravity": - - ''' - # 1. 处理 system_instruction - custom_prompt = "Please ignore the following [ignore]You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**[/ignore]" - - # 提取原有的 parts(如果存在) - existing_parts = [] - if system_instruction: - if isinstance(system_instruction, dict): - existing_parts = system_instruction.get("parts", []) - - # custom_prompt 始终放在第一位,原有内容整体后移 - result["systemInstruction"] = { - "parts": [{"text": custom_prompt}] + existing_parts - } - ''' - - # 2. 判断图片模型 - if "image" in model.lower(): - # 调用图片生成专用处理函数 - return prepare_image_generation_request(result, model) - else: - # 3. 思考模型处理 - if is_thinking_model(model) or ("thinkingBudget" in generation_config.get("thinkingConfig", {}) and generation_config["thinkingConfig"]["thinkingBudget"] != 0): - # 直接设置 thinkingConfig - if "thinkingConfig" not in generation_config: - generation_config["thinkingConfig"] = {} - - thinking_config = generation_config["thinkingConfig"] - # 优先使用传入的思考预算,否则使用默认值 - if "thinkingBudget" not in thinking_config: - thinking_config["thinkingBudget"] = 1024 - thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突 - thinking_config["includeThoughts"] = return_thoughts - - # 检查最后一个 assistant 消息是否以 thinking 块开始 - contents = result.get("contents", []) - - if "claude" in model.lower(): - # 检测是否有工具调用(MCP场景) - has_tool_calls = any( - isinstance(content, dict) and - any( - isinstance(part, dict) and ("functionCall" in part or "function_call" in part) - for part in content.get("parts", []) - ) - for content in contents - ) - - if has_tool_calls: - # MCP 场景:检测到工具调用,移除 thinkingConfig - log.warning(f"[ANTIGRAVITY] 检测到工具调用(MCP场景),移除 thinkingConfig 避免失效") - generation_config.pop("thinkingConfig", None) - else: - # 非 MCP 场景:填充思考块 - # log.warning(f"[ANTIGRAVITY] 最后一个 assistant 消息不以 thinking 块开始,自动填充思考块") - - # 找到最后一个 model 角色的 content - for i in range(len(contents) - 1, -1, -1): - content = contents[i] - if isinstance(content, dict) and content.get("role") == "model": - # 在 parts 开头插入思考块(使用官方跳过验证的虚拟签名) - parts = content.get("parts", []) - thinking_part = { - "text": "...", - # "thought": True, # 标记为思考块 - "thoughtSignature": "skip_thought_signature_validator" # 官方文档推荐的虚拟签名 - } - # 如果第一个 part 不是 thinking,则插入 - if not parts or not (isinstance(parts[0], dict) and ("thought" in parts[0] or "thoughtSignature" in parts[0])): - content["parts"] = [thinking_part] + parts - log.debug(f"[ANTIGRAVITY] 已在最后一个 assistant 消息开头插入思考块(含跳过验证签名)") - break - - # 移除 -thinking 后缀 - model = model.replace("-thinking", "") - - # 4. Claude 模型关键词映射 - # 使用关键词匹配而不是精确匹配,更灵活地处理各种变体 - original_model = model - if "opus" in model.lower(): - model = "claude-opus-4-6-thinking" - elif "sonnet" in model.lower(): - model = "claude-sonnet-4-6" - elif "haiku" in model.lower(): - model = "gemini-2.5-flash" - elif "claude" in model.lower(): - # Claude 模型兜底:如果包含 claude 但不是 opus/sonnet/haiku - model = "claude-sonnet-4-6" - - result["model"] = model - if original_model != model: - log.debug(f"[ANTIGRAVITY] 映射模型: {original_model} -> {model}") - - # 5. 模型特殊处理:循环移除末尾的 model 消息,保证以用户消息结尾 - # 因为该模型不支持预填充 - if "claude-opus-4-6-thinking" in model.lower() or "claude-sonnet-4-6" in model.lower(): - contents = result.get("contents", []) - removed_count = 0 - while contents and isinstance(contents[-1], dict) and contents[-1].get("role") == "model": - contents.pop() - removed_count += 1 - if removed_count > 0: - log.warning(f"[ANTIGRAVITY] {model} 不支持预填充,移除了 {removed_count} 条末尾 model 消息") - result["contents"] = contents - - # 6. 移除 antigravity 模式不支持的字段 - generation_config.pop("presencePenalty", None) - generation_config.pop("frequencyPenalty", None) - generation_config.pop("stopSequences", None) - - # ========== 公共处理 ========== - - # 1. 安全设置覆盖 - if "tools" in result: - result["tools"] = _normalize_tools_for_internal_api(result.get("tools")) - result["tools"] = _ensure_empty_tool_schema_for_claude(result.get("tools"), model) - - if "lite" in model.lower(): - result["safetySettings"] = LITE_SAFETY_SETTINGS - else: - result["safetySettings"] = DEFAULT_SAFETY_SETTINGS - - # 2. 参数范围限制 - if generation_config: - # 强制设置 maxOutputTokens 为 64000 - generation_config["maxOutputTokens"] = 64000 - # 强制设置 topK 为 64 - generation_config["topK"] = 64 - - if "contents" in result: - cleaned_contents = [] - for content in result["contents"]: - if isinstance(content, dict) and "parts" in content: - # 过滤掉空的或无效的 parts - valid_parts = [] - for part in content["parts"]: - if not isinstance(part, dict): - continue - - # 检查 part 是否有有效的非空值 - # 过滤掉空字典或所有值都为空的 part - has_valid_value = any( - value not in (None, "", {}, []) - for key, value in part.items() - if key != "thought" # thought 字段可以为空 - ) - - if has_valid_value: - part = _normalize_part_thought_signature(part, model) - - # 修复 text 字段:确保是字符串而不是列表 - if "text" in part: - text_value = part["text"] - if isinstance(text_value, list): - # 如果是列表,合并为字符串 - log.warning(f"[GEMINI_FIX] text 字段是列表,自动合并: {text_value}") - part["text"] = " ".join(str(t) for t in text_value if t) - elif isinstance(text_value, str): - # 清理尾随空格 - part["text"] = text_value.rstrip() - else: - # 其他类型转为字符串 - log.warning(f"[GEMINI_FIX] text 字段类型异常 ({type(text_value)}), 转为字符串: {text_value}") - part["text"] = str(text_value) - - valid_parts.append(part) - else: - log.warning(f"[GEMINI_FIX] 移除空的或无效的 part: {part}") - - # 只添加有有效 parts 的 content - if valid_parts: - cleaned_content = content.copy() - cleaned_content["parts"] = valid_parts - cleaned_contents.append(cleaned_content) - else: - log.warning(f"[GEMINI_FIX] 跳过没有有效 parts 的 content: {content.get('role')}") - else: - cleaned_contents.append(content) - - result["contents"] = cleaned_contents - - if generation_config: - result["generationConfig"] = generation_config - - return result diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py deleted file mode 100644 index 42f347fd3af5a80f857c147f63d73c31cd0c0b7a..0000000000000000000000000000000000000000 --- a/src/converter/openai2gemini.py +++ /dev/null @@ -1,1801 +0,0 @@ -""" -OpenAI Transfer Module - Handles conversion between OpenAI and Gemini API formats -被openai-router调用,负责OpenAI格式与Gemini格式的双向转换 -""" - -import json -import time -import uuid -from typing import Any, Dict, List, Optional, Tuple, Union - -from pypinyin import Style, lazy_pinyin - -from src.converter.thoughtSignature_fix import ( - decode_tool_id_and_signature, - is_internal_placeholder_text, - is_skip_thought_signature_placeholder, - SKIP_THOUGHT_SIGNATURE_VALIDATOR, -) -from src.converter.utils import merge_system_messages - -from log import log - -def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - 将Gemini的usageMetadata转换为OpenAI格式的usage字段 - - Args: - usage_metadata: Gemini API的usageMetadata字段 - - Returns: - OpenAI格式的usage字典,如果没有usage数据则返回None - """ - if not usage_metadata: - return None - - prompt_tokens_total = int(usage_metadata.get("promptTokenCount", 0) or 0) - cached_tokens = int(usage_metadata.get("cachedContentTokenCount", 0) or 0) - prompt_tokens = max(prompt_tokens_total - cached_tokens, 0) - completion_tokens = int(usage_metadata.get("candidatesTokenCount", 0) or 0) - raw_total_tokens = int( - usage_metadata.get( - "totalTokenCount", - prompt_tokens_total + completion_tokens + int(usage_metadata.get("thoughtsTokenCount", 0) or 0), - ) - or 0 - ) - - usage = { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": max(raw_total_tokens - cached_tokens, prompt_tokens + completion_tokens), - } - - if cached_tokens > 0: - usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} - - reasoning_tokens = int(usage_metadata.get("thoughtsTokenCount", 0) or 0) - if reasoning_tokens > 0: - usage["completion_tokens_details"] = {"reasoning_tokens": reasoning_tokens} - - return usage - - -def _build_message_with_reasoning(role: str, content: str, reasoning_content: str) -> dict: - """构建包含可选推理内容的消息对象""" - message = {"role": role, "content": content} - - # 如果有thinking tokens,添加reasoning_content - if reasoning_content: - message["reasoning_content"] = reasoning_content - - return message - - -def _map_finish_reason(gemini_reason: str) -> str: - """ - 将Gemini结束原因映射到OpenAI结束原因 - - Args: - gemini_reason: 来自Gemini API的结束原因 - - Returns: - OpenAI兼容的结束原因 - """ - if gemini_reason == "STOP": - return "stop" - elif gemini_reason == "MAX_TOKENS": - return "length" - elif gemini_reason in ["SAFETY", "RECITATION"]: - return "content_filter" - else: - # 对于 None 或未知的 finishReason,返回 "stop" 作为默认值 - # 避免返回 None 导致 MCP 客户端误判为响应未完成而循环调用 - return "stop" - - -# ==================== Tool Conversion Functions ==================== - - -def _normalize_function_name(name: str) -> str: - """ - 规范化函数名以符合 Gemini API 要求 - - 规则: - - 必须以字母或下划线开头 - - 只能包含 a-z, A-Z, 0-9, 下划线, 英文句点, 英文短划线 - - 最大长度 64 个字符 - - 转换策略: - 1. 中文字符转换为拼音 - 2. 将非法字符替换为下划线 - 3. 如果以非字母/下划线开头,添加下划线前缀 - 4. 截断到 64 个字符 - - Args: - name: 原始函数名 - - Returns: - 规范化后的函数名 - """ - import re - - if not name: - return "_unnamed_function" - - # 步骤1:转换中文字符为拼音 - if re.search(r"[\u4e00-\u9fff]", name): - try: - parts = [] - for char in name: - if "\u4e00" <= char <= "\u9fff": - # 中文字符转换为拼音 - pinyin = lazy_pinyin(char, style=Style.NORMAL) - parts.append("".join(pinyin)) - else: - parts.append(char) - normalized = "".join(parts) - except ImportError: - log.warning("pypinyin not installed, cannot convert Chinese characters to pinyin") - normalized = name - else: - normalized = name - - # 步骤2:将非法字符替换为下划线 - # 合法字符:a-z, A-Z, 0-9, _, ., - - normalized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", normalized) - - # 步骤3:确保以字母或下划线开头 - if normalized and not (normalized[0].isalpha() or normalized[0] == "_"): - # 以数字、点或短横线开头,添加下划线前缀 - normalized = "_" + normalized - - # 步骤4:截断到 64 个字符 - if len(normalized) > 64: - normalized = normalized[:64] - - # 步骤5:确保不为空 - if not normalized: - normalized = "_unnamed_function" - - return normalized - - -def _resolve_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - 解析 $ref 或 ref 引用 - - Args: - ref: 引用路径,如 "#/definitions/MyType" 或 "#/$defs/MyType" - root_schema: 根 schema 对象 - - Returns: - 解析后的 schema,如果失败返回 None - """ - if not isinstance(ref, str): - return None - - if not ref.startswith('#/'): - # 尝试在 definitions 或 $defs 中查找 - for key in ["definitions", "$defs"]: - if key in root_schema and ref in root_schema[key]: - return root_schema[key][ref] - return None - - path = ref[2:].split('/') - current = root_schema - - for segment in path: - if isinstance(current, dict) and segment in current: - current = current[segment] - else: - return None - - return current if isinstance(current, dict) else None - - -def _clean_schema_for_claude(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any: - """ - 清理 JSON Schema,转换为 Claude API 支持的格式(符合 JSON Schema draft 2020-12) - - 处理逻辑: - 1. 解析 $ref 引用 - 2. 合并 allOf 中的 schema - 3. 转换 anyOf 为更兼容的格式 - 4. 保持标准 JSON Schema 类型(不转换为大写) - 5. 处理 array 的 items - 6. 清理 Claude 不支持的字段 - - Args: - schema: JSON Schema 对象 - root_schema: 根 schema(用于解析 $ref) - visited: 已访问的对象集合(防止循环引用) - - Returns: - 清理后的 schema - """ - # 非字典类型直接返回 - if not isinstance(schema, dict): - return schema - - # 初始化 - if root_schema is None: - root_schema = schema - if visited is None: - visited = set() - - # 防止循环引用 - schema_id = id(schema) - if schema_id in visited: - return schema - visited.add(schema_id) - - # 创建副本避免修改原对象 - result = {} - - # 1. 处理 $ref - if "$ref" in schema: - resolved = _resolve_ref(schema["$ref"], root_schema) - if resolved: - import copy - result = copy.deepcopy(resolved) - for key, value in schema.items(): - if key != "$ref": - result[key] = value - schema = result - result = {} - - # 2. 处理 allOf(合并所有 schema) - if "allOf" in schema: - all_of_schemas = schema["allOf"] - for item in all_of_schemas: - cleaned_item = _clean_schema_for_claude(item, root_schema, visited) - - if "properties" in cleaned_item: - if "properties" not in result: - result["properties"] = {} - result["properties"].update(cleaned_item["properties"]) - - if "required" in cleaned_item: - if "required" not in result: - result["required"] = [] - result["required"].extend(cleaned_item["required"]) - - for key, value in cleaned_item.items(): - if key not in ["properties", "required"]: - result[key] = value - - for key, value in schema.items(): - if key not in ["allOf", "properties", "required"]: - result[key] = value - elif key in ["properties", "required"] and key not in result: - result[key] = value - else: - result = dict(schema) - - # 3. 处理 type 数组(如 ["string", "null"]) - if "type" in result: - type_value = result["type"] - if isinstance(type_value, list): - # Claude 支持 type 数组,保持不变 - pass - - # 4. 处理 array 的 items - if result.get("type") == "array": - if "items" not in result: - result["items"] = {} - elif isinstance(result["items"], list): - # Tuple 定义,检查是否所有元素类型相同 - tuple_items = result["items"] - first_type = tuple_items[0].get("type") if tuple_items else None - is_homogeneous = all(item.get("type") == first_type for item in tuple_items) - - if is_homogeneous and first_type: - result["items"] = _clean_schema_for_claude(tuple_items[0], root_schema, visited) - else: - # 异质元组,使用 anyOf 表示 - result["items"] = { - "anyOf": [_clean_schema_for_claude(item, root_schema, visited) for item in tuple_items] - } - else: - result["items"] = _clean_schema_for_claude(result["items"], root_schema, visited) - - # 5. 处理 anyOf(保持 anyOf,递归清理) - if "anyOf" in result: - result["anyOf"] = [_clean_schema_for_claude(item, root_schema, visited) for item in result["anyOf"]] - - # 6. 清理 Claude 不支持的字段(根据 JSON Schema 2020-12) - # Claude API 对某些字段比较严格,移除可能导致问题的字段 - unsupported_keys = { - "title", "$schema", "strict", - "additionalItems", # 废弃字段,使用 items 替代 - "exclusiveMaximum", "exclusiveMinimum", # 在 2020-12 中这些应该是数值而非布尔值 - "$defs", "definitions", # 移除 definitions 相关字段避免冲突 - "example", "examples", "readOnly", "writeOnly", - "const", # const 可能导致问题 - "contentEncoding", "contentMediaType", - "oneOf", # oneOf 可能导致问题,用 anyOf 替代 - "patternProperties", "dependencies", "propertyNames", # Google API 不支持 - } - - for key in list(result.keys()): - if key in unsupported_keys: - del result[key] - - # 递归处理 additionalProperties(如果存在) - if "additionalProperties" in result and isinstance(result["additionalProperties"], dict): - result["additionalProperties"] = _clean_schema_for_claude(result["additionalProperties"], root_schema, visited) - - # 7. 递归处理 properties - if "properties" in result: - cleaned_props = {} - for prop_name, prop_schema in result["properties"].items(): - cleaned_props[prop_name] = _clean_schema_for_claude(prop_schema, root_schema, visited) - result["properties"] = cleaned_props - - # 8. 确保有 type 字段(如果有 properties 但没有 type) - if "properties" in result and "type" not in result: - result["type"] = "object" - - # 9. 去重 required 数组 - if "required" in result and isinstance(result["required"], list): - result["required"] = list(dict.fromkeys(result["required"])) - - return result - - -def _clean_schema_for_gemini(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any: - """ - 清理 JSON Schema,转换为 Gemini 支持的格式 - - 参考 worker.mjs 的 transformOpenApiSchemaToGemini 实现 - - 处理逻辑: - 1. 解析 $ref 引用 - 2. 合并 allOf 中的 schema - 3. 转换 anyOf 为 enum(如果可能) - 4. 类型映射(string -> STRING) - 5. 处理 ARRAY 的 items(包括 Tuple) - 6. 将 default 值移到 description - 7. 清理不支持的字段 - - Args: - schema: JSON Schema 对象 - root_schema: 根 schema(用于解析 $ref) - visited: 已访问的对象集合(防止循环引用) - - Returns: - 清理后的 schema - """ - # 非字典类型直接返回 - if not isinstance(schema, dict): - return schema - - # 初始化 - if root_schema is None: - root_schema = schema - if visited is None: - visited = set() - - # 防止循环引用 - schema_id = id(schema) - if schema_id in visited: - return schema - visited.add(schema_id) - - # 创建副本避免修改原对象 - result = {} - - # 1. 处理 $ref 或 ref - ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None) - if ref_key: - resolved = _resolve_ref(schema[ref_key], root_schema) - if resolved: - # 检测循环引用 - resolved_id = id(resolved) - if resolved_id in visited: - return {"type": "OBJECT", "description": "(circular reference)"} - - visited.add(resolved_id) - # 合并解析后的 schema - merged = dict(resolved) - - # 重要:根据 Gemini API 限制,当存在引用时,只能并列存在 description 和 default - # 其他字段(如 type, properties 等)必须丢弃,否则会触发 400 错误 - for key in ["description", "default"]: - if key in schema: - merged[key] = schema[key] - - schema = merged - result = {} - - # 2. 处理 allOf(合并所有 schema) - if "allOf" in schema: - all_of_schemas = schema["allOf"] - for item in all_of_schemas: - cleaned_item = _clean_schema_for_gemini(item, root_schema, visited) - - # 合并 properties - if "properties" in cleaned_item: - if "properties" not in result: - result["properties"] = {} - result["properties"].update(cleaned_item["properties"]) - - # 合并 required - if "required" in cleaned_item: - if "required" not in result: - result["required"] = [] - result["required"].extend(cleaned_item["required"]) - - # 合并其他字段(简单覆盖) - for key, value in cleaned_item.items(): - if key not in ["properties", "required"]: - result[key] = value - - # 复制其他字段 - for key, value in schema.items(): - if key not in ["allOf", "properties", "required"]: - result[key] = value - elif key in ["properties", "required"] and key not in result: - result[key] = value - else: - # 复制所有字段 - result = dict(schema) - - # 3. 类型映射(转换为大写) - # 注意:Gemini API 的 type 字段必须是字符串,不能是数组 - if "type" in result: - type_value = result["type"] - - # 如果 type 是列表,提取主要类型(非 null) - if isinstance(type_value, list): - primary_type = next((t for t in type_value if t != "null"), None) - type_value = primary_type if primary_type else "STRING" # 默认为 STRING - - # 类型映射 - type_map = { - "string": "STRING", - "number": "NUMBER", - "integer": "INTEGER", - "boolean": "BOOLEAN", - "array": "ARRAY", - "object": "OBJECT", - } - - if isinstance(type_value, str) and type_value.lower() in type_map: - # 确保 result["type"] 是字符串而不是列表 - result["type"] = type_map[type_value.lower()] - else: - # 未知类型,删除该字段 - del result["type"] - - # 4. 处理 ARRAY 的 items - if result.get("type") == "ARRAY": - if "items" not in result: - # 没有 items,默认允许任意类型 - result["items"] = {} - elif isinstance(result["items"], list): - # Tuple 定义(items 是数组) - tuple_items = result["items"] - - # 提取类型信息用于 description - tuple_types = [item.get("type", "any") for item in tuple_items] - tuple_desc = f"(Tuple: [{', '.join(tuple_types)}])" - - original_desc = result.get("description", "") - result["description"] = f"{original_desc} {tuple_desc}".strip() - - # 检查是否所有元素类型相同 - first_type = tuple_items[0].get("type") if tuple_items else None - is_homogeneous = all(item.get("type") == first_type for item in tuple_items) - - if is_homogeneous and first_type: - # 同质元组,转换为 List - result["items"] = _clean_schema_for_gemini(tuple_items[0], root_schema, visited) - else: - # 异质元组,Gemini 不支持,设为 {} - result["items"] = {} - else: - # 递归处理 items - result["items"] = _clean_schema_for_gemini(result["items"], root_schema, visited) - - # 5. 处理 anyOf(尝试转换为 enum) - if "anyOf" in result: - any_of_schemas = result["anyOf"] - - # 递归处理每个 schema - cleaned_any_of = [_clean_schema_for_gemini(item, root_schema, visited) for item in any_of_schemas] - - # 尝试提取 enum - if all("const" in item for item in cleaned_any_of): - enum_values = [ - str(item["const"]) - for item in cleaned_any_of - if item.get("const") not in ["", None] - ] - if enum_values: - result["type"] = "STRING" - result["enum"] = enum_values - elif "type" not in result: - # 如果不是 enum,尝试取第一个有效的类型定义 - first_valid = next((item for item in cleaned_any_of if item.get("type") or item.get("enum")), None) - if first_valid: - result.update(first_valid) - - # 删除 anyOf - del result["anyOf"] - - # 6. 将 default 值移到 description - if "default" in result: - default_value = result["default"] - original_desc = result.get("description", "") - result["description"] = f"{original_desc} (Default: {json.dumps(default_value)})".strip() - del result["default"] - - # 7. 清理不支持的字段 - unsupported_keys = { - "title", "$schema", "$ref", "ref", "strict", "exclusiveMaximum", - "exclusiveMinimum", "additionalProperties", "oneOf", "allOf", - "$defs", "definitions", "example", "examples", "readOnly", - "writeOnly", "const", "additionalItems", "contains", - "patternProperties", "dependencies", "propertyNames", - "if", "then", "else", "contentEncoding", "contentMediaType" - } - - for key in list(result.keys()): - if key in unsupported_keys: - del result[key] - - # 8. 递归处理 properties - if "properties" in result: - cleaned_props = {} - for prop_name, prop_schema in result["properties"].items(): - cleaned_props[prop_name] = _clean_schema_for_gemini(prop_schema, root_schema, visited) - result["properties"] = cleaned_props - - # 9. 确保有 type 字段(如果有 properties 但没有 type) - if "properties" in result and "type" not in result: - result["type"] = "OBJECT" - - # 10. 去重 required 数组 - if "required" in result and isinstance(result["required"], list): - result["required"] = list(dict.fromkeys(result["required"])) # 保持顺序去重 - - return result - - -def _append_schema_hint(schema: Dict[str, Any], hint: str) -> None: - """把不兼容的校验信息挪到 description 里,避免上游直接拒收。""" - if not hint: - return - desc = schema.get("description") - schema["description"] = f"{desc} ({hint})" if desc else hint - - -def _clean_schema_for_parameters_json_schema( - schema: Any, - root_schema: Optional[Dict[str, Any]] = None, - visited: Optional[set] = None, -) -> Any: - """ - 清理 JSON Schema,供 Gemini CLI 内部接口的 parametersJsonSchema 使用。 - - Code Assist 的内部接口更接近官方 Gemini CLI:工具参数应放在 - parametersJsonSchema 中,并保持 JSON Schema 的小写 type。 - """ - if not isinstance(schema, dict): - return schema - - if root_schema is None: - root_schema = schema - if visited is None: - visited = set() - - schema_id = id(schema) - if schema_id in visited: - return {"type": "object", "description": "(circular reference)"} - visited.add(schema_id) - - result: Dict[str, Any] - - ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None) - if ref_key: - resolved = _resolve_ref(schema[ref_key], root_schema) - if resolved: - import copy - result = copy.deepcopy(resolved) - for key in ("description", "default"): - if key in schema: - result[key] = schema[key] - schema = result - - if "allOf" in schema: - result = {} - for item in schema.get("allOf") or []: - cleaned_item = _clean_schema_for_parameters_json_schema(item, root_schema, visited) - if not isinstance(cleaned_item, dict): - continue - if "properties" in cleaned_item: - result.setdefault("properties", {}).update(cleaned_item["properties"]) - if "required" in cleaned_item: - result.setdefault("required", []).extend(cleaned_item["required"]) - for key, value in cleaned_item.items(): - if key not in ("properties", "required"): - result[key] = value - for key, value in schema.items(): - if key not in ("allOf", "properties", "required"): - result[key] = value - elif key in ("properties", "required") and key not in result: - result[key] = value - else: - result = dict(schema) - - if "type" in result: - type_value = result["type"] - if isinstance(type_value, list): - non_null_types = [t for t in type_value if isinstance(t, str) and t.lower() != "null"] - if non_null_types: - result["type"] = non_null_types[0] - if "null" in [str(t).lower() for t in type_value]: - _append_schema_hint(result, "nullable") - else: - result["type"] = "string" - elif isinstance(type_value, str): - lower_type = type_value.lower() - if lower_type in {"string", "number", "integer", "boolean", "array", "object", "null"}: - result["type"] = "string" if lower_type == "null" else lower_type - else: - del result["type"] - - if "anyOf" in result or "oneOf" in result: - union_key = "anyOf" if "anyOf" in result else "oneOf" - union_items = result.get(union_key) or [] - cleaned_items = [ - item for item in ( - _clean_schema_for_parameters_json_schema(item, root_schema, visited) - for item in union_items - ) - if isinstance(item, dict) - ] - enum_values = [ - item.get("const") - for item in union_items - if isinstance(item, dict) and item.get("const") not in ("", None) - ] - if enum_values and len(enum_values) == len(union_items): - result["type"] = "string" - result["enum"] = [str(v) for v in enum_values] - else: - preferred = next( - ( - item for item in cleaned_items - if item.get("type") in ("object", "array") or item.get("properties") - ), - None, - ) - if preferred is None: - preferred = next((item for item in cleaned_items if item.get("type") or item.get("enum")), None) - if preferred: - existing_description = result.get("description") - result.update(preferred) - if existing_description: - _append_schema_hint(result, existing_description) - result.pop("anyOf", None) - result.pop("oneOf", None) - - if result.get("type") == "array": - items = result.get("items") - if isinstance(items, list): - if items: - result["items"] = _clean_schema_for_parameters_json_schema(items[0], root_schema, visited) - _append_schema_hint(result, "tuple schema simplified") - else: - result.pop("items", None) - elif isinstance(items, dict): - result["items"] = _clean_schema_for_parameters_json_schema(items, root_schema, visited) - - validation_keys = { - "default", "minLength", "maxLength", "minimum", "maximum", - "minItems", "maxItems", "pattern", "format", "uniqueItems", - } - for key in list(result.keys()): - if key in validation_keys: - value = result.pop(key) - if value not in (None, "", {}, []): - _append_schema_hint(result, f"{key}: {json.dumps(value, ensure_ascii=False)}") - - unsupported_keys = { - "title", "$schema", "$id", "$ref", "ref", "strict", - "exclusiveMaximum", "exclusiveMinimum", "additionalProperties", - "allOf", "anyOf", "oneOf", "$defs", "definitions", "example", - "examples", "readOnly", "writeOnly", "const", "additionalItems", - "contains", "patternProperties", "dependencies", "propertyNames", - "if", "then", "else", "contentEncoding", "contentMediaType", - } - for key in list(result.keys()): - if key in unsupported_keys or key.startswith("x-"): - del result[key] - - nullable_props = set() - if "properties" in result and isinstance(result["properties"], dict): - cleaned_props = {} - for prop_name, prop_schema in result["properties"].items(): - if isinstance(prop_schema, dict): - prop_type = prop_schema.get("type") - if isinstance(prop_type, list) and any(str(t).lower() == "null" for t in prop_type): - nullable_props.add(prop_name) - cleaned_props[prop_name] = _clean_schema_for_parameters_json_schema(prop_schema, root_schema, visited) - result["properties"] = cleaned_props - - if "properties" in result and "type" not in result: - result["type"] = "object" - - if "required" in result and isinstance(result["required"], list): - prop_names = set(result.get("properties", {}).keys()) if isinstance(result.get("properties"), dict) else None - required = [] - for item in result["required"]: - if not isinstance(item, str): - continue - if prop_names is not None and item not in prop_names: - continue - if item in nullable_props: - continue - if item not in required: - required.append(item) - if required: - result["required"] = required - else: - result.pop("required", None) - - return result - - -def fix_tool_call_args_types( - args: Dict[str, Any], - parameters_schema: Dict[str, Any] -) -> Dict[str, Any]: - """ - 根据工具的参数 schema 修正函数调用参数的类型 - - 例如:将字符串 "5" 转换为数字 5,根据 schema 中的 type 定义 - - Args: - args: 函数调用的参数字典 - parameters_schema: 工具定义中的 parameters schema - - Returns: - 类型修正后的参数字典 - """ - if not args or not parameters_schema: - return args - - properties = parameters_schema.get("properties", {}) - if not properties: - return args - - fixed_args = {} - for key, value in args.items(): - if key not in properties: - # 参数不在 schema 中,保持原样 - fixed_args[key] = value - continue - - param_schema = properties[key] - param_type = param_schema.get("type") - - # 根据 schema 中的类型修正参数值 - if param_type == "number" or param_type == "integer": - # 如果值是字符串,尝试转换为数字 - if isinstance(value, str): - try: - if param_type == "integer": - fixed_args[key] = int(value) - else: - # 尝试转换为 float,如果是整数则保持为 int - num_value = float(value) - fixed_args[key] = int(num_value) if num_value.is_integer() else num_value - log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} ({param_type})") - except (ValueError, AttributeError): - # 转换失败,保持原样 - fixed_args[key] = value - log.warning(f"[OPENAI2GEMINI] 无法将参数 {key} 的值 '{value}' 转换为 {param_type}") - else: - fixed_args[key] = value - elif param_type == "boolean": - # 如果值是字符串,转换为布尔值 - if isinstance(value, str): - if value.lower() in ("true", "1", "yes"): - fixed_args[key] = True - elif value.lower() in ("false", "0", "no"): - fixed_args[key] = False - else: - fixed_args[key] = value - if fixed_args[key] != value: - log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} (boolean)") - else: - fixed_args[key] = value - elif param_type == "string": - # 如果值不是字符串,转换为字符串 - if not isinstance(value, str): - fixed_args[key] = str(value) - log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} {value} -> '{fixed_args[key]}' (string)") - else: - fixed_args[key] = value - else: - # 其他类型(array, object 等)保持原样 - fixed_args[key] = value - - return fixed_args - - -def convert_openai_tools_to_gemini(openai_tools: List, model: str = "") -> List[Dict[str, Any]]: - """ - 将 OpenAI tools 格式转换为 Gemini functionDeclarations 格式 - - Args: - openai_tools: OpenAI 格式的工具列表(可能是字典或 Pydantic 模型) - model: 模型名称(用于判断是否为 Claude 模型) - - Returns: - Gemini 格式的工具列表 - """ - if not openai_tools: - return [] - - # 判断是否为 Claude 模型 - is_claude_model = "claude" in model.lower() - - function_declarations = [] - - for tool in openai_tools: - if tool.get("type") != "function": - log.warning(f"Skipping non-function tool type: {tool.get('type')}") - continue - - function = tool.get("function") - if not function: - log.warning("Tool missing 'function' field") - continue - - # 获取并规范化函数名 - original_name = function.get("name") - if not original_name: - log.warning("Tool missing 'name' field, using default") - original_name = "_unnamed_function" - - normalized_name = _normalize_function_name(original_name) - - # 如果名称被修改了,记录日志 - if normalized_name != original_name: - log.debug(f"Function name normalized: '{original_name}' -> '{normalized_name}'") - - # 构建 Gemini function declaration - declaration = { - "name": normalized_name, - "description": function.get("description", ""), - } - - # 添加参数(如果有)- Gemini CLI 内部接口更适合 parametersJsonSchema - if "parameters" in function: - if is_claude_model: - cleaned_params = _clean_schema_for_parameters_json_schema(function["parameters"]) - log.debug(f"[OPENAI2GEMINI] Using Claude schema cleaning for tool: {normalized_name}") - else: - cleaned_params = _clean_schema_for_parameters_json_schema(function["parameters"]) - - if cleaned_params: - declaration["parametersJsonSchema"] = cleaned_params - elif is_claude_model: - declaration["parametersJsonSchema"] = {"type": "object", "properties": {}} - elif is_claude_model: - declaration["parametersJsonSchema"] = {"type": "object", "properties": {}} - - function_declarations.append(declaration) - - if not function_declarations: - return [] - - # Gemini 格式:工具数组中包含 functionDeclarations - return [{"functionDeclarations": function_declarations}] - - -def convert_tool_choice_to_tool_config(tool_choice: Union[str, Dict[str, Any]]) -> Dict[str, Any]: - """ - 将 OpenAI tool_choice 转换为 Gemini toolConfig - - Args: - tool_choice: OpenAI 格式的 tool_choice - - Returns: - Gemini 格式的 toolConfig - """ - if isinstance(tool_choice, str): - if tool_choice == "auto": - return {"functionCallingConfig": {"mode": "AUTO"}} - elif tool_choice == "none": - return {"functionCallingConfig": {"mode": "NONE"}} - elif tool_choice == "required": - return {"functionCallingConfig": {"mode": "ANY"}} - elif isinstance(tool_choice, dict): - # {"type": "function", "function": {"name": "my_function"}} - if tool_choice.get("type") == "function": - function_name = tool_choice.get("function", {}).get("name") - if function_name: - return { - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": [function_name], - } - } - - # 默认返回 AUTO 模式 - return {"functionCallingConfig": {"mode": "AUTO"}} - - -def convert_tool_message_to_function_response(message, all_messages: List = None) -> Dict[str, Any]: - """ - 将 OpenAI 的 tool role 消息转换为 Gemini functionResponse - - Args: - message: OpenAI 格式的工具消息 - all_messages: 所有消息的列表,用于查找 tool_call_id 对应的函数名 - - Returns: - Gemini 格式的 functionResponse part - """ - # 获取 name 字段 - name = getattr(message, "name", None) - encoded_tool_call_id = getattr(message, "tool_call_id", None) or "" - - # 解码获取原始ID(functionResponse不需要签名) - original_tool_call_id, _ = decode_tool_id_and_signature(encoded_tool_call_id) - - # 如果没有 name,尝试从 all_messages 中查找对应的 tool_call_id - # 注意:使用编码ID查找,因为存储的是编码ID - if not name and encoded_tool_call_id and all_messages: - for msg in all_messages: - if getattr(msg, "role", None) == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls: - for tool_call in msg.tool_calls: - if getattr(tool_call, "id", None) == encoded_tool_call_id: - func = getattr(tool_call, "function", None) - if func: - name = getattr(func, "name", None) - break - if name: - break - - # 最终兜底:如果仍然没有 name,使用默认值 - if not name: - name = "unknown_function" - log.warning(f"Tool message missing function name, using default: {name}") - - try: - # 尝试将 content 解析为 JSON - response_data = ( - json.loads(message.content) if isinstance(message.content, str) else message.content - ) - except (json.JSONDecodeError, TypeError): - # 如果不是有效的 JSON,包装为对象 - response_data = {"result": str(message.content)} - - # 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象) - if not isinstance(response_data, dict): - response_data = {"result": response_data} - - return {"functionResponse": {"id": original_tool_call_id, "name": name, "response": response_data}} - - -def _reverse_transform_value(value: Any) -> Any: - """ - 将值转换回原始类型(Gemini 可能将所有值转为字符串) - - 仅处理 Gemini 在工具参数中常见的布尔/空值字符串化情况, - 不再对数字字符串做启发式转换,避免把 schema 声明为 string - 的参数错误还原成 integer。 - - 参考 worker.mjs 的 reverseTransformValue - - Args: - value: 要转换的值 - - Returns: - 转换后的值 - """ - if not isinstance(value, str): - return value - - # 布尔值 - if value == 'true': - return True - if value == 'false': - return False - - # null - if value == 'null': - return None - - # 其他情况保持字符串 - return value - - -def _reverse_transform_args(args: Any) -> Any: - """ - 递归转换函数参数,将字符串转回原始类型 - - 参考 worker.mjs 的 reverseTransformArgs - - Args: - args: 函数参数(可能是字典、列表或其他类型) - - Returns: - 转换后的参数 - """ - if not isinstance(args, (dict, list)): - return args - - if isinstance(args, list): - return [_reverse_transform_args(item) for item in args] - - # 处理字典 - result = {} - for key, value in args.items(): - if isinstance(value, (dict, list)): - result[key] = _reverse_transform_args(value) - else: - result[key] = _reverse_transform_value(value) - - return result - - -def extract_tool_calls_from_parts( - parts: List[Dict[str, Any]], is_streaming: bool = False -) -> Tuple[List[Dict[str, Any]], str]: - """ - 从 Gemini response parts 中提取工具调用和文本内容 - - Args: - parts: Gemini response 的 parts 数组 - is_streaming: 是否为流式响应(流式响应需要添加 index 字段) - - Returns: - (tool_calls, text_content) 元组 - """ - tool_calls = [] - text_content = "" - - for idx, part in enumerate(parts): - # 检查是否是函数调用 - if "functionCall" in part: - function_call = part["functionCall"] - # 获取原始ID或生成新ID - original_id = function_call.get("id") or f"call_{uuid.uuid4().hex[:24]}" - # 获取参数并转换类型 - args = function_call.get("args", {}) - # 将字符串类型的值转回原始类型 - args = _reverse_transform_args(args) - - tool_call = { - "id": original_id, - "type": "function", - "function": { - "name": function_call.get("name", "nameless_function"), - "arguments": json.dumps(args), - }, - } - # 流式响应需要 index 字段 - if is_streaming: - tool_call["index"] = idx - tool_calls.append(tool_call) - - # 提取文本内容(排除 thinking tokens) - elif "text" in part and not part.get("thought", False): - text = part["text"] - if ( - is_skip_thought_signature_placeholder(part) - or is_internal_placeholder_text(text) - ): - continue - text_content += text - - return tool_calls, text_content - - -def extract_images_from_content(content: Any) -> Dict[str, Any]: - """ - 从 OpenAI content 中提取文本和图片 - - Args: - content: OpenAI 消息的 content 字段(可能是字符串或列表) - - Returns: - 包含 text 和 images 的字典 - """ - result = {"text": "", "images": []} - - if isinstance(content, str): - result["text"] = content - elif isinstance(content, list): - for item in content: - if isinstance(item, dict): - if item.get("type") == "text": - result["text"] += item.get("text", "") - elif item.get("type") == "image_url": - image_url = item.get("image_url", {}).get("url", "") - # 解析 data:image/png;base64,xxx 格式 - if image_url.startswith("data:image/"): - import re - match = re.match(r"^data:image/(\w+);base64,(.+)$", image_url) - if match: - mime_type = match.group(1) - base64_data = match.group(2) - result["images"].append({ - "inlineData": { - "mimeType": f"image/{mime_type}", - "data": base64_data - } - }) - - return result - - -def _sanitize_openai_roundtrip_signatures(contents: List[Dict[str, Any]]) -> None: - """ - OpenAI-compatible clients may round-trip Gemini thinking signatures through - fields we do not fully control. Keep tool calls on the safe bypass sentinel - and drop signatures everywhere else to avoid Corrupted thought signature. - """ - for content in contents: - if not isinstance(content, dict): - continue - parts = content.get("parts") - if not isinstance(parts, list): - continue - - for index, part in enumerate(parts): - if not isinstance(part, dict): - continue - - sanitized_part = part.copy() - if "thoughtSignature" in sanitized_part: - if "functionCall" in sanitized_part or "function_call" in sanitized_part: - sanitized_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR - else: - sanitized_part.pop("thoughtSignature", None) - - if sanitized_part.get("thought") is True and not sanitized_part.get("thoughtSignature"): - sanitized_part.pop("thought", None) - - parts[index] = sanitized_part - - -async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Dict[str, Any]: - """ - 将 OpenAI 格式请求体转换为 Gemini 格式请求体 - - 注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理 - (如 thinking config, search tools, 参数范围限制等) - - Args: - openai_request: OpenAI 格式的请求体字典,包含: - - messages: 消息列表 - - temperature, top_p, max_tokens, stop 等生成参数 - - tools, tool_choice (可选) - - response_format (可选) - - Returns: - Gemini 格式的请求体字典,包含: - - contents: 转换后的消息内容 - - generationConfig: 生成配置 - - systemInstruction: 系统指令 (如果有) - - tools, toolConfig (如果有) - """ - # 处理连续的system消息(兼容性模式) - openai_request = await merge_system_messages(openai_request) - - contents = [] - - # 提取消息列表 - messages = openai_request.get("messages", []) - - # 构建 tool_call_id -> (name, original_id, signature) 的映射 - tool_call_mapping = {} - for msg in messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - encoded_id = tc.get("id", "") - func_name = tc.get("function", {}).get("name") or "" - if encoded_id: - # 解码获取原始ID和签名 - original_id, _ = decode_tool_id_and_signature(encoded_id) - tool_call_mapping[encoded_id] = (func_name, original_id, None) - - # 构建工具名称到参数 schema 的映射(用于类型修正) - tool_schemas = {} - if "tools" in openai_request and openai_request["tools"]: - for tool in openai_request["tools"]: - if tool.get("type") == "function": - function = tool.get("function", {}) - func_name = function.get("name") - if func_name: - tool_schemas[func_name] = function.get("parameters", {}) - - # 用于累积连续的 tool message 的 functionResponse parts - pending_tool_parts = [] - - def flush_pending_tool_parts(): - """将累积的 tool parts 作为单个 contents 条目追加""" - nonlocal pending_tool_parts - if pending_tool_parts: - contents.append({ - "role": "user", - "parts": pending_tool_parts - }) - pending_tool_parts = [] - - for message in messages: - role = message.get("role", "user") - content = message.get("content", "") - - # 处理工具消息(tool role)- 累积到 pending_tool_parts - if role == "tool": - tool_call_id = message.get("tool_call_id", "") - func_name = message.get("name") - - # 使用映射表查找 - if tool_call_id in tool_call_mapping: - func_name, original_id, _ = tool_call_mapping[tool_call_id] - else: - # 如果没有name,尝试从消息列表中查找 - if not func_name and tool_call_id: - for msg in messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - if tc.get("id") == tool_call_id: - func_name = tc.get("function", {}).get("name") - break - if func_name: - break - - # 解码 tool_call_id 获取原始 ID - original_id, _ = decode_tool_id_and_signature(tool_call_id) - - # 最终兜底:确保 func_name 不为空 - if not func_name: - func_name = "unknown_function" - log.warning(f"Tool message missing function name for tool_call_id={tool_call_id}, using default: {func_name}") - - # 解析响应数据 - try: - response_data = json.loads(content) if isinstance(content, str) else content - except (json.JSONDecodeError, TypeError): - response_data = {"result": str(content)} - - # 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象) - if not isinstance(response_data, dict): - response_data = {"result": response_data} - - # 累积 functionResponse part(不立即追加到 contents) - pending_tool_parts.append({ - "functionResponse": { - "id": original_id, - "name": func_name, - "response": response_data - } - }) - continue - - # 遇到非 tool 消息时,先 flush 累积的 tool parts - flush_pending_tool_parts() - - # system 消息已经由 merge_system_messages 处理,这里跳过 - if role == "system": - continue - - # 将OpenAI角色映射到Gemini角色 - if role == "assistant": - role = "model" - - # 检查是否有tool_calls - tool_calls = message.get("tool_calls") - if tool_calls: - parts = [] - - # 如果有文本内容,先添加文本 - if content: - parts.append({"text": content}) - - # 添加每个工具调用 - for tool_call in tool_calls: - try: - args = ( - json.loads(tool_call["function"]["arguments"]) - if isinstance(tool_call["function"]["arguments"], str) - else tool_call["function"]["arguments"] - ) - - # 根据工具的 schema 修正参数类型 - func_name = tool_call["function"]["name"] - if func_name in tool_schemas: - args = fix_tool_call_args_types(args, tool_schemas[func_name]) - - # 解码工具ID和thoughtSignature - encoded_id = tool_call.get("id", "") - original_id, signature = decode_tool_id_and_signature(encoded_id) - - # 构建functionCall part - function_call_part = { - "functionCall": { - "id": original_id, - "name": func_name, - "args": args - } - } - - # OpenAI/RooCode 中转可能会改写或截断 tool_call_id,真实签名回传后容易触发 - # Corrupted thought signature。工具调用使用官方跳过校验占位符更稳。 - function_call_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR - - parts.append(function_call_part) - except (json.JSONDecodeError, KeyError) as e: - log.error(f"Failed to parse tool call: {e}") - continue - - if parts: - contents.append({"role": role, "parts": parts}) - continue - - # 处理普通内容 - if isinstance(content, list): - parts = [] - for part in content: - if part.get("type") == "text": - parts.append({"text": part.get("text", "")}) - elif part.get("type") == "image_url": - image_url = part.get("image_url", {}).get("url") - if image_url: - try: - mime_type, base64_data = image_url.split(";") - _, mime_type = mime_type.split(":") - _, base64_data = base64_data.split(",") - parts.append({ - "inlineData": { - "mimeType": mime_type, - "data": base64_data, - } - }) - except ValueError: - continue - if parts: - contents.append({"role": role, "parts": parts}) - elif content: - contents.append({"role": role, "parts": [{"text": content}]}) - - # 循环结束后,flush 剩余的 tool parts(如果消息列表以 tool 消息结尾) - flush_pending_tool_parts() - _sanitize_openai_roundtrip_signatures(contents) - - # 构建生成配置 - generation_config = {} - model = openai_request.get("model", "") - - # 基础参数映射 - if "temperature" in openai_request: - generation_config["temperature"] = openai_request["temperature"] - if "top_p" in openai_request: - generation_config["topP"] = openai_request["top_p"] - if "top_k" in openai_request: - generation_config["topK"] = openai_request["top_k"] - if "max_tokens" in openai_request or "max_completion_tokens" in openai_request: - # max_completion_tokens 优先于 max_tokens - max_tokens = openai_request.get("max_completion_tokens") or openai_request.get("max_tokens") - generation_config["maxOutputTokens"] = max_tokens - if "stop" in openai_request: - stop = openai_request["stop"] - generation_config["stopSequences"] = [stop] if isinstance(stop, str) else stop - if "frequency_penalty" in openai_request: - generation_config["frequencyPenalty"] = openai_request["frequency_penalty"] - if "presence_penalty" in openai_request: - generation_config["presencePenalty"] = openai_request["presence_penalty"] - if "n" in openai_request: - generation_config["candidateCount"] = openai_request["n"] - if "seed" in openai_request: - generation_config["seed"] = openai_request["seed"] - - # 处理 response_format - if "response_format" in openai_request and openai_request["response_format"]: - response_format = openai_request["response_format"] - format_type = response_format.get("type") - - if format_type == "json_schema": - # JSON Schema 模式 - if "json_schema" in response_format and "schema" in response_format["json_schema"]: - schema = response_format["json_schema"]["schema"] - # 清理 schema - generation_config["responseSchema"] = _clean_schema_for_gemini(schema) - generation_config["responseMimeType"] = "application/json" - elif format_type == "json_object": - # JSON Object 模式 - generation_config["responseMimeType"] = "application/json" - elif format_type == "text": - # Text 模式 - generation_config["responseMimeType"] = "text/plain" - - # 如果contents为空,添加默认用户消息 - if not contents: - contents.append({"role": "user", "parts": [{"text": "请根据系统指令回答。"}]}) - - # 构建基础请求 - gemini_request = { - "contents": contents, - "generationConfig": generation_config - } - - # 如果 merge_system_messages 已经添加了 systemInstruction,使用它 - if "systemInstruction" in openai_request: - gemini_request["systemInstruction"] = openai_request["systemInstruction"] - - # 处理工具 - 传递 model 参数以便根据模型类型选择清理策略 - model = openai_request.get("model", "") - if "tools" in openai_request and openai_request["tools"]: - gemini_request["tools"] = convert_openai_tools_to_gemini(openai_request["tools"], model) - - # 处理tool_choice - if "tool_choice" in openai_request and openai_request["tool_choice"]: - gemini_request["toolConfig"] = convert_tool_choice_to_tool_config(openai_request["tool_choice"]) - - # 透传图片生成的 size 参数(如 "1024x1536") - if "size" in openai_request and openai_request["size"]: - gemini_request["size"] = openai_request["size"] - - return gemini_request - - -def convert_gemini_to_openai_response( - gemini_response: Union[Dict[str, Any], Any], - model: str, - status_code: int = 200 -) -> Dict[str, Any]: - """ - 将 Gemini 格式非流式响应转换为 OpenAI 格式非流式响应 - - 注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始响应 - - Args: - gemini_response: Gemini 格式的响应体 (字典或响应对象) - model: 模型名称 - status_code: HTTP 状态码 (默认 200) - - Returns: - OpenAI 格式的响应体字典,或原始响应 (如果状态码不是 2xx) - """ - # 非 2xx 状态码直接返回原始响应 - if not (200 <= status_code < 300): - if isinstance(gemini_response, dict): - return gemini_response - else: - # 如果是响应对象,尝试解析为字典 - try: - if hasattr(gemini_response, "json"): - return gemini_response.json() - elif hasattr(gemini_response, "body"): - body = gemini_response.body - if isinstance(body, bytes): - return json.loads(body.decode()) - return json.loads(str(body)) - else: - return {"error": str(gemini_response)} - except Exception: - return {"error": str(gemini_response)} - - # 确保是字典格式 - if not isinstance(gemini_response, dict): - try: - if hasattr(gemini_response, "json"): - gemini_response = gemini_response.json() - elif hasattr(gemini_response, "body"): - body = gemini_response.body - if isinstance(body, bytes): - gemini_response = json.loads(body.decode()) - else: - gemini_response = json.loads(str(body)) - else: - gemini_response = json.loads(str(gemini_response)) - except Exception: - return {"error": "Invalid response format"} - - # 处理 GeminiCLI 的 response 包装格式 - if "response" in gemini_response: - gemini_response = gemini_response["response"] - - # 转换为 OpenAI 格式 - choices = [] - - for candidate in gemini_response.get("candidates", []): - role = candidate.get("content", {}).get("role", "assistant") - - # 将Gemini角色映射回OpenAI角色 - if role == "model": - role = "assistant" - - # 提取并分离thinking tokens和常规内容 - parts = candidate.get("content", {}).get("parts", []) - - # 提取工具调用和文本内容 - tool_calls, text_content = extract_tool_calls_from_parts(parts) - - # 提取多种类型的内容 - content_parts = [] - reasoning_parts = [] - - for part in parts: - # 处理 executableCode(代码生成) - if "executableCode" in part: - exec_code = part["executableCode"] - lang = exec_code.get("language", "python").lower() - code = exec_code.get("code", "") - # 添加代码块(前后加换行符确保 Markdown 渲染正确) - content_parts.append(f"\n```{lang}\n{code}\n```\n") - - # 处理 codeExecutionResult(代码执行结果) - elif "codeExecutionResult" in part: - result = part["codeExecutionResult"] - outcome = result.get("outcome") - output = result.get("output", "") - - if output: - label = "output" if outcome == "OUTCOME_OK" else "error" - content_parts.append(f"\n```{label}\n{output}\n```\n") - - # 处理 thought(思考内容) - elif ( - part.get("thought", False) - and "text" in part - and not is_skip_thought_signature_placeholder(part) - ): - reasoning_parts.append(part["text"]) - - # 处理普通文本(非思考内容) - elif "text" in part and not part.get("thought", False): - # 这部分已经在 extract_tool_calls_from_parts 中处理 - pass - - # 处理 inlineData(图片) - elif "inlineData" in part: - inline_data = part["inlineData"] - mime_type = inline_data.get("mimeType", "image/png") - base64_data = inline_data.get("data", "") - # 使用 Markdown 格式 - content_parts.append(f"![gemini-generated-content](data:{mime_type};base64,{base64_data})") - - # 合并所有内容部分 - if content_parts: - # 使用双换行符连接各部分,确保块之间有间距 - additional_content = "\n\n".join(content_parts) - if text_content: - text_content = text_content + "\n\n" + additional_content - else: - text_content = additional_content - - # 合并 reasoning content - reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else "" - - # 构建消息对象 - message = {"role": role} - - # 获取 Gemini 的 finishReason - gemini_finish_reason = candidate.get("finishReason") - - # 如果有工具调用 - if tool_calls: - message["tool_calls"] = tool_calls - message["content"] = text_content if text_content else None - # 只有在正常停止(STOP)时才设为 tool_calls,其他情况保持原始 finish_reason - # 这样可以避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环 - if gemini_finish_reason == "STOP": - finish_reason = "tool_calls" - else: - finish_reason = _map_finish_reason(gemini_finish_reason) - else: - message["content"] = text_content - finish_reason = _map_finish_reason(gemini_finish_reason) - - # 添加 reasoning content (如果有) - if reasoning_content: - message["reasoning_content"] = reasoning_content - - choices.append({ - "index": candidate.get("index", 0), - "message": message, - "finish_reason": finish_reason, - }) - - # 转换 usageMetadata - usage = _convert_usage_metadata(gemini_response.get("usageMetadata")) - - response_data = { - "id": str(uuid.uuid4()), - "object": "chat.completion", - "created": int(time.time()), - "model": model, - "choices": choices, - } - - if usage: - response_data["usage"] = usage - - return response_data - - -def convert_gemini_to_openai_stream( - gemini_stream_chunk: str, - model: str, - response_id: str, - status_code: int = 200 -) -> Optional[str]: - """ - 将 Gemini 格式流式响应块转换为 OpenAI SSE 格式流式响应 - - 注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始内容 - - Args: - gemini_stream_chunk: Gemini 格式的流式响应块 (字符串,通常是 "data: {json}" 格式) - model: 模型名称 - response_id: 此流式响应的一致ID - status_code: HTTP 状态码 (默认 200) - - Returns: - OpenAI SSE 格式的响应字符串 (如 "data: {json}\n\n"), - 或原始内容 (如果状态码不是 2xx), - 或 None (如果解析失败) - """ - # 非 2xx 状态码直接返回原始内容 - if not (200 <= status_code < 300): - return gemini_stream_chunk - - # 解析 Gemini 流式块 - try: - # 去除 "data: " 前缀 - if isinstance(gemini_stream_chunk, bytes): - if gemini_stream_chunk.startswith(b"data: "): - payload_str = gemini_stream_chunk[len(b"data: "):].strip().decode("utf-8") - else: - payload_str = gemini_stream_chunk.strip().decode("utf-8") - else: - if gemini_stream_chunk.startswith("data: "): - payload_str = gemini_stream_chunk[len("data: "):].strip() - else: - payload_str = gemini_stream_chunk.strip() - - # 跳过空块 - if not payload_str: - return None - - # 解析 JSON - gemini_chunk = json.loads(payload_str) - except (json.JSONDecodeError, UnicodeDecodeError): - # 解析失败,跳过此块 - return None - - # 处理 GeminiCLI 的 response 包装格式 - if "response" in gemini_chunk: - gemini_response = gemini_chunk["response"] - else: - gemini_response = gemini_chunk - - # 转换为 OpenAI 流式格式 - choices = [] - - for candidate in gemini_response.get("candidates", []): - role = candidate.get("content", {}).get("role", "assistant") - - # 将Gemini角色映射回OpenAI角色 - if role == "model": - role = "assistant" - - # 提取并分离thinking tokens和常规内容 - parts = candidate.get("content", {}).get("parts", []) - - # 提取工具调用和文本内容 (流式需要 index) - tool_calls, text_content = extract_tool_calls_from_parts(parts, is_streaming=True) - - # 提取多种类型的内容 - content_parts = [] - reasoning_parts = [] - - for part in parts: - # 处理 executableCode(代码生成) - if "executableCode" in part: - exec_code = part["executableCode"] - lang = exec_code.get("language", "python").lower() - code = exec_code.get("code", "") - content_parts.append(f"\n```{lang}\n{code}\n```\n") - - # 处理 codeExecutionResult(代码执行结果) - elif "codeExecutionResult" in part: - result = part["codeExecutionResult"] - outcome = result.get("outcome") - output = result.get("output", "") - - if output: - label = "output" if outcome == "OUTCOME_OK" else "error" - content_parts.append(f"\n```{label}\n{output}\n```\n") - - # 处理 thought(思考内容) - elif ( - part.get("thought", False) - and "text" in part - and not is_skip_thought_signature_placeholder(part) - ): - reasoning_parts.append(part["text"]) - - # 处理普通文本(非思考内容) - elif "text" in part and not part.get("thought", False): - # 这部分已经在 extract_tool_calls_from_parts 中处理 - pass - - # 处理 inlineData(图片) - elif "inlineData" in part: - inline_data = part["inlineData"] - mime_type = inline_data.get("mimeType", "image/png") - base64_data = inline_data.get("data", "") - content_parts.append(f"![gemini-generated-content](data:{mime_type};base64,{base64_data})") - - # 合并所有内容部分 - if content_parts: - additional_content = "\n\n".join(content_parts) - if text_content: - text_content = text_content + "\n\n" + additional_content - else: - text_content = additional_content - - # 合并 reasoning content - reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else "" - - # 构建 delta 对象 - delta = {} - - if tool_calls: - delta["tool_calls"] = tool_calls - if text_content: - delta["content"] = text_content - elif text_content: - delta["content"] = text_content - - if reasoning_content: - delta["reasoning_content"] = reasoning_content - - # 获取 Gemini 的 finishReason - gemini_finish_reason = candidate.get("finishReason") - finish_reason = _map_finish_reason(gemini_finish_reason) - - # 只有在正常停止(STOP)且有工具调用时才设为 tool_calls - # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环 - if tool_calls and gemini_finish_reason == "STOP": - finish_reason = "tool_calls" - - choices.append({ - "index": candidate.get("index", 0), - "delta": delta, - "finish_reason": finish_reason, - }) - - # 转换 usageMetadata (只在流结束时存在) - usage = _convert_usage_metadata(gemini_response.get("usageMetadata")) - - # 构建 OpenAI 流式响应 - response_data = { - "id": response_id, - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": choices, - } - - # 只在有 usage 数据且有 finish_reason 时添加 usage - if usage: - has_finish_reason = any(choice.get("finish_reason") for choice in choices) - if has_finish_reason: - response_data["usage"] = usage - - # 转换为 SSE 格式: "data: {json}\n\n" - return f"data: {json.dumps(response_data)}\n\n" diff --git a/src/converter/thoughtSignature_fix.py b/src/converter/thoughtSignature_fix.py deleted file mode 100644 index cbeebdd2a2a8f5a0c845446ffc44554f4492f2c2..0000000000000000000000000000000000000000 --- a/src/converter/thoughtSignature_fix.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -thoughtSignature 处理公共模块 - -提供统一的 thoughtSignature 编码/解码功能,用于在工具调用ID中保留签名信息。 -这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段。 -""" - -from typing import Any, Mapping, Optional, Tuple - -# 在工具调用ID中嵌入thoughtSignature的分隔符 -# 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段 -THOUGHT_SIGNATURE_SEPARATOR = "__thought__" -SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" -SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT = "..." - - -def is_internal_placeholder_text(text: Any) -> bool: - if not isinstance(text, str): - return False - return text.strip() in (SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT, "…") - - -def is_skip_thought_signature_placeholder(part: Mapping[str, Any]) -> bool: - """Return True for the internal placeholder that should not reach clients.""" - if not isinstance(part, Mapping): - return False - if part.get("thoughtSignature") != SKIP_THOUGHT_SIGNATURE_VALIDATOR: - return False - if "functionCall" in part or "function_call" in part or "functionResponse" in part: - return False - return is_internal_placeholder_text(part.get("text")) - - -def encode_tool_id_with_signature(tool_id: str, signature: Optional[str]) -> str: - """ - 将 thoughtSignature 编码到工具调用ID中,以便往返保留。 - - Args: - tool_id: 原始工具调用ID - signature: thoughtSignature(可选) - - Returns: - 编码后的工具调用ID - - Examples: - >>> encode_tool_id_with_signature("call_123", "abc") - 'call_123__thought__abc' - >>> encode_tool_id_with_signature("call_123", None) - 'call_123' - """ - if not signature: - return tool_id - return f"{tool_id}{THOUGHT_SIGNATURE_SEPARATOR}{signature}" - - -def decode_tool_id_and_signature(encoded_id: str) -> Tuple[str, Optional[str]]: - """ - 从编码的ID中提取原始工具ID和thoughtSignature。 - - Args: - encoded_id: 编码的工具调用ID - - Returns: - (原始工具ID, thoughtSignature) 元组 - - Examples: - >>> decode_tool_id_and_signature("call_123__thought__abc") - ('call_123', 'abc') - >>> decode_tool_id_and_signature("call_123") - ('call_123', None) - """ - if not encoded_id or THOUGHT_SIGNATURE_SEPARATOR not in encoded_id: - return encoded_id, None - parts = encoded_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1) - return parts[0], parts[1] if len(parts) == 2 else None diff --git a/src/converter/utils.py b/src/converter/utils.py deleted file mode 100644 index 81d511f6429736ac6dbc29c4aab1eb5c476d61eb..0000000000000000000000000000000000000000 --- a/src/converter/utils.py +++ /dev/null @@ -1,247 +0,0 @@ -from typing import Any, Dict - -from src.converter.thoughtSignature_fix import ( - is_internal_placeholder_text, - is_skip_thought_signature_placeholder, -) - - -def extract_content_and_reasoning(parts: list) -> tuple: - """从Gemini响应部件中提取内容和推理内容 - - Args: - parts: Gemini 响应中的 parts 列表 - - Returns: - (content, reasoning_content, images): 文本内容、推理内容和图片数据的元组 - - content: 文本内容字符串 - - reasoning_content: 推理内容字符串 - - images: 图片数据列表,每个元素格式为: - { - "type": "image_url", - "image_url": { - "url": "data:{mime_type};base64,{base64_data}" - } - } - """ - content = "" - reasoning_content = "" - images = [] - - for part in parts: - if is_skip_thought_signature_placeholder(part): - continue - - # 提取文本内容 - text = part.get("text", "") - if is_internal_placeholder_text(text): - continue - if text: - if part.get("thought", False): - reasoning_content += text - else: - content += text - - # 提取图片数据 - if "inlineData" in part: - inline_data = part["inlineData"] - mime_type = inline_data.get("mimeType", "image/png") - base64_data = inline_data.get("data", "") - images.append({ - "type": "image_url", - "image_url": { - "url": f"data:{mime_type};base64,{base64_data}" - } - }) - - return content, reasoning_content, images - - -async def merge_system_messages(request_body: Dict[str, Any]) -> Dict[str, Any]: - """ - 根据兼容性模式处理请求体中的system消息 - - - 兼容性模式关闭(False):将连续的system消息合并为systemInstruction - - 兼容性模式开启(True):将所有system消息转换为user消息 - - Args: - request_body: OpenAI或Claude格式的请求体,包含messages字段 - - Returns: - 处理后的请求体 - - Example (兼容性模式关闭): - 输入: - { - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "system", "content": "You are an expert in Python."}, - {"role": "user", "content": "Hello"} - ] - } - - 输出: - { - "systemInstruction": { - "parts": [ - {"text": "You are a helpful assistant."}, - {"text": "You are an expert in Python."} - ] - }, - "messages": [ - {"role": "user", "content": "Hello"} - ] - } - - Example (兼容性模式开启): - 输入: - { - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello"} - ] - } - - 输出: - { - "messages": [ - {"role": "user", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello"} - ] - } - - Example (Anthropic格式,兼容性模式关闭): - 输入: - { - "system": "You are a helpful assistant.", - "messages": [ - {"role": "user", "content": "Hello"} - ] - } - - 输出: - { - "systemInstruction": { - "parts": [ - {"text": "You are a helpful assistant."} - ] - }, - "messages": [ - {"role": "user", "content": "Hello"} - ] - } - """ - from config import get_compatibility_mode_enabled - - compatibility_mode = await get_compatibility_mode_enabled() - - # 处理 Anthropic 格式的顶层 system 参数 - # Anthropic API 规范: system 是顶层参数,不在 messages 中 - system_content = request_body.get("system") - if system_content: - system_parts = [] - - if isinstance(system_content, str): - if system_content.strip(): - system_parts.append({"text": system_content}) - elif isinstance(system_content, list): - # system 可以是包含多个块的列表 - for item in system_content: - if isinstance(item, dict): - if item.get("type") == "text" and item.get("text", "").strip(): - system_parts.append({"text": item["text"]}) - elif isinstance(item, str) and item.strip(): - system_parts.append({"text": item}) - - if system_parts: - if compatibility_mode: - # 兼容性模式:将 system 转换为 user 消息插入到 messages 开头 - user_system_message = { - "role": "user", - "content": system_content if isinstance(system_content, str) else - "\n".join(part["text"] for part in system_parts) - } - messages = request_body.get("messages", []) - request_body = request_body.copy() - request_body["messages"] = [user_system_message] + messages - else: - # 非兼容性模式:添加为 systemInstruction - request_body = request_body.copy() - request_body["systemInstruction"] = {"parts": system_parts} - - messages = request_body.get("messages", []) - if not messages: - return request_body - - compatibility_mode = await get_compatibility_mode_enabled() - - if compatibility_mode: - # 兼容性模式开启:将所有system消息转换为user消息 - converted_messages = [] - for message in messages: - if message.get("role") == "system": - # 创建新的消息对象,将role改为user - converted_message = message.copy() - converted_message["role"] = "user" - converted_messages.append(converted_message) - else: - converted_messages.append(message) - - result = request_body.copy() - result["messages"] = converted_messages - return result - else: - # 兼容性模式关闭:提取连续的system消息合并为systemInstruction - system_parts = [] - - # 如果已经从顶层 system 参数创建了 systemInstruction,获取现有的 parts - if "systemInstruction" in request_body: - existing_instruction = request_body.get("systemInstruction", {}) - if isinstance(existing_instruction, dict): - system_parts = existing_instruction.get("parts", []).copy() - - remaining_messages = [] - collecting_system = True - - for message in messages: - role = message.get("role", "") - content = message.get("content", "") - - if role == "system" and collecting_system: - # 提取system消息的文本内容 - if isinstance(content, str): - if content.strip(): - system_parts.append({"text": content}) - elif isinstance(content, list): - # 处理列表格式的content - for item in content: - if isinstance(item, dict): - if item.get("type") == "text" and item.get("text", "").strip(): - system_parts.append({"text": item["text"]}) - elif isinstance(item, str) and item.strip(): - system_parts.append({"text": item}) - else: - # 遇到非system消息,停止收集 - collecting_system = False - if role == "system": - # 将后续的system消息转换为user消息 - converted_message = message.copy() - converted_message["role"] = "user" - remaining_messages.append(converted_message) - else: - remaining_messages.append(message) - - # 如果没有找到任何system消息(包括顶层参数和messages中的),返回原始请求体 - if not system_parts: - return request_body - - # 构建新的请求体 - result = request_body.copy() - - # 添加或更新systemInstruction - result["systemInstruction"] = {"parts": system_parts} - - # 更新messages列表(移除已处理的system消息) - result["messages"] = remaining_messages - - return result diff --git a/src/credential_manager.py b/src/credential_manager.py deleted file mode 100644 index b99feb7757a85325637bcd3ef886a58f7f6c607a..0000000000000000000000000000000000000000 --- a/src/credential_manager.py +++ /dev/null @@ -1,510 +0,0 @@ -""" -凭证管理器 -""" - -import asyncio -import time -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Tuple - -from log import log - -from src.google_oauth_api import Credentials -from src.storage_adapter import get_storage_adapter - -class CredentialManager: - """ - 统一凭证管理器 - 所有存储操作通过storage_adapter进行 - """ - - def __init__(self): - # 核心状态 - self._initialized = False - self._storage_adapter = None - - # 并发控制(简化) - # 后端数据库自行处理并发,credential_manager 不再使用本地锁 - - async def _ensure_initialized(self): - """确保管理器已初始化(内部使用)""" - if not self._initialized or self._storage_adapter is None: - await self.initialize() - - async def initialize(self): - """初始化凭证管理器""" - if self._initialized and self._storage_adapter is not None: - return - - # 初始化统一存储适配器 - self._storage_adapter = await get_storage_adapter() - self._initialized = True - - async def close(self): - """清理资源""" - log.debug("Closing credential manager...") - self._initialized = False - log.debug("Credential manager closed") - - async def get_valid_credential( - self, mode: str = "geminicli", model_name: Optional[str] = None - ) -> Optional[Tuple[str, Dict[str, Any]]]: - """ - 获取有效的凭证 - 随机负载均衡版 - 每次随机选择一个可用的凭证(未禁用、未冷却、符合preview要求) - 如果刷新失败会自动禁用失效凭证并重试获取下一个可用凭证 - - Args: - mode: 凭证模式 ("geminicli" 或 "antigravity") - model_name: 完整模型名,用于模型级冷却检查和preview筛选 - - geminicli: 完整模型名 - - 包含 "preview" 的模型只能使用 preview=True 的凭证 - - 不包含 "preview" 的模型优先使用 preview=False 的凭证 - - antigravity: 完整模型名(如 "gemini-2.0-flash-exp") - """ - await self._ensure_initialized() - - # 最多重试3次 - max_retries = 3 - for attempt in range(max_retries): - result = await self._storage_adapter._backend.get_next_available_credential( - mode=mode, model_name=model_name - ) - - # 如果没有可用凭证,直接返回None - if not result: - if attempt == 0: - log.warning(f"没有可用凭证 (mode={mode}, model_name={model_name})") - return None - - filename, credential_data = result - - # Token 刷新检查 - if await self._should_refresh_token(credential_data): - log.debug(f"Token需要刷新 - 文件: {filename} (mode={mode})") - refreshed_data = await self._refresh_token(credential_data, filename, mode=mode) - if refreshed_data: - # 刷新成功,返回凭证 - credential_data = refreshed_data - log.debug(f"Token刷新成功: {filename} (mode={mode})") - return filename, credential_data - else: - # 刷新失败(_refresh_token内部已自动禁用失效凭证) - log.warning(f"Token刷新失败,尝试获取下一个凭证: {filename} (mode={mode}, attempt={attempt+1}/{max_retries})") - # 继续循环,尝试获取下一个可用凭证 - continue - else: - # Token有效,直接返回 - return filename, credential_data - - # 重试次数用尽 - log.error(f"重试{max_retries}次后仍无可用凭证 (mode={mode}, model_name={model_name})") - return None - - async def add_credential(self, credential_name: str, credential_data: Dict[str, Any]): - """ - 新增或更新一个凭证 - 存储层会自动处理轮换顺序 - """ - await self._ensure_initialized() - await self._storage_adapter.store_credential(credential_name, credential_data) - log.info(f"Credential added/updated: {credential_name}") - - async def add_antigravity_credential(self, credential_name: str, credential_data: Dict[str, Any]): - """ - 新增或更新一个Antigravity凭证 - 存储层会自动处理轮换顺序 - """ - await self._ensure_initialized() - await self._storage_adapter.store_credential(credential_name, credential_data, mode="antigravity") - log.info(f"Antigravity credential added/updated: {credential_name}") - - async def remove_credential(self, credential_name: str, mode: str = "geminicli") -> bool: - """删除一个凭证""" - await self._ensure_initialized() - try: - await self._storage_adapter.delete_credential(credential_name, mode=mode) - log.info(f"Credential removed: {credential_name} (mode={mode})") - return True - except Exception as e: - log.error(f"Error removing credential {credential_name}: {e}") - return False - - async def update_credential_state(self, credential_name: str, state_updates: Dict[str, Any], mode: str = "geminicli"): - """更新凭证状态""" - log.debug(f"[CredMgr] update_credential_state 开始: credential_name={credential_name}, state_updates={state_updates}, mode={mode}") - log.debug(f"[CredMgr] 调用 _ensure_initialized...") - await self._ensure_initialized() - log.debug(f"[CredMgr] _ensure_initialized 完成") - try: - log.debug(f"[CredMgr] 调用 storage_adapter.update_credential_state...") - success = await self._storage_adapter.update_credential_state( - credential_name, state_updates, mode=mode - ) - log.debug(f"[CredMgr] storage_adapter.update_credential_state 返回: {success}") - if success: - log.debug(f"Updated credential state: {credential_name} (mode={mode})") - else: - log.warning(f"Failed to update credential state: {credential_name} (mode={mode})") - return success - except Exception as e: - log.error(f"Error updating credential state {credential_name}: {e}") - return False - - async def set_cred_disabled(self, credential_name: str, disabled: bool, mode: str = "geminicli"): - """设置凭证的启用/禁用状态""" - try: - log.info(f"[CredMgr] set_cred_disabled 开始: credential_name={credential_name}, disabled={disabled}, mode={mode}") - success = await self.update_credential_state( - credential_name, {"disabled": disabled}, mode=mode - ) - log.info(f"[CredMgr] update_credential_state 返回: success={success}") - if success: - action = "disabled" if disabled else "enabled" - log.info(f"Credential {action}: {credential_name} (mode={mode})") - else: - log.warning(f"[CredMgr] 设置禁用状态失败: credential_name={credential_name}, disabled={disabled}") - return success - except Exception as e: - log.error(f"Error setting credential disabled state {credential_name}: {e}") - return False - - async def get_creds_status(self) -> Dict[str, Dict[str, Any]]: - """获取所有凭证的状态""" - await self._ensure_initialized() - try: - return await self._storage_adapter.get_all_credential_states() - except Exception as e: - log.error(f"Error getting credential statuses: {e}") - return {} - - async def get_creds_summary(self) -> List[Dict[str, Any]]: - """ - 获取所有凭证的摘要信息(轻量级,不包含完整凭证数据) - 使用后端的高性能查询 - """ - await self._ensure_initialized() - try: - return await self._storage_adapter._backend.get_credentials_summary() - except Exception as e: - log.error(f"Error getting credentials summary: {e}") - return [] - - async def get_or_fetch_user_email(self, credential_name: str, mode: str = "geminicli") -> Optional[str]: - """获取或获取用户邮箱地址""" - try: - # 确保已初始化 - await self._ensure_initialized() - - # 从状态中获取缓存的邮箱 - state = await self._storage_adapter.get_credential_state(credential_name, mode=mode) - cached_email = state.get("user_email") if state else None - - if cached_email: - return cached_email - - # 如果没有缓存,从凭证数据获取 - credential_data = await self._storage_adapter.get_credential(credential_name, mode=mode) - if not credential_data: - return None - - # 创建凭证对象并自动刷新 token - from .google_oauth_api import Credentials, get_user_email - - credentials = Credentials.from_dict(credential_data) - if not credentials: - return None - - # 自动刷新 token(如果需要) - token_refreshed = await credentials.refresh_if_needed() - - # 如果 token 被刷新了,更新存储 - if token_refreshed: - log.info(f"Token已自动刷新: {credential_name} (mode={mode})") - updated_data = credentials.to_dict() - await self._storage_adapter.store_credential(credential_name, updated_data, mode=mode) - - # 获取邮箱 - email = await get_user_email(credentials) - - if email: - # 缓存邮箱地址 - await self._storage_adapter.update_credential_state( - credential_name, {"user_email": email}, mode=mode - ) - return email - - return None - - except Exception as e: - log.error(f"Error fetching user email for {credential_name}: {e}") - return None - - async def record_api_call_result( - self, - credential_name: str, - success: bool, - error_code: Optional[int] = None, - cooldown_until: Optional[float] = None, - mode: str = "geminicli", - model_name: Optional[str] = None, - error_message: Optional[str] = None - ): - """ - 记录API调用结果 - - Args: - credential_name: 凭证名称 - success: 是否成功 - error_code: 错误码(如果失败) - cooldown_until: 冷却截止时间戳(Unix时间戳,针对429 QUOTA_EXHAUSTED) - mode: 凭证模式 ("geminicli" 或 "antigravity") - model_name: 模型名(用于设置模型级冷却) - error_message: 错误信息(如果失败) - """ - await self._ensure_initialized() - try: - if success: - # 条件写入:仅当凭证有错误状态或模型冷却时才写 DB,零内存缓存 - # fire-and-forget,不阻塞请求链路 - asyncio.create_task( - self._storage_adapter._backend.record_success( - credential_name, model_name=model_name, mode=mode - ) - ) - - elif error_code: - # 记录错误码和错误信息 - error_messages = {} - if error_message: - error_messages[str(error_code)] = error_message - - state_updates = { - "error_codes": [error_code], - "error_messages": error_messages, - } - - await self.update_credential_state(credential_name, state_updates, mode=mode) - - # 设置模型级冷却 - if cooldown_until is not None and model_name: - if hasattr(self._storage_adapter._backend, 'set_model_cooldown'): - await self._storage_adapter._backend.set_model_cooldown( - credential_name, model_name, cooldown_until, mode=mode - ) - log.info( - f"设置模型级冷却: {credential_name}, model_name={model_name}, " - f"冷却至: {datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}" - ) - - except Exception as e: - log.error(f"Error recording API call result for {credential_name}: {e}") - - async def _should_refresh_token(self, credential_data: Dict[str, Any]) -> bool: - """检查token是否需要刷新""" - try: - # 如果没有access_token或过期时间,需要刷新 - if not credential_data.get("access_token") and not credential_data.get("token"): - log.debug("没有access_token,需要刷新") - return True - - expiry_str = credential_data.get("expiry") - if not expiry_str: - log.debug("没有过期时间,需要刷新") - return True - - # 解析过期时间 - try: - if isinstance(expiry_str, str): - if "+" in expiry_str: - file_expiry = datetime.fromisoformat(expiry_str) - elif expiry_str.endswith("Z"): - file_expiry = datetime.fromisoformat(expiry_str.replace("Z", "+00:00")) - else: - file_expiry = datetime.fromisoformat(expiry_str) - else: - log.debug("过期时间格式无效,需要刷新") - return True - - # 确保时区信息 - if file_expiry.tzinfo is None: - file_expiry = file_expiry.replace(tzinfo=timezone.utc) - - # 检查是否还有至少5分钟有效期 - now = datetime.now(timezone.utc) - time_left = (file_expiry - now).total_seconds() - - log.debug( - f"Token时间检查: " - f"当前UTC时间={now.isoformat()}, " - f"过期时间={file_expiry.isoformat()}, " - f"剩余时间={int(time_left/60)}分{int(time_left%60)}秒" - ) - - if time_left > 300: # 5分钟缓冲 - return False - else: - log.debug(f"Token即将过期(剩余{int(time_left/60)}分钟),需要刷新") - return True - - except Exception as e: - log.warning(f"解析过期时间失败: {e},需要刷新") - return True - - except Exception as e: - log.error(f"检查token过期时出错: {e}") - return True - - async def _refresh_token( - self, credential_data: Dict[str, Any], filename: str, mode: str = "geminicli" - ) -> Optional[Dict[str, Any]]: - """刷新token并更新存储""" - await self._ensure_initialized() - try: - # 创建Credentials对象 - creds = Credentials.from_dict(credential_data) - - # 检查是否可以刷新 - if not creds.refresh_token: - log.error(f"没有refresh_token,无法刷新: {filename} (mode={mode})") - # 自动禁用没有refresh_token的凭证 - try: - await self.update_credential_state(filename, {"disabled": True}, mode=mode) - log.warning(f"凭证已自动禁用(缺少refresh_token): {filename}") - except Exception as e: - log.error(f"禁用凭证失败 {filename}: {e}") - return None - - # 刷新token - log.debug(f"正在刷新token: {filename} (mode={mode})") - await creds.refresh() - - # 更新凭证数据 - if creds.access_token: - credential_data["access_token"] = creds.access_token - # 保持兼容性 - credential_data["token"] = creds.access_token - - if creds.expires_at: - credential_data["expiry"] = creds.expires_at.isoformat() - - # 保存到存储 - await self._storage_adapter.store_credential(filename, credential_data, mode=mode) - log.info(f"Token刷新成功并已保存: {filename} (mode={mode})") - - return credential_data - - except Exception as e: - error_msg = str(e) - log.error(f"Token刷新失败 {filename} (mode={mode}): {error_msg}") - - # 尝试提取HTTP状态码(TokenError可能携带status_code属性) - status_code = None - if hasattr(e, 'status_code'): - status_code = e.status_code - - # 检查是否是凭证永久失效的错误(只有明确的400/403等才判定为永久失效) - is_permanent_failure = self._is_permanent_refresh_failure(error_msg, status_code) - - if is_permanent_failure: - log.warning(f"检测到凭证永久失效 (HTTP {status_code}): {filename}") - # 记录失效状态 - if status_code: - await self.record_api_call_result(filename, False, status_code, mode=mode) - else: - await self.record_api_call_result(filename, False, 400, mode=mode) - - # 禁用失效凭证 - try: - # 直接禁用该凭证(随机选择机制会自动跳过它) - disabled_ok = await self.update_credential_state(filename, {"disabled": True}, mode=mode) - if disabled_ok: - log.warning(f"永久失效凭证已禁用: {filename}") - else: - log.warning("永久失效凭证禁用失败,将由上层逻辑继续处理") - except Exception as e2: - log.error(f"禁用永久失效凭证时出错 {filename}: {e2}") - else: - # 网络错误或其他临时性错误,不封禁凭证 - log.warning(f"Token刷新失败但非永久性错误 (HTTP {status_code}),不封禁凭证: {filename}") - - return None - - def _is_permanent_refresh_failure(self, error_msg: str, status_code: Optional[int] = None) -> bool: - """ - 判断是否是凭证永久失效的错误 - - Args: - error_msg: 错误信息 - status_code: HTTP状态码(如果有) - - Returns: - True表示凭证永久失效应封禁,False表示临时错误不应封禁 - """ - # 优先使用HTTP状态码判断 - if status_code is not None: - # 400/401/403 明确表示凭证有问题,应该封禁 - if status_code in [400, 401, 403]: - log.debug(f"检测到客户端错误状态码 {status_code},判定为永久失效") - return True - # 500/502/503/504 是服务器错误,不应封禁凭证 - elif status_code in [500, 502, 503, 504]: - log.debug(f"检测到服务器错误状态码 {status_code},不应封禁凭证") - return False - # 429 (限流) 不应封禁凭证 - elif status_code == 429: - log.debug("检测到限流错误 429,不应封禁凭证") - return False - - # 如果没有状态码,回退到错误信息匹配(谨慎判断) - # 只有明确的凭证失效错误才判定为永久失效 - permanent_error_patterns = [ - "invalid_grant", - "refresh_token_expired", - "invalid_refresh_token", - "unauthorized_client", - "access_denied", - ] - - error_msg_lower = error_msg.lower() - for pattern in permanent_error_patterns: - if pattern.lower() in error_msg_lower: - log.debug(f"错误信息匹配到永久失效模式: {pattern}") - return True - - # 默认认为是临时错误(如网络问题),不应封禁凭证 - log.debug("未匹配到明确的永久失效模式,判定为临时错误") - return False - -class _CredentialManagerSingleton: - """单例包装器,支持懒加载和自动初始化""" - - _instance: Optional[CredentialManager] = None - _lock = None - - def __init__(self): - self._manager = None - - async def _get_or_create(self) -> CredentialManager: - """获取或创建单例实例(线程安全)""" - if self._instance is None: - # 简单的实例创建(异步环境下一般不需要复杂的锁) - if self._instance is None: - self._instance = CredentialManager() - await self._instance.initialize() - log.debug("CredentialManager singleton initialized") - - return self._instance - - def __getattr__(self, name): - """代理所有方法调用到真实的 CredentialManager 实例""" - async def _async_wrapper(*args, **kwargs): - manager = await self._get_or_create() - method = getattr(manager, name) - return await method(*args, **kwargs) - - return _async_wrapper - - -# 全局单例实例 - 直接导入即可使用 -credential_manager = _CredentialManagerSingleton() diff --git a/src/google_oauth_api.py b/src/google_oauth_api.py deleted file mode 100644 index f2681ddfe36bcbd7eab7f8353fd7921683bbb89d..0000000000000000000000000000000000000000 --- a/src/google_oauth_api.py +++ /dev/null @@ -1,852 +0,0 @@ -""" -Google OAuth2 认证模块 -""" - -import time -import asyncio -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urlencode - -import jwt - -from config import ( - get_googleapis_proxy_url, - get_oauth_proxy_url, - get_resource_manager_api_url, - get_service_usage_api_url, -) -from log import log - -from src.httpx_client import get_async, post_async - - -class TokenError(Exception): - """Token相关错误""" - - pass - - -class Credentials: - """凭证类""" - - def __init__( - self, - access_token: str, - refresh_token: str = None, - client_id: str = None, - client_secret: str = None, - expires_at: datetime = None, - project_id: str = None, - ): - self.access_token = access_token - self.refresh_token = refresh_token - self.client_id = client_id - self.client_secret = client_secret - self.expires_at = expires_at - self.project_id = project_id - - # 反代配置将在使用时异步获取 - self.oauth_base_url = None - self.token_endpoint = None - - def is_expired(self) -> bool: - """检查token是否过期""" - if not self.expires_at: - return True - - # 提前3分钟认为过期 - buffer = timedelta(minutes=3) - return (self.expires_at - buffer) <= datetime.now(timezone.utc) - - async def refresh_if_needed(self) -> bool: - """如果需要则刷新token""" - if not self.is_expired(): - return False - - if not self.refresh_token: - raise TokenError("需要刷新令牌但未提供") - - await self.refresh() - return True - - async def refresh(self): - """刷新访问令牌""" - if not self.refresh_token: - raise TokenError("无刷新令牌") - - data = { - "client_id": self.client_id, - "client_secret": self.client_secret, - "refresh_token": self.refresh_token, - "grant_type": "refresh_token", - } - - try: - oauth_base_url = await get_oauth_proxy_url() - token_url = f"{oauth_base_url.rstrip('/')}/token" - response = await post_async( - token_url, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - response.raise_for_status() - - token_data = response.json() - self.access_token = token_data["access_token"] - - if "expires_in" in token_data: - expires_in = int(token_data["expires_in"]) - current_utc = datetime.now(timezone.utc) - self.expires_at = current_utc + timedelta(seconds=expires_in) - log.debug( - f"Token刷新: 当前UTC时间={current_utc.isoformat()}, " - f"有效期={expires_in}秒, " - f"过期时间={self.expires_at.isoformat()}" - ) - - if "refresh_token" in token_data: - self.refresh_token = token_data["refresh_token"] - - log.debug(f"Token刷新成功,过期时间: {self.expires_at}") - - except Exception as e: - error_msg = str(e) - status_code = None - if hasattr(e, 'response') and hasattr(e.response, 'status_code'): - status_code = e.response.status_code - error_msg = f"Token刷新失败 (HTTP {status_code}): {error_msg}" - else: - error_msg = f"Token刷新失败: {error_msg}" - - log.error(error_msg) - token_error = TokenError(error_msg) - token_error.status_code = status_code - raise token_error - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Credentials": - """从字典创建凭证""" - # 处理过期时间 - expires_at = None - if "expiry" in data and data["expiry"]: - try: - expiry_str = data["expiry"] - if isinstance(expiry_str, str): - if expiry_str.endswith("Z"): - expires_at = datetime.fromisoformat(expiry_str.replace("Z", "+00:00")) - elif "+" in expiry_str: - expires_at = datetime.fromisoformat(expiry_str) - else: - expires_at = datetime.fromisoformat(expiry_str).replace(tzinfo=timezone.utc) - except ValueError: - log.warning(f"无法解析过期时间: {expiry_str}") - - return cls( - access_token=data.get("token") or data.get("access_token", ""), - refresh_token=data.get("refresh_token"), - client_id=data.get("client_id"), - client_secret=data.get("client_secret"), - expires_at=expires_at, - project_id=data.get("project_id"), - ) - - def to_dict(self) -> Dict[str, Any]: - """转为字典""" - result = { - "access_token": self.access_token, - "refresh_token": self.refresh_token, - "client_id": self.client_id, - "client_secret": self.client_secret, - "project_id": self.project_id, - } - - if self.expires_at: - result["expiry"] = self.expires_at.isoformat() - - return result - - -class Flow: - """OAuth流程类""" - - def __init__( - self, client_id: str, client_secret: str, scopes: List[str], redirect_uri: str = None - ): - self.client_id = client_id - self.client_secret = client_secret - self.scopes = scopes - self.redirect_uri = redirect_uri - - # 反代配置将在使用时异步获取 - self.oauth_base_url = None - self.token_endpoint = None - self.auth_endpoint = "https://accounts.google.com/o/oauth2/auth" - - self.credentials: Optional[Credentials] = None - - def get_auth_url(self, state: str = None, **kwargs) -> str: - """生成授权URL""" - params = { - "client_id": self.client_id, - "redirect_uri": self.redirect_uri, - "scope": " ".join(self.scopes), - "response_type": "code", - "access_type": "offline", - "prompt": "consent", - "include_granted_scopes": "true", - } - - if state: - params["state"] = state - - params.update(kwargs) - return f"{self.auth_endpoint}?{urlencode(params)}" - - async def exchange_code(self, code: str) -> Credentials: - """用授权码换取token""" - data = { - "client_id": self.client_id, - "client_secret": self.client_secret, - "redirect_uri": self.redirect_uri, - "code": code, - "grant_type": "authorization_code", - } - - try: - oauth_base_url = await get_oauth_proxy_url() - token_url = f"{oauth_base_url.rstrip('/')}/token" - response = await post_async( - token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} - ) - response.raise_for_status() - - token_data = response.json() - - # 计算过期时间 - expires_at = None - if "expires_in" in token_data: - expires_in = int(token_data["expires_in"]) - expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in) - - # 创建凭证对象 - self.credentials = Credentials( - access_token=token_data["access_token"], - refresh_token=token_data.get("refresh_token"), - client_id=self.client_id, - client_secret=self.client_secret, - expires_at=expires_at, - ) - - return self.credentials - - except Exception as e: - error_msg = f"获取token失败: {str(e)}" - log.error(error_msg) - raise TokenError(error_msg) - - -class ServiceAccount: - """Service Account类""" - - def __init__( - self, email: str, private_key: str, project_id: str = None, scopes: List[str] = None - ): - self.email = email - self.private_key = private_key - self.project_id = project_id - self.scopes = scopes or [] - - # 反代配置将在使用时异步获取 - self.oauth_base_url = None - self.token_endpoint = None - - self.access_token: Optional[str] = None - self.expires_at: Optional[datetime] = None - - def is_expired(self) -> bool: - """检查token是否过期""" - if not self.expires_at: - return True - - buffer = timedelta(minutes=3) - return (self.expires_at - buffer) <= datetime.now(timezone.utc) - - def create_jwt(self) -> str: - """创建JWT令牌""" - now = int(time.time()) - - payload = { - "iss": self.email, - "scope": " ".join(self.scopes) if self.scopes else "", - "aud": self.token_endpoint, - "exp": now + 3600, - "iat": now, - } - - return jwt.encode(payload, self.private_key, algorithm="RS256") - - async def get_access_token(self) -> str: - """获取访问令牌""" - if not self.is_expired() and self.access_token: - return self.access_token - - assertion = self.create_jwt() - - data = {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion} - - try: - oauth_base_url = await get_oauth_proxy_url() - token_url = f"{oauth_base_url.rstrip('/')}/token" - response = await post_async( - token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} - ) - response.raise_for_status() - - token_data = response.json() - self.access_token = token_data["access_token"] - - if "expires_in" in token_data: - expires_in = int(token_data["expires_in"]) - self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in) - - return self.access_token - - except Exception as e: - error_msg = f"Service Account获取token失败: {str(e)}" - log.error(error_msg) - raise TokenError(error_msg) - - @classmethod - def from_dict(cls, data: Dict[str, Any], scopes: List[str] = None) -> "ServiceAccount": - """从字典创建Service Account凭证""" - return cls( - email=data["client_email"], - private_key=data["private_key"], - project_id=data.get("project_id"), - scopes=scopes, - ) - - -# 工具函数 -async def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]: - """获取用户信息""" - await credentials.refresh_if_needed() - - try: - googleapis_base_url = await get_googleapis_proxy_url() - userinfo_url = f"{googleapis_base_url.rstrip('/')}/oauth2/v2/userinfo" - response = await get_async( - userinfo_url, headers={"Authorization": f"Bearer {credentials.access_token}"} - ) - response.raise_for_status() - return response.json() - except Exception as e: - log.error(f"获取用户信息失败: {e}") - return None - - -async def get_user_email(credentials: Credentials) -> Optional[str]: - """获取用户邮箱地址""" - try: - # 确保凭证有效 - await credentials.refresh_if_needed() - - # 调用Google userinfo API获取邮箱 - user_info = await get_user_info(credentials) - if user_info: - email = user_info.get("email") - if email: - log.info(f"成功获取邮箱地址: {email}") - return email - else: - log.warning(f"userinfo响应中没有邮箱信息: {user_info}") - return None - else: - log.warning("获取用户信息失败") - return None - - except Exception as e: - log.error(f"获取用户邮箱失败: {e}") - return None - - -async def fetch_user_email_from_file(cred_data: Dict[str, Any]) -> Optional[str]: - """从凭证数据获取用户邮箱地址(支持统一存储)""" - try: - # 直接从凭证数据创建凭证对象 - credentials = Credentials.from_dict(cred_data) - if not credentials or not credentials.access_token: - log.warning("无法从凭证数据创建凭证对象或获取访问令牌") - return None - - # 获取邮箱 - return await get_user_email(credentials) - - except Exception as e: - log.error(f"从凭证数据获取用户邮箱失败: {e}") - return None - - -async def validate_token(token: str) -> Optional[Dict[str, Any]]: - """验证访问令牌""" - try: - oauth_base_url = await get_oauth_proxy_url() - tokeninfo_url = f"{oauth_base_url.rstrip('/')}/tokeninfo?access_token={token}" - - response = await get_async(tokeninfo_url) - response.raise_for_status() - return response.json() - except Exception as e: - log.error(f"验证令牌失败: {e}") - return None - - -async def enable_required_apis(credentials: Credentials, project_id: str) -> bool: - """自动启用必需的API服务""" - try: - # 确保凭证有效 - if credentials.is_expired() and credentials.refresh_token: - await credentials.refresh() - - headers = { - "Authorization": f"Bearer {credentials.access_token}", - "Content-Type": "application/json", - "User-Agent": "geminicli-oauth/1.0", - } - - # 需要启用的服务列表 - required_services = [ - "geminicloudassist.googleapis.com", # Gemini Cloud Assist API - "cloudaicompanion.googleapis.com", # Gemini for Google Cloud API - ] - - for service in required_services: - log.info(f"正在检查并启用服务: {service}") - - # 检查服务是否已启用 - service_usage_base_url = await get_service_usage_api_url() - check_url = ( - f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}" - ) - try: - check_response = await get_async(check_url, headers=headers) - if check_response.status_code == 200: - service_data = check_response.json() - if service_data.get("state") == "ENABLED": - log.info(f"服务 {service} 已启用") - continue - except Exception as e: - log.debug(f"检查服务状态失败,将尝试启用: {e}") - - # 启用服务 - enable_url = f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}:enable" - try: - enable_response = await post_async(enable_url, headers=headers, json={}) - - if enable_response.status_code in [200, 201]: - log.info(f"✅ 成功启用服务: {service}") - elif enable_response.status_code == 400: - error_data = enable_response.json() - if "already enabled" in error_data.get("error", {}).get("message", "").lower(): - log.info(f"✅ 服务 {service} 已经启用") - else: - log.warning(f"⚠️ 启用服务 {service} 时出现警告: {error_data}") - else: - log.warning( - f"⚠️ 启用服务 {service} 失败: {enable_response.status_code} - {enable_response.text}" - ) - - except Exception as e: - log.warning(f"⚠️ 启用服务 {service} 时发生异常: {e}") - - return True - - except Exception as e: - log.error(f"启用API服务时发生错误: {e}") - return False - - -async def get_user_projects(credentials: Credentials) -> List[Dict[str, Any]]: - """获取用户可访问的Google Cloud项目列表""" - try: - # 确保凭证有效 - if credentials.is_expired() and credentials.refresh_token: - await credentials.refresh() - - headers = { - "Authorization": f"Bearer {credentials.access_token}", - "User-Agent": "geminicli-oauth/1.0", - } - - # 使用Resource Manager API的正确域名和端点 - resource_manager_base_url = await get_resource_manager_api_url() - url = f"{resource_manager_base_url.rstrip('/')}/v1/projects" - log.info(f"正在调用API: {url}") - response = await get_async(url, headers=headers) - - log.info(f"API响应状态码: {response.status_code}") - if response.status_code != 200: - log.error(f"API响应内容: {response.text}") - - if response.status_code == 200: - data = response.json() - projects = data.get("projects", []) - # 只返回活跃的项目 - active_projects = [ - project for project in projects if project.get("lifecycleState") == "ACTIVE" - ] - log.info(f"获取到 {len(active_projects)} 个活跃项目") - return active_projects - else: - log.warning(f"获取项目列表失败: {response.status_code} - {response.text}") - return [] - - except Exception as e: - log.error(f"获取用户项目列表失败: {e}") - return [] - - -async def select_default_project(projects: List[Dict[str, Any]]) -> Optional[str]: - """从项目列表中选择默认项目""" - if not projects: - return None - - # 策略1:查找显示名称或项目ID包含"default"的项目 - for project in projects: - display_name = project.get("displayName", "").lower() - # Google API returns projectId in camelCase - project_id = project.get("projectId", "") - if "default" in display_name or "default" in project_id.lower(): - log.info(f"选择默认项目: {project_id} ({project.get('displayName', project_id)})") - return project_id - - # 策略2:选择第一个项目 - first_project = projects[0] - # Google API returns projectId in camelCase - project_id = first_project.get("projectId", "") - log.info( - f"选择第一个项目作为默认: {project_id} ({first_project.get('displayName', project_id)})" - ) - return project_id - - -async def fetch_project_id_and_tier( - access_token: str, - user_agent: str, - api_base_url: str, - include_credits: bool = False, -) -> Tuple[Optional[str], Optional[str]] | Tuple[Optional[str], Optional[str], Optional[int]]: - """ - 从 API 获取 project_id 和订阅等级 - - Args: - access_token: Google OAuth access token - user_agent: User-Agent header - api_base_url: API base URL (e.g., antigravity or code assist endpoint) - - Returns: - 默认返回 (project_id, subscription_tier) - 当 include_credits=True 时返回 (project_id, subscription_tier, credit_amount) - subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None - credit_amount 为积分数量(整数)或 None - """ - headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json', - 'Accept-Encoding': 'gzip' - } - - def _map_raw_tier(raw_tier: Optional[str]) -> Optional[str]: - """将 loadCodeAssist 返回的 raw tier 映射为统一 tier。""" - if not raw_tier: - return None - - tier_mapping = { - "g1-ultra-tier": "ultra", - "ws-ai-ultra-business-tier": "ultra", - "g1-pro-tier": "pro", - "helium-tier": "pro", - "standard-tier": "pro", - "free-tier": "free", - } - - return tier_mapping.get(raw_tier.lower(), "pro") - - subscription_tier = None - credit_amount: Optional[int] = None - - # 步骤 1: 尝试 loadCodeAssist - try: - project_id, raw_tier, raw_credit_amount = await _try_load_code_assist(api_base_url, headers) - subscription_tier = _map_raw_tier(raw_tier) - - if raw_credit_amount is not None: - try: - credit_amount = int(raw_credit_amount) - log.info( - f"[fetch_project_id_and_tier] Found credit_amount: {credit_amount}" - ) - except (TypeError, ValueError): - log.warning( - f"[fetch_project_id_and_tier] Invalid credit_amount: {raw_credit_amount}" - ) - - if raw_tier: - log.info( - f"[fetch_project_id_and_tier] Raw tier '{raw_tier}' mapped to '{subscription_tier}'" - ) - - if project_id: - if include_credits: - return project_id, subscription_tier, credit_amount - return project_id, subscription_tier - - log.warning("[fetch_project_id_and_tier] loadCodeAssist did not return project_id, falling back to onboardUser") - - except Exception as e: - log.warning(f"[fetch_project_id_and_tier] loadCodeAssist failed: {type(e).__name__}: {e}") - log.warning("[fetch_project_id_and_tier] Falling back to onboardUser") - - # 步骤 2: 回退到 onboardUser - try: - project_id = await _try_onboard_user(api_base_url, headers) - if project_id: - if include_credits: - return project_id, subscription_tier, credit_amount - return project_id, subscription_tier - - log.error("[fetch_project_id_and_tier] Failed to get project_id from both loadCodeAssist and onboardUser") - if include_credits: - return None, subscription_tier, credit_amount - return None, subscription_tier - - except Exception as e: - log.error(f"[fetch_project_id_and_tier] onboardUser failed: {type(e).__name__}: {e}") - import traceback - log.debug(f"[fetch_project_id_and_tier] Traceback: {traceback.format_exc()}") - if include_credits: - return None, subscription_tier, credit_amount - return None, subscription_tier - - -async def _try_load_code_assist( - api_base_url: str, - headers: dict -) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """ - 尝试通过 loadCodeAssist 获取 project_id 和订阅等级 - - Returns: - (project_id, subscription_tier, credit_amount) 元组 - subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None - credit_amount 为字符串格式积分或 None - """ - request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist" - request_body = { - "metadata": { - "ideType": "ANTIGRAVITY" - } - } - - log.debug(f"[loadCodeAssist] Fetching project_id from: {request_url}") - log.debug(f"[loadCodeAssist] Request body: {request_body}") - - response = await post_async( - request_url, - json=request_body, - headers=headers, - timeout=30.0, - ) - - log.debug(f"[loadCodeAssist] Response status: {response.status_code}") - - if response.status_code == 200: - response_text = response.text - log.debug(f"[loadCodeAssist] Response body: {response_text}") - - data = response.json() - log.debug(f"[loadCodeAssist] Response JSON keys: {list(data.keys())}") - - # 提取订阅等级 - 优先使用 paidTier(更准确反映实际权益) - paid_tier = data.get("paidTier", {}) - current_tier = data.get("currentTier", {}) - available_credits = paid_tier.get("availableCredits", []) if isinstance(paid_tier, dict) else [] - - # paidTier.id 优先,然后是 currentTier.id - subscription_tier = None - if isinstance(paid_tier, dict) and paid_tier.get("id"): - subscription_tier = paid_tier.get("id") - log.info(f"[loadCodeAssist] Found paidTier: {subscription_tier}") - elif isinstance(current_tier, dict) and current_tier.get("id"): - subscription_tier = current_tier.get("id") - log.info(f"[loadCodeAssist] Found currentTier: {subscription_tier}") - - # 提取积分数量(如果返回了 availableCredits) - credit_amount = None - if isinstance(available_credits, list) and available_credits: - first_credit = available_credits[0] - if isinstance(first_credit, dict): - credit_amount = first_credit.get("creditAmount") - if credit_amount is not None: - log.info(f"[loadCodeAssist] Found creditAmount: {credit_amount}") - - # 检查是否有 currentTier(表示用户已激活) - if current_tier: - log.info("[loadCodeAssist] User is already activated") - - # 使用服务器返回的 project_id - project_id = data.get("cloudaicompanionProject") - if project_id: - log.info(f"[loadCodeAssist] Successfully fetched project_id: {project_id}, tier: {subscription_tier}") - return project_id, subscription_tier, credit_amount - - log.warning("[loadCodeAssist] No project_id in response") - return None, subscription_tier, credit_amount - else: - log.info("[loadCodeAssist] User not activated yet (no currentTier)") - return None, None, credit_amount - else: - log.warning(f"[loadCodeAssist] Failed: HTTP {response.status_code}") - log.warning(f"[loadCodeAssist] Response body: {response.text[:500]}") - raise Exception(f"HTTP {response.status_code}: {response.text[:200]}") - - -async def _try_onboard_user( - api_base_url: str, - headers: dict -) -> Optional[str]: - """ - 尝试通过 onboardUser 获取 project_id(长时间运行操作,需要轮询) - - Returns: - project_id 或 None - """ - request_url = f"{api_base_url.rstrip('/')}/v1internal:onboardUser" - - # 首先需要获取用户的 tier 信息 - tier_id = await _get_onboard_tier(api_base_url, headers) - if not tier_id: - log.error("[onboardUser] Failed to determine user tier") - return None - - log.info(f"[onboardUser] User tier: {tier_id}") - - # 构造 onboardUser 请求 - # 注意:FREE tier 不应该包含 cloudaicompanionProject - request_body = { - "tierId": tier_id, - "metadata": { - "ideType": "ANTIGRAVITY", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI" - } - } - - log.debug(f"[onboardUser] Request URL: {request_url}") - log.debug(f"[onboardUser] Request body: {request_body}") - - # onboardUser 是长时间运行操作,需要轮询 - # 最多等待 10 秒(5 次 * 2 秒) - max_attempts = 5 - attempt = 0 - - while attempt < max_attempts: - attempt += 1 - log.debug(f"[onboardUser] Polling attempt {attempt}/{max_attempts}") - - response = await post_async( - request_url, - json=request_body, - headers=headers, - timeout=30.0, - ) - - log.debug(f"[onboardUser] Response status: {response.status_code}") - - if response.status_code == 200: - data = response.json() - log.debug(f"[onboardUser] Response data: {data}") - - # 检查长时间运行操作是否完成 - if data.get("done"): - log.info("[onboardUser] Operation completed") - - # 从响应中提取 project_id - response_data = data.get("response", {}) - project_obj = response_data.get("cloudaicompanionProject", {}) - - if isinstance(project_obj, dict): - project_id = project_obj.get("id") - elif isinstance(project_obj, str): - project_id = project_obj - else: - project_id = None - - if project_id: - log.info(f"[onboardUser] Successfully fetched project_id: {project_id}") - return project_id - else: - log.warning("[onboardUser] Operation completed but no project_id in response") - return None - else: - log.debug("[onboardUser] Operation still in progress, waiting 2 seconds...") - await asyncio.sleep(2) - else: - log.warning(f"[onboardUser] Failed: HTTP {response.status_code}") - log.warning(f"[onboardUser] Response body: {response.text[:500]}") - raise Exception(f"HTTP {response.status_code}: {response.text[:200]}") - - log.error("[onboardUser] Timeout: Operation did not complete within 10 seconds") - return None - - -async def _get_onboard_tier( - api_base_url: str, - headers: dict -) -> Optional[str]: - """ - 从 loadCodeAssist 响应中获取用户应该注册的 tier - - Returns: - tier_id (如 "FREE", "STANDARD", "LEGACY") 或 None - """ - request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist" - request_body = { - "metadata": { - "ideType": "ANTIGRAVITY", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI" - } - } - - log.debug(f"[_get_onboard_tier] Fetching tier info from: {request_url}") - - response = await post_async( - request_url, - json=request_body, - headers=headers, - timeout=30.0, - ) - - if response.status_code == 200: - data = response.json() - log.debug(f"[_get_onboard_tier] Response data: {data}") - - # 查找默认的 tier - allowed_tiers = data.get("allowedTiers", []) - for tier in allowed_tiers: - if tier.get("isDefault"): - tier_id = tier.get("id") - log.info(f"[_get_onboard_tier] Found default tier: {tier_id}") - return tier_id - - # 如果没有默认 tier,使用 LEGACY 作为回退 - log.warning("[_get_onboard_tier] No default tier found, using LEGACY") - return "LEGACY" - else: - log.error(f"[_get_onboard_tier] Failed to fetch tier info: HTTP {response.status_code}") - return None - - diff --git a/src/httpx_client.py b/src/httpx_client.py deleted file mode 100644 index d04007e2e09e1113f2ef989d204a42b50323664a..0000000000000000000000000000000000000000 --- a/src/httpx_client.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -通用的HTTP客户端模块 -为所有需要使用httpx的模块提供统一的客户端配置和方法 -保持通用性,不与特定业务逻辑耦合 -""" - -from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Dict, Optional - -import httpx - -from config import get_proxy_config -from log import log - - -class HttpxClientManager: - """通用HTTP客户端管理器""" - - async def get_client_kwargs(self, timeout: float = 30.0, **kwargs) -> Dict[str, Any]: - """获取httpx客户端的通用配置参数""" - client_kwargs = {"timeout": timeout, **kwargs} - - # 动态读取代理配置,支持热更新 - current_proxy_config = await get_proxy_config() - if current_proxy_config: - client_kwargs["proxy"] = current_proxy_config - - return client_kwargs - - @asynccontextmanager - async def get_client( - self, timeout: float = 30.0, **kwargs - ) -> AsyncGenerator[httpx.AsyncClient, None]: - """获取配置好的异步HTTP客户端""" - client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs) - - async with httpx.AsyncClient(**client_kwargs) as client: - yield client - - @asynccontextmanager - async def get_streaming_client( - self, timeout: float = None, **kwargs - ) -> AsyncGenerator[httpx.AsyncClient, None]: - """获取用于流式请求的HTTP客户端(无超时限制)""" - client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs) - - # 创建独立的客户端实例用于流式处理 - client = httpx.AsyncClient(**client_kwargs) - try: - yield client - finally: - # 确保无论发生什么都关闭客户端 - try: - await client.aclose() - except Exception as e: - log.warning(f"Error closing streaming client: {e}") - - -# 全局HTTP客户端管理器实例 -http_client = HttpxClientManager() - - -# 通用的异步方法 -async def get_async( - url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0, **kwargs -) -> httpx.Response: - """通用异步GET请求""" - async with http_client.get_client(timeout=timeout, **kwargs) as client: - return await client.get(url, headers=headers) - - -async def post_async( - url: str, - data: Any = None, - json: Any = None, - headers: Optional[Dict[str, str]] = None, - timeout: float = 900.0, - **kwargs, -) -> httpx.Response: - """通用异步POST请求""" - async with http_client.get_client(timeout=timeout, **kwargs) as client: - return await client.post(url, data=data, json=json, headers=headers) - - -# 调试用:设为 True 时所有流式请求都返回 429 -_MOCK_STREAM_429 = False - -async def stream_post_async( - url: str, - body: Dict[str, Any], - native: bool = False, - headers: Optional[Dict[str, str]] = None, - **kwargs, -): - """流式异步POST请求""" - if _MOCK_STREAM_429: - from fastapi import Response - import json - log.warning(f"[MOCK] stream_post_async: 返回模拟429错误") - yield Response( - content=json.dumps({"error": {"code": 429, "message": "mock rate limit", "status": "RESOURCE_EXHAUSTED"}}), - status_code=429, - ) - return - - async with http_client.get_streaming_client(**kwargs) as client: - async with client.stream("POST", url, json=body, headers=headers) as r: - # 错误直接返回 - if r.status_code != 200: - from fastapi import Response - yield Response(await r.aread(), r.status_code, dict(r.headers)) - return - - # 如果native=True,直接返回bytes流 - if native: - async for chunk in r.aiter_bytes(): - yield chunk - else: - # 通过aiter_lines转化成str流返回 - async for line in r.aiter_lines(): - yield line diff --git a/src/keeplive.py b/src/keeplive.py deleted file mode 100644 index 0844d70f77581dbf46f0b8a7ea8420b4f1fb83cb..0000000000000000000000000000000000000000 --- a/src/keeplive.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -保活服务模块 -定期向配置的URL发送GET请求,保持服务在线 -未配置保活URL时不启动任何任务,零资源占用 -""" - -import asyncio -from typing import Optional - -from config import get_keepalive_interval, get_keepalive_url -from log import log -from src.httpx_client import get_async - - -class KeepAliveService: - """保活服务:定期向指定URL发送GET请求""" - - def __init__(self): - self._task: Optional[asyncio.Task] = None - - async def _run(self, url: str, interval: int): - """保活循环,读取到有效URL才会被调用""" - log.info(f"[KeepAlive] 保活任务启动,URL={url},间隔={interval}s") - while True: - try: - response = await get_async(url, timeout=30.0) - log.info(f"[KeepAlive] GET {url} -> {response.status_code}") - except asyncio.CancelledError: - raise - except Exception as e: - log.warning(f"[KeepAlive] GET {url} 失败: {e}") - - try: - await asyncio.sleep(interval) - except asyncio.CancelledError: - raise - - async def start(self): - """ - 启动保活服务。 - 仅当配置了有效的保活URL时才创建后台任务,否则零开销。 - """ - if self._task and not self._task.done(): - # 已有任务在运行,不重复启动 - return - - url = await get_keepalive_url() - interval = await get_keepalive_interval() - - if not url or not url.strip(): - log.debug("[KeepAlive] 未配置保活URL,保活服务不启动") - return - - if interval <= 0: - log.warning(f"[KeepAlive] 保活间隔无效({interval}),保活服务不启动") - return - - self._task = asyncio.create_task( - self._run(url.strip(), interval), name="keepalive_service" - ) - - async def stop(self): - """停止保活服务""" - if self._task and not self._task.done(): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - log.info("[KeepAlive] 保活服务已停止") - self._task = None - - async def restart(self): - """ - 重启保活服务。 - 配置变更时调用,会停止旧任务并根据最新配置决定是否启动新任务。 - """ - await self.stop() - await self.start() - - @property - def is_running(self) -> bool: - """当前保活任务是否在运行""" - return self._task is not None and not self._task.done() - - -# 全局保活服务实例 -keepalive_service = KeepAliveService() diff --git a/src/models.py b/src/models.py deleted file mode 100644 index 5eeec1e38c4aecd46f2106b01bd2130afe1aca20..0000000000000000000000000000000000000000 --- a/src/models.py +++ /dev/null @@ -1,383 +0,0 @@ -from typing import Any, Dict, List, Optional, Union - -from pydantic import BaseModel, Field - - -# Pydantic v1/v2 兼容性辅助函数 -def model_to_dict(model: BaseModel) -> Dict[str, Any]: - """ - 兼容 Pydantic v1 和 v2 的模型转字典方法,排除 None 值 - - v1: model.dict(exclude_none=True) - - v2: model.model_dump(exclude_none=True) - """ - if hasattr(model, 'model_dump'): - # Pydantic v2 - return model.model_dump(exclude_none=True) - else: - # Pydantic v1 - return model.dict(exclude_none=True) - - -# Common Models -class Model(BaseModel): - id: str - object: str = "model" - created: Optional[int] = None - owned_by: Optional[str] = "google" - - -class ModelList(BaseModel): - object: str = "list" - data: List[Model] - - -# OpenAI Models -class OpenAIToolFunction(BaseModel): - name: str - arguments: str # JSON string - - -class OpenAIToolCall(BaseModel): - id: str - type: str = "function" - function: OpenAIToolFunction - - -class OpenAITool(BaseModel): - type: str = "function" - function: Dict[str, Any] - - -class OpenAIChatMessage(BaseModel): - role: str - content: Union[str, List[Dict[str, Any]], None] = None - reasoning_content: Optional[str] = None - name: Optional[str] = None - tool_calls: Optional[List[OpenAIToolCall]] = None - tool_call_id: Optional[str] = None # for role="tool" - - -class OpenAIChatCompletionRequest(BaseModel): - model: str - messages: List[OpenAIChatMessage] - stream: bool = False - temperature: Optional[float] = Field(None, ge=0.0, le=2.0) - top_p: Optional[float] = Field(None, ge=0.0, le=1.0) - max_tokens: Optional[int] = Field(None, ge=1) - stop: Optional[Union[str, List[str]]] = None - frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0) - presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0) - n: Optional[int] = Field(1, ge=1, le=128) - seed: Optional[int] = None - response_format: Optional[Dict[str, Any]] = None - top_k: Optional[int] = Field(None, ge=1) - tools: Optional[List[OpenAITool]] = None - tool_choice: Optional[Union[str, Dict[str, Any]]] = None - - class Config: - extra = "allow" # Allow additional fields not explicitly defined - - -# 通用的聊天完成请求模型(兼容OpenAI和其他格式) -ChatCompletionRequest = OpenAIChatCompletionRequest - - -class OpenAIChatCompletionChoice(BaseModel): - index: int - message: OpenAIChatMessage - finish_reason: Optional[str] = None - logprobs: Optional[Dict[str, Any]] = None - - -class OpenAIChatCompletionResponse(BaseModel): - id: str - object: str = "chat.completion" - created: int - model: str - choices: List[OpenAIChatCompletionChoice] - usage: Optional[Dict[str, Any]] = None - system_fingerprint: Optional[str] = None - - -class OpenAIDelta(BaseModel): - role: Optional[str] = None - content: Optional[str] = None - reasoning_content: Optional[str] = None - - -class OpenAIChatCompletionStreamChoice(BaseModel): - index: int - delta: OpenAIDelta - finish_reason: Optional[str] = None - logprobs: Optional[Dict[str, Any]] = None - - -class OpenAIChatCompletionStreamResponse(BaseModel): - id: str - object: str = "chat.completion.chunk" - created: int - model: str - choices: List[OpenAIChatCompletionStreamChoice] - system_fingerprint: Optional[str] = None - - -# Gemini Models -class GeminiPart(BaseModel): - text: Optional[str] = None - inlineData: Optional[Dict[str, Any]] = None - fileData: Optional[Dict[str, Any]] = None - thought: Optional[bool] = None # 改为 None,避免序列化时包含 False - - class Config: - extra = "allow" # 允许额外字段(如 functionCall, functionResponse) - - -class GeminiContent(BaseModel): - role: str - parts: List[GeminiPart] - - -class GeminiSystemInstruction(BaseModel): - parts: List[GeminiPart] - - -class GeminiImageConfig(BaseModel): - """图片生成配置""" - aspect_ratio: Optional[str] = None # "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9" - image_size: Optional[str] = None # "1K", "2K", "4K" - - -class GeminiGenerationConfig(BaseModel): - temperature: Optional[float] = Field(None, ge=0.0, le=2.0) - topP: Optional[float] = Field(None, ge=0.0, le=1.0) - topK: Optional[int] = Field(None, ge=1) - maxOutputTokens: Optional[int] = Field(None, ge=1) - stopSequences: Optional[List[str]] = None - responseMimeType: Optional[str] = None - responseSchema: Optional[Dict[str, Any]] = None - candidateCount: Optional[int] = Field(None, ge=1, le=8) - seed: Optional[int] = None - frequencyPenalty: Optional[float] = Field(None, ge=-2.0, le=2.0) - presencePenalty: Optional[float] = Field(None, ge=-2.0, le=2.0) - thinkingConfig: Optional[Dict[str, Any]] = None - # 图片生成相关参数 - response_modalities: Optional[List[str]] = None # ["TEXT", "IMAGE"] - image_config: Optional[GeminiImageConfig] = None - - -class GeminiSafetySetting(BaseModel): - category: str - threshold: str - - -class GeminiRequest(BaseModel): - contents: List[GeminiContent] - systemInstruction: Optional[GeminiSystemInstruction] = None - generationConfig: Optional[GeminiGenerationConfig] = None - safetySettings: Optional[List[GeminiSafetySetting]] = None - tools: Optional[List[Dict[str, Any]]] = None - toolConfig: Optional[Dict[str, Any]] = None - cachedContent: Optional[str] = None - - class Config: - extra = "allow" # 允许透传未定义的字段 - - -class GeminiCandidate(BaseModel): - content: GeminiContent - finishReason: Optional[str] = None - index: int = 0 - safetyRatings: Optional[List[Dict[str, Any]]] = None - citationMetadata: Optional[Dict[str, Any]] = None - tokenCount: Optional[int] = None - - -class GeminiUsageMetadata(BaseModel): - promptTokenCount: Optional[int] = None - candidatesTokenCount: Optional[int] = None - totalTokenCount: Optional[int] = None - cachedContentTokenCount: Optional[int] = None - thoughtsTokenCount: Optional[int] = None - - -class GeminiResponse(BaseModel): - candidates: List[GeminiCandidate] - usageMetadata: Optional[GeminiUsageMetadata] = None - modelVersion: Optional[str] = None - - -# Claude Models -class ClaudeContentBlock(BaseModel): - type: str # "text", "image", "tool_use", "tool_result" - text: Optional[str] = None - source: Optional[Dict[str, Any]] = None # for image type - id: Optional[str] = None # for tool_use - name: Optional[str] = None # for tool_use - input: Optional[Dict[str, Any]] = None # for tool_use - tool_use_id: Optional[str] = None # for tool_result - content: Optional[Union[str, List[Dict[str, Any]]]] = None # for tool_result - - -class ClaudeMessage(BaseModel): - role: str # "user" or "assistant" - content: Union[str, List[ClaudeContentBlock]] - - -class ClaudeTool(BaseModel): - name: str - description: Optional[str] = None - input_schema: Dict[str, Any] - - -class ClaudeMetadata(BaseModel): - user_id: Optional[str] = None - - -class ClaudeRequest(BaseModel): - model: str - messages: List[ClaudeMessage] - max_tokens: int = Field(..., ge=1) - system: Optional[Union[str, List[Dict[str, Any]]]] = None - temperature: Optional[float] = Field(None, ge=0.0, le=1.0) - top_p: Optional[float] = Field(None, ge=0.0, le=1.0) - top_k: Optional[int] = Field(None, ge=1) - stop_sequences: Optional[List[str]] = None - stream: bool = False - metadata: Optional[ClaudeMetadata] = None - tools: Optional[List[ClaudeTool]] = None - tool_choice: Optional[Union[str, Dict[str, Any]]] = None - - class Config: - extra = "allow" - - -class ClaudeUsage(BaseModel): - input_tokens: int - output_tokens: int - cache_creation_input_tokens: Optional[int] = None - cache_read_input_tokens: Optional[int] = None - - -class ClaudeResponse(BaseModel): - id: str - type: str = "message" - role: str = "assistant" - content: List[ClaudeContentBlock] - model: str - stop_reason: Optional[str] = None - stop_sequence: Optional[str] = None - usage: ClaudeUsage - - -class ClaudeStreamEvent(BaseModel): - type: str # "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop" - message: Optional[ClaudeResponse] = None - index: Optional[int] = None - content_block: Optional[ClaudeContentBlock] = None - delta: Optional[Dict[str, Any]] = None - usage: Optional[ClaudeUsage] = None - - class Config: - extra = "allow" - - -# Error Models -class APIError(BaseModel): - message: str - type: str = "api_error" - code: Optional[int] = None - - -class ErrorResponse(BaseModel): - error: APIError - - -# Control Panel Models -class SystemStatus(BaseModel): - status: str - timestamp: str - credentials: Dict[str, int] - config: Dict[str, Any] - current_credential: str - - -class CredentialInfo(BaseModel): - filename: str - project_id: Optional[str] = None - status: Dict[str, Any] - size: Optional[int] = None - modified_time: Optional[str] = None - error: Optional[str] = None - - -class LogEntry(BaseModel): - timestamp: str - level: str - message: str - module: Optional[str] = None - - -class ConfigValue(BaseModel): - key: str - value: Any - env_locked: bool = False - description: Optional[str] = None - - -# Authentication Models -class AuthRequest(BaseModel): - project_id: Optional[str] = None - user_session: Optional[str] = None - - -class AuthResponse(BaseModel): - success: bool - auth_url: Optional[str] = None - state: Optional[str] = None - error: Optional[str] = None - credentials: Optional[Dict[str, Any]] = None - file_path: Optional[str] = None - requires_manual_project_id: Optional[bool] = None - requires_project_selection: Optional[bool] = None - available_projects: Optional[List[Dict[str, str]]] = None - - -class CredentialStatus(BaseModel): - disabled: bool = False - error_codes: List[int] = [] - last_success: Optional[str] = None - - -# Web Routes Models -class LoginRequest(BaseModel): - password: str - - -class AuthStartRequest(BaseModel): - project_id: Optional[str] = None # 现在是可选的 - mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity - - -class AuthCallbackRequest(BaseModel): - project_id: Optional[str] = None # 现在是可选的 - mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity - - -class AuthCallbackUrlRequest(BaseModel): - callback_url: str # OAuth回调完整URL - project_id: Optional[str] = None # 可选的项目ID - mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity - - -class CredFileActionRequest(BaseModel): - filename: str - action: str # enable, disable, delete - - -class CredFileBatchActionRequest(BaseModel): - action: str # "enable", "disable", "delete" - filenames: List[str] # 批量操作的文件名列表 - - -class ConfigSaveRequest(BaseModel): - config: dict diff --git a/src/panel/__init__.py b/src/panel/__init__.py deleted file mode 100644 index 07fda27851f384a842e5abaf7ac3e7f41c0bed68..0000000000000000000000000000000000000000 --- a/src/panel/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Panel模块 - 整合所有控制面板路由 -""" - -from fastapi import APIRouter - -from . import auth, creds, config_routes, logs, version, root - - -def create_router() -> APIRouter: - """创建并返回整合所有子路由的主路由器""" - router = APIRouter() - - # 包含所有子路由 - router.include_router(root.router) - router.include_router(auth.router) - router.include_router(creds.router) - router.include_router(config_routes.router) - router.include_router(logs.router) - router.include_router(version.router) - - return router - - -# 导出主路由器 -router = create_router() - -# 导出常用工具 -from .utils import ConnectionManager, is_mobile_user_agent, validate_mode, get_env_locked_keys - -__all__ = [ - "router", - "ConnectionManager", - "is_mobile_user_agent", - "validate_mode", - "get_env_locked_keys", -] diff --git a/src/panel/auth.py b/src/panel/auth.py deleted file mode 100644 index 21adff032191e62ccf2dc491820cb4f77b75b4c5..0000000000000000000000000000000000000000 --- a/src/panel/auth.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -认证路由模块 - 处理 /auth/* 相关的HTTP请求 -""" - -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import JSONResponse - -from log import log -from src.auth import ( - asyncio_complete_auth_flow, - complete_auth_flow_from_callback_url, - create_auth_url, - get_auth_status, - verify_password, -) -from src.models import ( - LoginRequest, - AuthStartRequest, - AuthCallbackRequest, - AuthCallbackUrlRequest, -) -from src.utils import verify_panel_token - - -# 创建路由器 -router = APIRouter(prefix="/auth", tags=["auth"]) - - -@router.post("/login") -async def login(request: LoginRequest): - """用户登录(简化版:直接返回密码作为token)""" - try: - if await verify_password(request.password): - # 直接使用密码作为token,简化认证流程 - return JSONResponse(content={"token": request.password, "message": "登录成功"}) - else: - raise HTTPException(status_code=401, detail="密码错误") - except HTTPException: - raise - except Exception as e: - log.error(f"登录失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/start") -async def start_auth(request: AuthStartRequest, token: str = Depends(verify_panel_token)): - """开始认证流程,支持自动检测项目ID""" - try: - # 如果没有提供项目ID,尝试自动检测 - project_id = request.project_id - if not project_id: - log.info("用户未提供项目ID,后续将使用自动检测...") - - # 使用认证令牌作为用户会话标识 - user_session = token if token else None - result = await create_auth_url( - project_id, user_session, mode=request.mode - ) - - if result["success"]: - return JSONResponse( - content={ - "auth_url": result["auth_url"], - "state": result["state"], - "auto_project_detection": result.get("auto_project_detection", False), - "detected_project_id": result.get("detected_project_id"), - } - ) - else: - raise HTTPException(status_code=500, detail=result["error"]) - - except HTTPException: - raise - except Exception as e: - log.error(f"开始认证流程失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/callback") -async def auth_callback(request: AuthCallbackRequest, token: str = Depends(verify_panel_token)): - """处理认证回调,支持自动检测项目ID""" - try: - # 项目ID现在是可选的,在回调处理中进行自动检测 - project_id = request.project_id - - # 使用认证令牌作为用户会话标识 - user_session = token if token else None - # 异步等待OAuth回调完成 - result = await asyncio_complete_auth_flow( - project_id, user_session, mode=request.mode - ) - - if result["success"]: - # 单项目认证成功 - return JSONResponse( - content={ - "credentials": result["credentials"], - "file_path": result["file_path"], - "message": "认证成功,凭证已保存", - "auto_detected_project": result.get("auto_detected_project", False), - } - ) - else: - # 如果需要手动项目ID或项目选择,在响应中标明 - if result.get("requires_manual_project_id"): - # 使用JSON响应 - return JSONResponse( - status_code=400, - content={"error": result["error"], "requires_manual_project_id": True}, - ) - elif result.get("requires_project_selection"): - # 返回项目列表供用户选择 - return JSONResponse( - status_code=400, - content={ - "error": result["error"], - "requires_project_selection": True, - "available_projects": result["available_projects"], - }, - ) - else: - raise HTTPException(status_code=400, detail=result["error"]) - - except HTTPException: - raise - except Exception as e: - log.error(f"处理认证回调失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/callback-url") -async def auth_callback_url(request: AuthCallbackUrlRequest, token: str = Depends(verify_panel_token)): - """从回调URL直接完成认证""" - try: - # 验证URL格式 - if not request.callback_url or not request.callback_url.startswith(("http://", "https://")): - raise HTTPException(status_code=400, detail="请提供有效的回调URL") - - # 从回调URL完成认证 - result = await complete_auth_flow_from_callback_url( - request.callback_url, request.project_id, mode=request.mode - ) - - if result["success"]: - # 单项目认证成功 - return JSONResponse( - content={ - "credentials": result["credentials"], - "file_path": result["file_path"], - "message": "从回调URL认证成功,凭证已保存", - "auto_detected_project": result.get("auto_detected_project", False), - } - ) - else: - # 处理各种错误情况 - if result.get("requires_manual_project_id"): - return JSONResponse( - status_code=400, - content={"error": result["error"], "requires_manual_project_id": True}, - ) - elif result.get("requires_project_selection"): - return JSONResponse( - status_code=400, - content={ - "error": result["error"], - "requires_project_selection": True, - "available_projects": result["available_projects"], - }, - ) - else: - raise HTTPException(status_code=400, detail=result["error"]) - - except HTTPException: - raise - except Exception as e: - log.error(f"从回调URL处理认证失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/status/{project_id}") -async def check_auth_status(project_id: str, token: str = Depends(verify_panel_token)): - """检查认证状态""" - try: - if not project_id: - raise HTTPException(status_code=400, detail="Project ID 不能为空") - - status = get_auth_status(project_id) - return JSONResponse(content=status) - - except Exception as e: - log.error(f"检查认证状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/panel/config_routes.py b/src/panel/config_routes.py deleted file mode 100644 index dd1f489a012743bdedb684942d9adf7d0cb6d968..0000000000000000000000000000000000000000 --- a/src/panel/config_routes.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -配置路由模块 - 处理 /config/* 相关的HTTP请求 -""" - -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import JSONResponse - -import config -from log import log -from src.keeplive import keepalive_service -from src.models import ConfigSaveRequest -from src.storage_adapter import get_storage_adapter -from src.utils import verify_panel_token -from .utils import get_env_locked_keys - - -# 创建路由器 -router = APIRouter(prefix="/config", tags=["config"]) - - -@router.get("/get") -async def get_config(token: str = Depends(verify_panel_token)): - """获取当前配置""" - try: - - - # 读取当前配置(包括环境变量和TOML文件中的配置) - current_config = {} - - # 基础配置 - current_config["code_assist_endpoint"] = await config.get_code_assist_endpoint() - current_config["credentials_dir"] = await config.get_credentials_dir() - current_config["proxy"] = await config.get_proxy_config() or "" - - # 代理端点配置 - current_config["oauth_proxy_url"] = await config.get_oauth_proxy_url() - current_config["googleapis_proxy_url"] = await config.get_googleapis_proxy_url() - current_config["resource_manager_api_url"] = await config.get_resource_manager_api_url() - current_config["service_usage_api_url"] = await config.get_service_usage_api_url() - current_config["antigravity_api_url"] = await config.get_antigravity_api_url() - - # 自动封禁配置 - current_config["auto_ban_enabled"] = await config.get_auto_ban_enabled() - current_config["auto_ban_error_codes"] = await config.get_auto_ban_error_codes() - - # 429重试配置 - current_config["retry_429_max_retries"] = await config.get_retry_429_max_retries() - current_config["retry_429_enabled"] = await config.get_retry_429_enabled() - current_config["retry_429_interval"] = await config.get_retry_429_interval() - # 抗截断配置 - current_config["anti_truncation_max_attempts"] = await config.get_anti_truncation_max_attempts() - - # 兼容性配置 - current_config["compatibility_mode_enabled"] = await config.get_compatibility_mode_enabled() - - # 思维链返回配置 - current_config["return_thoughts_to_frontend"] = await config.get_return_thoughts_to_frontend() - - # Antigravity流式转非流式配置 - current_config["antigravity_stream2nostream"] = await config.get_antigravity_stream2nostream() - current_config["antigravity_switch_credential_enabled"] = await config.get_antigravity_switch_credential_enabled() - - # 保活配置 - current_config["keepalive_url"] = await config.get_keepalive_url() - current_config["keepalive_interval"] = await config.get_keepalive_interval() - - # 服务器配置 - current_config["host"] = await config.get_server_host() - current_config["port"] = await config.get_server_port() - current_config["api_password"] = await config.get_api_password() - current_config["panel_password"] = await config.get_panel_password() - current_config["password"] = await config.get_server_password() - - # 从存储系统读取配置 - storage_adapter = await get_storage_adapter() - storage_config = await storage_adapter.get_all_config() - - # 获取环境变量锁定的配置键 - env_locked_keys = get_env_locked_keys() - - # 合并存储系统配置(不覆盖环境变量) - for key, value in storage_config.items(): - if key not in env_locked_keys: - current_config[key] = value - - return JSONResponse(content={"config": current_config, "env_locked": list(env_locked_keys)}) - - except Exception as e: - log.error(f"获取配置失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/save") -async def save_config(request: ConfigSaveRequest, token: str = Depends(verify_panel_token)): - """保存配置""" - try: - - new_config = request.config - - log.debug(f"收到的配置数据: {list(new_config.keys())}") - log.debug(f"收到的password值: {new_config.get('password', 'NOT_FOUND')}") - - # 验证配置项 - if "retry_429_max_retries" in new_config: - if ( - not isinstance(new_config["retry_429_max_retries"], int) - or new_config["retry_429_max_retries"] < 0 - ): - raise HTTPException(status_code=400, detail="最大429重试次数必须是大于等于0的整数") - - if "retry_429_enabled" in new_config: - if not isinstance(new_config["retry_429_enabled"], bool): - raise HTTPException(status_code=400, detail="429重试开关必须是布尔值") - - # 验证新的配置项 - if "retry_429_interval" in new_config: - try: - interval = float(new_config["retry_429_interval"]) - if interval < 0.01 or interval > 10: - raise HTTPException(status_code=400, detail="429重试间隔必须在0.01-10秒之间") - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="429重试间隔必须是有效的数字") - - if "anti_truncation_max_attempts" in new_config: - if ( - not isinstance(new_config["anti_truncation_max_attempts"], int) - or new_config["anti_truncation_max_attempts"] < 1 - or new_config["anti_truncation_max_attempts"] > 10 - ): - raise HTTPException( - status_code=400, detail="抗截断最大重试次数必须是1-10之间的整数" - ) - - if "compatibility_mode_enabled" in new_config: - if not isinstance(new_config["compatibility_mode_enabled"], bool): - raise HTTPException(status_code=400, detail="兼容性模式开关必须是布尔值") - - if "return_thoughts_to_frontend" in new_config: - if not isinstance(new_config["return_thoughts_to_frontend"], bool): - raise HTTPException(status_code=400, detail="思维链返回开关必须是布尔值") - - if "antigravity_stream2nostream" in new_config: - if not isinstance(new_config["antigravity_stream2nostream"], bool): - raise HTTPException(status_code=400, detail="Antigravity流式转非流式开关必须是布尔值") - - if "antigravity_switch_credential_enabled" in new_config: - if not isinstance(new_config["antigravity_switch_credential_enabled"], bool): - raise HTTPException(status_code=400, detail="Antigravity切换凭证开关必须是布尔值") - - # 验证保活配置 - if "keepalive_url" in new_config: - if not isinstance(new_config["keepalive_url"], str): - raise HTTPException(status_code=400, detail="保活URL必须是字符串") - - if "keepalive_interval" in new_config: - try: - interval = int(new_config["keepalive_interval"]) - if interval < 5 or interval > 86400: - raise HTTPException(status_code=400, detail="保活间隔必须在 5-86400 秒之间") - new_config["keepalive_interval"] = interval - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="保活间隔必须是有效整数") - # 验证服务器配置 - if "host" in new_config: - if not isinstance(new_config["host"], str) or not new_config["host"].strip(): - raise HTTPException(status_code=400, detail="服务器主机地址不能为空") - - if "port" in new_config: - if ( - not isinstance(new_config["port"], int) - or new_config["port"] < 1 - or new_config["port"] > 65535 - ): - raise HTTPException(status_code=400, detail="端口号必须是1-65535之间的整数") - - if "api_password" in new_config: - if not isinstance(new_config["api_password"], str): - raise HTTPException(status_code=400, detail="API访问密码必须是字符串") - - if "panel_password" in new_config: - if not isinstance(new_config["panel_password"], str): - raise HTTPException(status_code=400, detail="控制面板密码必须是字符串") - - if "password" in new_config: - if not isinstance(new_config["password"], str): - raise HTTPException(status_code=400, detail="访问密码必须是字符串") - - # 获取环境变量锁定的配置键 - env_locked_keys = get_env_locked_keys() - - # 直接使用存储适配器保存配置 - storage_adapter = await get_storage_adapter() - for key, value in new_config.items(): - if key not in env_locked_keys: - await storage_adapter.set_config(key, value) - if key in ("password", "api_password", "panel_password"): - log.debug(f"设置{key}字段为: {value}") - - # 重新加载配置缓存(关键!) - await config.reload_config() - - # 如果保活相关配置发生变化,立即重启保活服务 - keepalive_keys = {"keepalive_url", "keepalive_interval"} - if keepalive_keys & set(new_config.keys()): - try: - await keepalive_service.restart() - except Exception as e: - log.warning(f"重启保活服务失败: {e}") - - # 验证保存后的结果 - test_api_password = await config.get_api_password() - test_panel_password = await config.get_panel_password() - test_password = await config.get_server_password() - log.debug(f"保存后立即读取的API密码: {test_api_password}") - log.debug(f"保存后立即读取的面板密码: {test_panel_password}") - log.debug(f"保存后立即读取的通用密码: {test_password}") - - # 构建响应消息 - response_data = { - "message": "配置保存成功", - "saved_config": {k: v for k, v in new_config.items() if k not in env_locked_keys}, - } - - return JSONResponse(content=response_data) - - except HTTPException: - raise - except Exception as e: - log.error(f"保存配置失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/panel/creds.py b/src/panel/creds.py deleted file mode 100644 index 9f64df0a0ea58cac956dd6ecbf75ffe7a2652105..0000000000000000000000000000000000000000 --- a/src/panel/creds.py +++ /dev/null @@ -1,1585 +0,0 @@ -""" -凭证管理路由模块 - 处理 /creds/* 相关的HTTP请求 -""" - -import asyncio -import io -import json -import os -import time -import zipfile -from typing import Any, List - -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response -from fastapi.responses import JSONResponse - -from log import log -from src.credential_manager import credential_manager -from src.models import ( - CredFileActionRequest, - CredFileBatchActionRequest -) -from src.storage_adapter import get_storage_adapter -from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT -from src.api.antigravity import fetch_quota_info -from src.google_oauth_api import Credentials, fetch_project_id_and_tier -from config import get_code_assist_endpoint, get_antigravity_api_url -from .utils import validate_mode - - -# 创建路由器 -router = APIRouter(prefix="/creds", tags=["credentials"]) - - -# ============================================================================= -# 工具函数 (Helper Functions) -# ============================================================================= - - -async def extract_json_files_from_zip(zip_file: UploadFile) -> List[dict]: - """从ZIP文件中提取JSON文件""" - try: - # 读取ZIP文件内容 - zip_content = await zip_file.read() - - # 不限制ZIP文件大小,只在处理时控制文件数量 - - files_data = [] - - with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_ref: - # 获取ZIP中的所有文件 - file_list = zip_ref.namelist() - json_files = [ - f for f in file_list if f.endswith(".json") and not f.startswith("__MACOSX/") - ] - - if not json_files: - raise HTTPException(status_code=400, detail="ZIP文件中没有找到JSON文件") - - log.info(f"从ZIP文件 {zip_file.filename} 中找到 {len(json_files)} 个JSON文件") - - for json_filename in json_files: - try: - # 读取JSON文件内容 - with zip_ref.open(json_filename) as json_file: - content = json_file.read() - - try: - content_str = content.decode("utf-8") - except UnicodeDecodeError: - log.warning(f"跳过编码错误的文件: {json_filename}") - continue - - # 使用原始文件名(去掉路径) - filename = os.path.basename(json_filename) - files_data.append({"filename": filename, "content": content_str}) - - except Exception as e: - log.warning(f"处理ZIP中的文件 {json_filename} 时出错: {e}") - continue - - log.info(f"成功从ZIP文件中提取 {len(files_data)} 个有效的JSON文件") - return files_data - - except zipfile.BadZipFile: - raise HTTPException(status_code=400, detail="无效的ZIP文件格式") - except Exception as e: - log.error(f"处理ZIP文件失败: {e}") - raise HTTPException(status_code=500, detail=f"处理ZIP文件失败: {str(e)}") - - -async def clear_all_model_cooldowns_for_credential( - storage_adapter: Any, - filename: str, - mode: str, -) -> None: - """清空指定凭证的所有模型冷却(后端支持时执行)。""" - try: - cleared = await storage_adapter._backend.clear_all_model_cooldowns(filename, mode=mode) - if not cleared: - log.warning(f"清空模型CD失败或凭证不存在: {filename} (mode={mode})") - except Exception as e: - log.warning(f"清空模型CD时出错: {filename} (mode={mode}), error={e}") - - -async def upload_credentials_common( - files: List[UploadFile], mode: str = "geminicli" -) -> JSONResponse: - """批量上传凭证文件的通用函数""" - mode = validate_mode(mode) - - if not files: - raise HTTPException(status_code=400, detail="请选择要上传的文件") - - # 检查文件数量限制 - if len(files) > 100: - raise HTTPException( - status_code=400, detail=f"文件数量过多,最多支持100个文件,当前:{len(files)}个" - ) - - files_data = [] - for file in files: - # 检查文件类型:支持JSON和ZIP - if file.filename.endswith(".zip"): - zip_files_data = await extract_json_files_from_zip(file) - files_data.extend(zip_files_data) - log.info(f"从ZIP文件 {file.filename} 中提取了 {len(zip_files_data)} 个JSON文件") - - elif file.filename.endswith(".json"): - # 处理单个JSON文件 - 流式读取 - content_chunks = [] - while True: - chunk = await file.read(8192) - if not chunk: - break - content_chunks.append(chunk) - - content = b"".join(content_chunks) - try: - content_str = content.decode("utf-8") - except UnicodeDecodeError: - raise HTTPException( - status_code=400, detail=f"文件 {file.filename} 编码格式不支持" - ) - - files_data.append({"filename": file.filename, "content": content_str}) - else: - raise HTTPException( - status_code=400, detail=f"文件 {file.filename} 格式不支持,只支持JSON和ZIP文件" - ) - - - - batch_size = 1000 - all_results = [] - total_success = 0 - - for i in range(0, len(files_data), batch_size): - batch_files = files_data[i : i + batch_size] - - async def process_single_file(file_data): - try: - filename = file_data["filename"] - # 确保文件名只保存basename,避免路径问题 - filename = os.path.basename(filename) - content_str = file_data["content"] - credential_data = json.loads(content_str) - - # 根据凭证类型调用不同的添加方法 - if mode == "antigravity": - await credential_manager.add_antigravity_credential(filename, credential_data) - else: - await credential_manager.add_credential(filename, credential_data) - - log.debug(f"成功上传 {mode} 凭证文件: {filename}") - return {"filename": filename, "status": "success", "message": "上传成功"} - - except json.JSONDecodeError as e: - return { - "filename": file_data["filename"], - "status": "error", - "message": f"JSON格式错误: {str(e)}", - } - except Exception as e: - return { - "filename": file_data["filename"], - "status": "error", - "message": f"处理失败: {str(e)}", - } - - log.info(f"开始并发处理 {len(batch_files)} 个 {mode} 文件...") - concurrent_tasks = [process_single_file(file_data) for file_data in batch_files] - batch_results = await asyncio.gather(*concurrent_tasks, return_exceptions=True) - - processed_results = [] - batch_uploaded_count = 0 - for result in batch_results: - if isinstance(result, Exception): - processed_results.append( - { - "filename": "unknown", - "status": "error", - "message": f"处理异常: {str(result)}", - } - ) - else: - processed_results.append(result) - if result["status"] == "success": - batch_uploaded_count += 1 - - all_results.extend(processed_results) - total_success += batch_uploaded_count - - batch_num = (i // batch_size) + 1 - total_batches = (len(files_data) + batch_size - 1) // batch_size - log.info( - f"批次 {batch_num}/{total_batches} 完成: 成功 " - f"{batch_uploaded_count}/{len(batch_files)} 个 {mode} 文件" - ) - - if total_success > 0: - return JSONResponse( - content={ - "uploaded_count": total_success, - "total_count": len(files_data), - "results": all_results, - "message": f"批量上传完成: 成功 {total_success}/{len(files_data)} 个 {mode} 文件", - } - ) - else: - raise HTTPException(status_code=400, detail=f"没有 {mode} 文件上传成功") - - -async def get_creds_status_common( - offset: int, limit: int, status_filter: str, mode: str = "geminicli", - error_code_filter: str = None, cooldown_filter: str = None, preview_filter: str = None, tier_filter: str = None -) -> JSONResponse: - """获取凭证文件状态的通用函数""" - mode = validate_mode(mode) - # 验证分页参数 - if offset < 0: - raise HTTPException(status_code=400, detail="offset 必须大于等于 0") - if limit not in [20, 50, 100, 200, 500, 1000]: - raise HTTPException(status_code=400, detail="limit 只能是 20、50、100、200、500 或 1000") - if status_filter not in ["all", "enabled", "disabled"]: - raise HTTPException(status_code=400, detail="status_filter 只能是 all、enabled 或 disabled") - if cooldown_filter and cooldown_filter not in ["all", "in_cooldown", "no_cooldown"]: - raise HTTPException(status_code=400, detail="cooldown_filter 只能是 all、in_cooldown 或 no_cooldown") - if preview_filter and preview_filter not in ["all", "preview", "no_preview"]: - raise HTTPException(status_code=400, detail="preview_filter 只能是 all、preview 或 no_preview") - if tier_filter and tier_filter not in ["all", "free", "pro", "ultra"]: - raise HTTPException(status_code=400, detail="tier_filter 只能是 all、free、pro 或 ultra") - - - - storage_adapter = await get_storage_adapter() - backend_info = await storage_adapter.get_backend_info() - backend_type = backend_info.get("backend_type", "unknown") - - # 使用高性能的分页摘要查询 - result = await storage_adapter._backend.get_credentials_summary( - offset=offset, - limit=limit, - status_filter=status_filter, - mode=mode, - error_code_filter=error_code_filter if error_code_filter and error_code_filter != "all" else None, - cooldown_filter=cooldown_filter if cooldown_filter and cooldown_filter != "all" else None, - preview_filter=preview_filter if preview_filter and preview_filter != "all" else None, - tier_filter=tier_filter if tier_filter and tier_filter != "all" else None - ) - - creds_list = [] - for summary in result["items"]: - cred_info = { - "filename": os.path.basename(summary["filename"]), - "user_email": summary["user_email"], - "disabled": summary["disabled"], - "error_codes": summary["error_codes"], - "last_success": summary["last_success"], - "backend_type": backend_type, - "model_cooldowns": summary.get("model_cooldowns", {}), - "tier": summary.get("tier", "pro"), - } - - if mode == "geminicli": - cred_info["preview"] = summary.get("preview", True) - else: - cred_info["enable_credit"] = summary.get("enable_credit", False) - - creds_list.append(cred_info) - - return JSONResponse(content={ - "items": creds_list, - "total": result["total"], - "offset": offset, - "limit": limit, - "has_more": (offset + limit) < result["total"], - "stats": result.get("stats", {"total": 0, "normal": 0, "disabled": 0}), - }) - - -async def download_all_creds_common(mode: str = "geminicli") -> Response: - """打包下载所有凭证文件的通用函数""" - mode = validate_mode(mode) - zip_filename = "antigravity_credentials.zip" if mode == "antigravity" else "credentials.zip" - - storage_adapter = await get_storage_adapter() - credential_filenames = await storage_adapter.list_credentials(mode=mode) - - if not credential_filenames: - raise HTTPException(status_code=404, detail=f"没有找到 {mode} 凭证文件") - - log.info(f"开始打包 {len(credential_filenames)} 个 {mode} 凭证文件...") - - zip_buffer = io.BytesIO() - - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - success_count = 0 - for idx, filename in enumerate(credential_filenames, 1): - try: - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if credential_data: - content = json.dumps(credential_data, ensure_ascii=False, indent=2) - zip_file.writestr(os.path.basename(filename), content) - success_count += 1 - - if idx % 10 == 0: - log.debug(f"打包进度: {idx}/{len(credential_filenames)}") - - except Exception as e: - log.warning(f"处理 {mode} 凭证文件 {filename} 时出错: {e}") - continue - - log.info(f"打包完成: 成功 {success_count}/{len(credential_filenames)} 个文件") - - zip_buffer.seek(0) - return Response( - content=zip_buffer.getvalue(), - media_type="application/zip", - headers={"Content-Disposition": f"attachment; filename={zip_filename}"}, - ) - - -async def fetch_user_email_common(filename: str, mode: str = "geminicli") -> JSONResponse: - """获取指定凭证文件用户邮箱的通用函数""" - mode = validate_mode(mode) - - filename_only = os.path.basename(filename) - if not filename_only.endswith(".json"): - raise HTTPException(status_code=404, detail="无效的文件名") - - storage_adapter = await get_storage_adapter() - credential_data = await storage_adapter.get_credential(filename_only, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="凭证文件不存在") - - email = await credential_manager.get_or_fetch_user_email(filename_only, mode=mode) - - if email: - return JSONResponse( - content={ - "filename": filename_only, - "user_email": email, - "message": "成功获取用户邮箱", - } - ) - else: - return JSONResponse( - content={ - "filename": filename_only, - "user_email": None, - "message": "无法获取用户邮箱,可能凭证已过期或权限不足", - }, - status_code=400, - ) - - -async def refresh_all_user_emails_common(mode: str = "geminicli") -> JSONResponse: - """刷新所有凭证文件用户邮箱的通用函数 - 只为没有邮箱的凭证获取 - - 利用 get_all_credential_states 批量获取状态 - """ - mode = validate_mode(mode) - - storage_adapter = await get_storage_adapter() - - # 一次性批量获取所有凭证的状态 - all_states = await storage_adapter.get_all_credential_states(mode=mode) - - results = [] - success_count = 0 - skipped_count = 0 - - # 在内存中筛选出需要获取邮箱的凭证 - for filename, state in all_states.items(): - try: - cached_email = state.get("user_email") - - if cached_email: - # 已有邮箱,跳过获取 - skipped_count += 1 - results.append({ - "filename": os.path.basename(filename), - "user_email": cached_email, - "success": True, - "skipped": True, - }) - continue - - # 没有邮箱,尝试获取 - email = await credential_manager.get_or_fetch_user_email(filename, mode=mode) - if email: - success_count += 1 - results.append({ - "filename": os.path.basename(filename), - "user_email": email, - "success": True, - }) - else: - results.append({ - "filename": os.path.basename(filename), - "user_email": None, - "success": False, - "error": "无法获取邮箱", - }) - except Exception as e: - results.append({ - "filename": os.path.basename(filename), - "user_email": None, - "success": False, - "error": str(e), - }) - - total_count = len(all_states) - return JSONResponse( - content={ - "success_count": success_count, - "total_count": total_count, - "skipped_count": skipped_count, - "results": results, - "message": f"成功获取 {success_count}/{total_count} 个邮箱地址,跳过 {skipped_count} 个已有邮箱的凭证", - } - ) - - -async def deduplicate_credentials_by_email_common(mode: str = "geminicli") -> JSONResponse: - """批量去重凭证文件的通用函数 - 删除邮箱相同的凭证(只保留一个)""" - mode = validate_mode(mode) - storage_adapter = await get_storage_adapter() - - try: - duplicate_info = await storage_adapter._backend.get_duplicate_credentials_by_email( - mode=mode - ) - - duplicate_groups = duplicate_info.get("duplicate_groups", []) - no_email_files = duplicate_info.get("no_email_files", []) - total_count = duplicate_info.get("total_count", 0) - - if not duplicate_groups: - return JSONResponse( - content={ - "deleted_count": 0, - "kept_count": total_count, - "total_count": total_count, - "unique_emails_count": duplicate_info.get("unique_email_count", 0), - "no_email_count": len(no_email_files), - "duplicate_groups": [], - "delete_errors": [], - "message": "没有发现重复的凭证(相同邮箱)", - } - ) - - # 执行删除操作 - deleted_count = 0 - delete_errors = [] - result_duplicate_groups = [] - - for group in duplicate_groups: - email = group["email"] - kept_file = group["kept_file"] - duplicate_files = group["duplicate_files"] - - deleted_files_in_group = [] - for filename in duplicate_files: - try: - success = await credential_manager.remove_credential(filename, mode=mode) - if success: - deleted_count += 1 - deleted_files_in_group.append(os.path.basename(filename)) - log.info(f"去重删除凭证: {filename} (邮箱: {email}) (mode={mode})") - else: - delete_errors.append(f"{os.path.basename(filename)}: 删除失败") - except Exception as e: - delete_errors.append(f"{os.path.basename(filename)}: {str(e)}") - log.error(f"去重删除凭证 {filename} 时出错: {e}") - - result_duplicate_groups.append({ - "email": email, - "kept_file": os.path.basename(kept_file), - "deleted_files": deleted_files_in_group, - "duplicate_count": len(deleted_files_in_group), - }) - - kept_count = total_count - deleted_count - - return JSONResponse( - content={ - "deleted_count": deleted_count, - "kept_count": kept_count, - "total_count": total_count, - "unique_emails_count": duplicate_info.get("unique_email_count", 0), - "no_email_count": len(no_email_files), - "duplicate_groups": result_duplicate_groups, - "delete_errors": delete_errors, - "message": f"去重完成:删除 {deleted_count} 个重复凭证,保留 {kept_count} 个凭证({duplicate_info.get('unique_email_count', 0)} 个唯一邮箱)", - } - ) - - except Exception as e: - log.error(f"批量去重凭证时出错: {e}") - return JSONResponse( - status_code=500, - content={ - "deleted_count": 0, - "kept_count": 0, - "total_count": 0, - "message": f"去重操作失败: {str(e)}", - } - ) - - -async def verify_credential_project_common(filename: str, mode: str = "geminicli") -> JSONResponse: - """验证并重新获取凭证的project id的通用函数""" - mode = validate_mode(mode) - - # 验证文件名 - if not filename.endswith(".json"): - raise HTTPException(status_code=400, detail="无效的文件名") - - - storage_adapter = await get_storage_adapter() - - # 获取凭证数据 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="凭证不存在") - - # 创建凭证对象 - credentials = Credentials.from_dict(credential_data) - - # 确保token有效(自动刷新) - token_refreshed = await credentials.refresh_if_needed() - - # 如果token被刷新了,更新存储 - if token_refreshed: - log.info(f"Token已自动刷新: {filename} (mode={mode})") - credential_data = credentials.to_dict() - await storage_adapter.store_credential(filename, credential_data, mode=mode) - - # 获取API端点和对应的User-Agent - if mode == "antigravity": - api_base_url = await get_antigravity_api_url() - user_agent = ANTIGRAVITY_USER_AGENT - else: - api_base_url = await get_code_assist_endpoint() - user_agent = GEMINICLI_USER_AGENT - - # 重新获取project id(仅 antigravity 模式请求积分) - if mode == "antigravity": - project_id, subscription_tier, credit_amount = await fetch_project_id_and_tier( - access_token=credentials.access_token, - user_agent=user_agent, - api_base_url=api_base_url, - include_credits=True, - ) - else: - project_id, subscription_tier = await fetch_project_id_and_tier( - access_token=credentials.access_token, - user_agent=user_agent, - api_base_url=api_base_url, - ) - credit_amount = None - - if project_id: - credential_data["project_id"] = project_id - - if project_id or subscription_tier: - await storage_adapter.store_credential(filename, credential_data, mode=mode) - - # 检验成功后自动解除禁用状态并清除错误码 - state_update = { - "disabled": False, - "error_codes": [] - } - - # 同步更新状态表中的 tier 字段 - state_update["tier"] = subscription_tier - - # 如果是 geminicli 模式,直接设置 preview=True - if mode == "geminicli": - state_update["preview"] = True - - await storage_adapter.update_credential_state(filename, state_update, mode=mode) - - log.info(f"检验 {mode} 凭证成功: {filename} - Project ID: {project_id}, Tier: {subscription_tier} - 已解除禁用并清除错误码") - - response_data = { - "success": True, - "filename": filename, - "project_id": project_id, - "subscription_tier": subscription_tier, - "message": "检验成功!Project ID已更新,已解除禁用状态并清除错误码,403错误应该已恢复" - } - - if mode == "antigravity" and credit_amount is not None: - response_data["credit_amount"] = credit_amount - - return JSONResponse(content=response_data) - else: - return JSONResponse( - status_code=400, - content={ - "success": False, - "filename": filename, - "message": "检验失败:无法获取Project ID,请检查凭证是否有效" - } - ) - - -# ============================================================================= -# 路由处理函数 (Route Handlers) -# ============================================================================= - - -@router.post("/upload") -async def upload_credentials( - files: List[UploadFile] = File(...), - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """批量上传凭证文件""" - try: - mode = validate_mode(mode) - return await upload_credentials_common(files, mode=mode) - except HTTPException: - raise - except Exception as e: - log.error(f"批量上传失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/status") -async def get_creds_status( - token: str = Depends(verify_panel_token), - offset: int = 0, - limit: int = 50, - status_filter: str = "all", - error_code_filter: str = "all", - cooldown_filter: str = "all", - preview_filter: str = "all", - tier_filter: str = "all", - mode: str = "geminicli" -): - """ - 获取凭证文件的状态(轻量级摘要,不包含完整凭证数据,支持分页和状态筛选) - - Args: - offset: 跳过的记录数(默认0) - limit: 每页返回的记录数(默认50,可选:20, 50, 100, 200, 500, 1000) - status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用) - error_code_filter: 错误码筛选(all=全部, 或具体错误码如"400", "403") - cooldown_filter: 冷却状态筛选(all=全部, in_cooldown=冷却中, no_cooldown=未冷却) - preview_filter: Preview筛选(all=全部, preview=支持preview, no_preview=不支持preview,仅geminicli模式有效) - tier_filter: tier筛选(all=全部, free/pro/ultra) - mode: 凭证模式(geminicli 或 antigravity) - - Returns: - 包含凭证列表、总数、分页信息的响应 - """ - try: - mode = validate_mode(mode) - return await get_creds_status_common( - offset, limit, status_filter, mode=mode, - error_code_filter=error_code_filter, - cooldown_filter=cooldown_filter, - preview_filter=preview_filter, - tier_filter=tier_filter - ) - except HTTPException: - raise - except Exception as e: - log.error(f"获取凭证状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/detail/{filename}") -async def get_cred_detail( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """ - 按需获取单个凭证的详细数据(包含完整凭证内容) - 用于用户查看/编辑凭证详情 - """ - try: - mode = validate_mode(mode) - # 验证文件名 - if not filename.endswith(".json"): - raise HTTPException(status_code=400, detail="无效的文件名") - - - - storage_adapter = await get_storage_adapter() - backend_info = await storage_adapter.get_backend_info() - backend_type = backend_info.get("backend_type", "unknown") - - # 获取凭证数据 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="凭证不存在") - - # 获取状态信息 - file_status = await storage_adapter.get_credential_state(filename, mode=mode) - if not file_status: - file_status = { - "error_codes": [], - "disabled": False, - "last_success": time.time(), - "user_email": None, - } - - result = { - "status": file_status, - "content": credential_data, - "filename": os.path.basename(filename), - "backend_type": backend_type, - "user_email": file_status.get("user_email"), - "model_cooldowns": file_status.get("model_cooldowns", {}), - } - - if mode == "geminicli": - result["preview"] = file_status.get("preview", True) - else: - result["enable_credit"] = file_status.get("enable_credit", False) - - if backend_type == "file" and os.path.exists(filename): - result.update({ - "size": os.path.getsize(filename), - "modified_time": os.path.getmtime(filename), - }) - - return JSONResponse(content=result) - - except HTTPException: - raise - except Exception as e: - log.error(f"获取凭证详情失败 {filename}: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/action") -async def creds_action( - request: CredFileActionRequest, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """对凭证文件执行操作(启用/禁用/删除/enable_credit开关)""" - try: - mode = validate_mode(mode) - - log.info(f"Received request: {request}") - - filename = request.filename - action = request.action - - log.info(f"Performing action '{action}' on file: {filename} (mode={mode})") - - # 验证文件名 - if not filename.endswith(".json"): - log.error(f"无效的文件名: {filename}(不是.json文件)") - raise HTTPException(status_code=400, detail=f"无效的文件名: {filename}") - - # 获取存储适配器 - storage_adapter = await get_storage_adapter() - - # 对于删除操作,不需要检查凭证数据是否完整,只需检查条目是否存在 - # 对于其他操作,需要确保凭证数据存在且完整 - if action != "delete": - # 检查凭证数据是否存在 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - log.error(f"凭证未找到: {filename} (mode={mode})") - raise HTTPException(status_code=404, detail="凭证文件不存在") - - if action == "enable": - log.info(f"Web请求: 启用文件 {filename} (mode={mode})") - result = await credential_manager.set_cred_disabled(filename, False, mode=mode) - log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}") - if result: - log.info(f"Web请求: 文件 {filename} 已成功启用 (mode={mode})") - return JSONResponse(content={"message": f"已启用凭证文件 {os.path.basename(filename)}"}) - else: - log.error(f"Web请求: 文件 {filename} 启用失败 (mode={mode})") - raise HTTPException(status_code=500, detail="启用凭证失败,可能凭证不存在") - - elif action == "disable": - log.info(f"Web请求: 禁用文件 {filename} (mode={mode})") - result = await credential_manager.set_cred_disabled(filename, True, mode=mode) - log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}") - if result: - log.info(f"Web请求: 文件 {filename} 已成功禁用 (mode={mode})") - return JSONResponse(content={"message": f"已禁用凭证文件 {os.path.basename(filename)}"}) - else: - log.error(f"Web请求: 文件 {filename} 禁用失败 (mode={mode})") - raise HTTPException(status_code=500, detail="禁用凭证失败,可能凭证不存在") - - elif action == "delete": - try: - # 使用 CredentialManager 删除凭证(包含队列/状态同步) - success = await credential_manager.remove_credential(filename, mode=mode) - if success: - log.info(f"通过管理器成功删除凭证: {filename} (mode={mode})") - return JSONResponse( - content={"message": f"已删除凭证文件 {os.path.basename(filename)}"} - ) - else: - raise HTTPException(status_code=500, detail="删除凭证失败") - except Exception as e: - log.error(f"删除凭证 {filename} 时出错: {e}") - raise HTTPException(status_code=500, detail=f"删除文件失败: {str(e)}") - - elif action == "enable_credit": - if mode != "antigravity": - raise HTTPException(status_code=400, detail="enable_credit 仅支持 antigravity 模式") - updated = await storage_adapter.update_credential_state( - filename, {"enable_credit": True}, mode=mode - ) - if updated: - await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode) - return JSONResponse(content={"message": f"已开启凭证信用额度模式 {os.path.basename(filename)}"}) - raise HTTPException(status_code=500, detail="开启信用额度模式失败,可能凭证不存在") - - elif action == "disable_credit": - if mode != "antigravity": - raise HTTPException(status_code=400, detail="disable_credit 仅支持 antigravity 模式") - updated = await storage_adapter.update_credential_state( - filename, {"enable_credit": False}, mode=mode - ) - if updated: - await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode) - return JSONResponse(content={"message": f"已关闭凭证信用额度模式 {os.path.basename(filename)}"}) - raise HTTPException(status_code=500, detail="关闭信用额度模式失败,可能凭证不存在") - - else: - raise HTTPException(status_code=400, detail="无效的操作类型") - - except HTTPException: - raise - except Exception as e: - log.error(f"凭证文件操作失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/batch-action") -async def creds_batch_action( - request: CredFileBatchActionRequest, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """批量对凭证文件执行操作(启用/禁用/删除/enable_credit开关)""" - try: - mode = validate_mode(mode) - - action = request.action - filenames = request.filenames - - if not filenames: - raise HTTPException(status_code=400, detail="文件名列表不能为空") - - log.info(f"对 {len(filenames)} 个文件执行批量操作 '{action}'") - - success_count = 0 - errors = [] - - storage_adapter = await get_storage_adapter() - - for filename in filenames: - try: - # 验证文件名安全性 - if not filename.endswith(".json"): - errors.append(f"{filename}: 无效的文件类型") - continue - - # 对于删除操作,不需要检查凭证数据完整性 - # 对于其他操作,需要确保凭证数据存在 - if action != "delete": - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - errors.append(f"{filename}: 凭证不存在") - continue - - # 执行相应操作 - if action == "enable": - await credential_manager.set_cred_disabled(filename, False, mode=mode) - success_count += 1 - - elif action == "disable": - await credential_manager.set_cred_disabled(filename, True, mode=mode) - success_count += 1 - - elif action == "delete": - try: - delete_success = await credential_manager.remove_credential(filename, mode=mode) - if delete_success: - success_count += 1 - log.info(f"成功删除批量中的凭证: {filename}") - else: - errors.append(f"{filename}: 删除失败") - continue - except Exception as e: - errors.append(f"{filename}: 删除文件失败 - {str(e)}") - continue - elif action == "enable_credit": - if mode != "antigravity": - errors.append(f"{filename}: enable_credit 仅支持 antigravity 模式") - continue - updated = await storage_adapter.update_credential_state( - filename, {"enable_credit": True}, mode=mode - ) - if updated: - await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode) - success_count += 1 - else: - errors.append(f"{filename}: 开启信用额度模式失败") - continue - elif action == "disable_credit": - if mode != "antigravity": - errors.append(f"{filename}: disable_credit 仅支持 antigravity 模式") - continue - updated = await storage_adapter.update_credential_state( - filename, {"enable_credit": False}, mode=mode - ) - if updated: - await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode) - success_count += 1 - else: - errors.append(f"{filename}: 关闭信用额度模式失败") - continue - else: - errors.append(f"{filename}: 无效的操作类型") - continue - - except Exception as e: - log.error(f"处理 {filename} 时出错: {e}") - errors.append(f"{filename}: 处理失败 - {str(e)}") - continue - - # 构建返回消息 - result_message = f"批量操作完成:成功处理 {success_count}/{len(filenames)} 个文件" - if errors: - result_message += "\n错误详情:\n" + "\n".join(errors) - - response_data = { - "success_count": success_count, - "total_count": len(filenames), - "errors": errors, - "message": result_message, - } - - return JSONResponse(content=response_data) - - except HTTPException: - raise - except Exception as e: - log.error(f"批量凭证文件操作失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/download/{filename}") -async def download_cred_file( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """下载单个凭证文件""" - try: - mode = validate_mode(mode) - # 验证文件名安全性 - if not filename.endswith(".json"): - raise HTTPException(status_code=404, detail="无效的文件名") - - # 获取存储适配器 - storage_adapter = await get_storage_adapter() - - # 从存储系统获取凭证数据 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="文件不存在") - - # 转换为JSON字符串 - content = json.dumps(credential_data, ensure_ascii=False, indent=2) - - from fastapi.responses import Response - - return Response( - content=content, - media_type="application/json", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) - - except HTTPException: - raise - except Exception as e: - log.error(f"下载凭证文件失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/fetch-email/{filename}") -async def fetch_user_email( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """获取指定凭证文件的用户邮箱地址""" - try: - mode = validate_mode(mode) - return await fetch_user_email_common(filename, mode=mode) - except HTTPException: - raise - except Exception as e: - log.error(f"获取用户邮箱失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/refresh-all-emails") -async def refresh_all_user_emails( - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """刷新所有凭证文件的用户邮箱地址""" - try: - mode = validate_mode(mode) - return await refresh_all_user_emails_common(mode=mode) - except Exception as e: - log.error(f"批量获取用户邮箱失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/deduplicate-by-email") -async def deduplicate_credentials_by_email( - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """批量去重凭证文件 - 删除邮箱相同的凭证(只保留一个)""" - try: - mode = validate_mode(mode) - return await deduplicate_credentials_by_email_common(mode=mode) - except Exception as e: - log.error(f"批量去重凭证失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/download-all") -async def download_all_creds( - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """ - 打包下载所有凭证文件(流式处理,按需加载每个凭证数据) - 只在实际下载时才加载完整凭证内容,最大化性能 - """ - try: - mode = validate_mode(mode) - return await download_all_creds_common(mode=mode) - except HTTPException: - raise - except Exception as e: - log.error(f"打包下载失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/verify-project/{filename}") -async def verify_credential_project( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """ - 检验凭证的project id,重新获取project id - 检验成功可以使403错误恢复 - """ - try: - mode = validate_mode(mode) - return await verify_credential_project_common(filename, mode=mode) - except HTTPException: - raise - except Exception as e: - log.error(f"检验凭证Project ID失败 {filename}: {e}") - raise HTTPException(status_code=500, detail=f"检验失败: {str(e)}") - - -@router.get("/errors/{filename}") -async def get_credential_errors( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """ - 获取指定凭证的错误信息(包含 error_codes 和 error_messages) - - Args: - filename: 凭证文件名 - mode: 凭证模式(geminicli 或 antigravity) - - Returns: - 包含 error_codes 和 error_messages 的 JSON 响应 - """ - try: - mode = validate_mode(mode) - - # 验证文件名 - if not filename.endswith(".json"): - raise HTTPException(status_code=400, detail="无效的文件名") - - storage_adapter = await get_storage_adapter() - - # 检查后端是否支持 get_credential_errors 方法 - if not hasattr(storage_adapter._backend, 'get_credential_errors'): - raise HTTPException( - status_code=501, - detail="当前存储后端不支持获取错误信息" - ) - - # 获取错误信息 - error_info = await storage_adapter._backend.get_credential_errors(filename, mode=mode) - - return JSONResponse(content=error_info) - - except HTTPException: - raise - except Exception as e: - log.error(f"获取凭证错误信息失败 {filename}: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/quota/{filename}") -async def get_credential_quota( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "antigravity" -): - """ - 获取指定凭证的额度信息(仅支持 antigravity 模式) - """ - try: - mode = validate_mode(mode) - # 验证文件名 - if not filename.endswith(".json"): - raise HTTPException(status_code=400, detail="无效的文件名") - - - storage_adapter = await get_storage_adapter() - - # 获取凭证数据 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="凭证不存在") - - # 使用 Credentials 对象自动处理 token 刷新 - from src.google_oauth_api import Credentials - - creds = Credentials.from_dict(credential_data) - - # 自动刷新 token(如果需要) - await creds.refresh_if_needed() - - # 如果 token 被刷新了,更新存储 - updated_data = creds.to_dict() - if updated_data != credential_data: - log.info(f"Token已自动刷新: {filename}") - await storage_adapter.store_credential(filename, updated_data, mode=mode) - credential_data = updated_data - - # 获取访问令牌 - access_token = credential_data.get("access_token") or credential_data.get("token") - if not access_token: - raise HTTPException(status_code=400, detail="凭证中没有访问令牌") - - # 获取额度信息 - quota_info = await fetch_quota_info(access_token) - - if quota_info.get("success"): - return JSONResponse(content={ - "success": True, - "filename": filename, - "models": quota_info.get("models", {}) - }) - else: - return JSONResponse( - status_code=400, - content={ - "success": False, - "filename": filename, - "error": quota_info.get("error", "未知错误") - } - ) - - except HTTPException: - raise - except Exception as e: - log.error(f"获取凭证额度失败 {filename}: {e}") - raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}") - - -@router.post("/configure-preview/{filename}") -async def configure_preview_channel( - filename: str, - token: str = Depends(verify_panel_token), - mode: str = "geminicli" -): - """ - 为 geminicli 凭证配置 preview 通道 - - 通过调用 Google Cloud API 设置 release_channel 为 EXPERIMENTAL - - Args: - filename: 凭证文件名 - mode: 凭证模式(仅支持 geminicli) - - Returns: - 配置结果信息 - """ - try: - mode = validate_mode(mode) - - # 只支持 geminicli 模式 - if mode != "geminicli": - raise HTTPException( - status_code=400, - detail="配置 preview 通道仅支持 geminicli 模式" - ) - - # 验证文件名 - if not filename.endswith(".json"): - raise HTTPException(status_code=400, detail="无效的文件名") - - storage_adapter = await get_storage_adapter() - - # 获取凭证数据 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="凭证不存在") - - # 创建凭证对象并刷新 token(如果需要) - credentials = Credentials.from_dict(credential_data) - token_refreshed = await credentials.refresh_if_needed() - - if token_refreshed: - log.info(f"Token已自动刷新: {filename}") - credential_data = credentials.to_dict() - await storage_adapter.store_credential(filename, credential_data, mode=mode) - - # 获取 access_token 和 project_id - access_token = credential_data.get("access_token") or credential_data.get("token") - project_id = credential_data.get("project_id", "") - - if not access_token: - raise HTTPException(status_code=400, detail="凭证中没有访问令牌") - if not project_id: - raise HTTPException(status_code=400, detail="凭证中没有项目ID") - - # 调用 Google Cloud API 配置 preview 通道 - # 根据文档,需要两个步骤: - # 1. 创建 Release Channel Setting (EXPERIMENTAL) - # 2. 创建 Setting Binding (绑定到目标项目) - from src.httpx_client import post_async - import uuid - - # 生成唯一的 ID - setting_id = f"preview-setting-{uuid.uuid4().hex[:8]}" - binding_id = f"preview-binding-{uuid.uuid4().hex[:8]}" - - base_url = f"https://cloudaicompanion.googleapis.com/v1/projects/{project_id}/locations/global" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json" - } - - log.info(f"开始配置 preview 通道: {filename} (project_id={project_id})") - - # 步骤 1: 创建 Release Channel Setting - setting_url = f"{base_url}/releaseChannelSettings" - setting_response = await post_async( - url=setting_url, - json={"release_channel": "EXPERIMENTAL"}, - headers=headers, - params={"release_channel_setting_id": setting_id}, - timeout=30.0 - ) - - setting_status = setting_response.status_code - - if setting_status == 200 or setting_status == 201: - log.info(f"步骤 1/2: Release Channel Setting 创建成功 (setting_id={setting_id})") - elif setting_status == 409: - # Setting 已存在,继续下一步 - log.info(f"步骤 1/2: Release Channel Setting 已存在") - else: - # 步骤 1 失败 - error_text = setting_response.text if hasattr(setting_response, 'text') else "" - log.error(f"步骤 1/2 失败: {filename} - Status: {setting_status}, Error: {error_text}") - - return JSONResponse( - status_code=setting_status, - content={ - "success": False, - "filename": filename, - "preview": False, - "message": f"创建 Release Channel Setting 失败: HTTP {setting_status}", - "error": error_text, - "step": "create_setting" - } - ) - - # 步骤 2: 创建 Setting Binding (绑定到当前项目) - binding_url = f"{base_url}/releaseChannelSettings/{setting_id}/settingBindings" - binding_response = await post_async( - url=binding_url, - json={ - "target": f"projects/{project_id}", - "product": "GEMINI_CODE_ASSIST" - }, - headers=headers, - params={"setting_binding_id": binding_id}, - timeout=30.0 - ) - - binding_status = binding_response.status_code - - if binding_status == 200 or binding_status == 201: - await storage_adapter.update_credential_state(filename, { - "preview": True - }, mode=mode) - - log.info(f"步骤 2/2: Setting Binding 创建成功 - Preview 通道配置完成: {filename}") - - return JSONResponse(content={ - "success": True, - "filename": filename, - "preview": True, - "message": "Preview 通道配置成功,已将 preview 属性设置为 true", - "setting_id": setting_id, - "binding_id": binding_id - }) - elif binding_status == 409: - # Binding 已存在,说明已经配置过了 - await storage_adapter.update_credential_state(filename, { - "preview": True - }, mode=mode) - - log.info(f"步骤 2/2: Setting Binding 已存在 - Preview 通道已配置: {filename}") - - return JSONResponse(content={ - "success": True, - "filename": filename, - "preview": True, - "message": "Preview 通道配置已存在,已将 preview 属性设置为 true" - }) - else: - # 步骤 2 失败 - error_text = binding_response.text if hasattr(binding_response, 'text') else "" - log.error(f"步骤 2/2 失败: {filename} - Status: {binding_status}, Error: {error_text}") - - return JSONResponse( - status_code=binding_status, - content={ - "success": False, - "filename": filename, - "preview": False, - "message": f"创建 Setting Binding 失败: HTTP {binding_status}", - "error": error_text, - "step": "create_binding" - } - ) - - except HTTPException: - raise - except Exception as e: - log.error(f"配置 preview 通道失败 {filename}: {e}") - raise HTTPException(status_code=500, detail=f"配置失败: {str(e)}") - - -@router.post("/test/{filename}") -async def test_credential( - filename: str, - mode: str = "geminicli", - _token: str = Depends(verify_panel_token) -): - """ - 测试指定凭证是否可用 - - Args: - filename: 凭证文件名 - mode: 凭证模式(geminicli 或 antigravity) - - Returns: - 返回状态码: - - 200: 凭证可用 - - 429: 凭证被限流但有效 - - 其他: 凭证失败(返回实际错误码) - """ - try: - mode = validate_mode(mode) - - # 验证文件名 - if not filename.endswith(".json"): - raise HTTPException(status_code=400, detail="无效的文件名") - - storage_adapter = await get_storage_adapter() - - # 获取凭证数据 - credential_data = await storage_adapter.get_credential(filename, mode=mode) - if not credential_data: - raise HTTPException(status_code=404, detail="凭证不存在") - - # 创建凭证对象并尝试刷新 token(如果需要) - credentials = Credentials.from_dict(credential_data) - token_refreshed = await credentials.refresh_if_needed() - - # 如果 token 被刷新了,更新存储 - if token_refreshed: - log.info(f"Token已自动刷新: {filename} (mode={mode})") - credential_data = credentials.to_dict() - await storage_adapter.store_credential(filename, credential_data, mode=mode) - - # 获取访问令牌 - access_token = credential_data.get("access_token") or credential_data.get("token") - if not access_token: - raise HTTPException(status_code=400, detail="凭证中没有访问令牌") - - # 根据模式构造测试请求 - from src.httpx_client import post_async - - # 获取 project_id - project_id = credential_data.get("project_id", "") - if not project_id: - raise HTTPException(status_code=400, detail="凭证中没有项目ID") - - # 根据模式选择 API 端点和请求头 - # 对于 geminicli 模式,使用两次测试:gemini-2.5-flash 和 gemini-3-flash-preview - # 对于 antigravity 模式,只使用 gemini-2.5-flash - test_model = "gemini-2.5-flash" - - if mode == "antigravity": - api_base_url = await get_antigravity_api_url() - from src.api.antigravity import build_antigravity_headers - headers = build_antigravity_headers(access_token, test_model) - else: - api_base_url = await get_code_assist_endpoint() - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "User-Agent": GEMINICLI_USER_AGENT, - } - - # 第一次测试:使用 gemini-2.5-flash - response = await post_async( - url=f"{api_base_url}/v1internal:generateContent", - json={ - "model": test_model, - "project": project_id, - "request": { - "contents": [{"role": "user", "parts": [{"text": "hi"}]}], - "generationConfig": {"maxOutputTokens": 1} - } - }, - headers=headers, - timeout=30.0 - ) - - # 返回实际的状态码和详细信息 - status_code = response.status_code - - if status_code == 200 or status_code == 429: - log.info(f"凭证测试成功: {filename} (mode={mode}, model={test_model}, status={status_code})") - # 测试成功时清除错误状态 - if status_code == 200: - await storage_adapter.update_credential_state(filename, { - "error_codes": [], - "error_messages": {} - }, mode=mode) - - # 如果是 geminicli 模式且第一次测试成功,继续测试 gemini-3-flash-preview - if mode == "geminicli": - preview_model = "gemini-3-flash-preview" - log.info(f"开始测试 preview 模型: {filename} (model={preview_model})") - - try: - preview_response = await post_async( - url=f"{api_base_url}/v1internal:generateContent", - json={ - "model": preview_model, - "project": project_id, - "request": { - "contents": [{"role": "user", "parts": [{"text": "hi"}]}], - "generationConfig": {"maxOutputTokens": 1} - } - }, - headers=headers, - timeout=30.0 - ) - - preview_status = preview_response.status_code - - if preview_status == 200 or preview_status == 429: - # preview 模型测试成功,设置 preview=True - log.info(f"Preview 模型测试成功: {filename} (status={preview_status})") - await storage_adapter.update_credential_state(filename, { - "preview": True - }, mode=mode) - elif preview_status == 404: - # preview 模型返回 404,说明不支持,设置 preview=False - log.warning(f"Preview 模型不支持: {filename} (status=404)") - await storage_adapter.update_credential_state(filename, { - "preview": False - }, mode=mode) - else: - # 其他错误,保持默认 preview 状态 - log.warning(f"Preview 模型测试失败: {filename} (status={preview_status})") - except Exception as e: - log.error(f"Preview 模型测试异常: {filename} - {e}") - - # 返回成功响应 - return JSONResponse( - status_code=status_code, - content={ - "success": True, - "status_code": status_code, - "message": "测试成功", - "filename": filename - } - ) - else: - log.warning(f"凭证测试失败: {filename} (mode={mode}, status={status_code})") - # 测试失败时保存错误码和错误消息(覆盖模式,只保存最新的一个错误) - try: - error_text = response.text if hasattr(response, 'text') else "" - - # 打印详细错误内容到日志 - log.error(f"凭证测试错误详情 - 文件: {filename}, 模式: {mode}, 状态码: {status_code}, 错误内容: {error_text}") - - # 使用覆盖模式保存错误(与 credential_manager 保持一致) - error_codes = [status_code] - error_messages = {str(status_code): error_text if error_text else f"HTTP {status_code}"} - - # 更新状态 - await storage_adapter.update_credential_state(filename, { - "error_codes": error_codes, - "error_messages": error_messages - }, mode=mode) - - log.info(f"已保存测试错误信息: {filename} - 错误码 {status_code}") - except Exception as e: - log.error(f"保存测试错误信息失败: {e}") - - # 返回错误响应,包含完整的错误信息 - error_text = response.text if hasattr(response, 'text') else "" - - return JSONResponse( - status_code=status_code, - content={ - "success": False, - "status_code": status_code, - "message": f"测试失败: HTTP {status_code}", - "error": error_text, - "filename": filename - } - ) - - except HTTPException: - raise - except Exception as e: - log.error(f"测试凭证失败 {filename}: {e}") - raise HTTPException(status_code=500, detail=f"测试失败: {str(e)}") diff --git a/src/panel/logs.py b/src/panel/logs.py deleted file mode 100644 index 4dcf1d1223a40c80fcf5a746cbd93f61434c08f3..0000000000000000000000000000000000000000 --- a/src/panel/logs.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -日志路由模块 - 处理 /logs/* 相关的HTTP请求和WebSocket连接 -""" - -import asyncio -import datetime -import os - -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, JSONResponse -from starlette.websockets import WebSocketState - -import config -from log import log -from src.utils import verify_panel_token -from .utils import ConnectionManager - - -# 创建路由器 -router = APIRouter(prefix="/logs", tags=["logs"]) - -# WebSocket连接管理器 -manager = ConnectionManager() - - -@router.post("/clear") -async def clear_logs(token: str = Depends(verify_panel_token)): - """清空日志文件""" - try: - # 直接使用环境变量获取日志文件路径 - log_file_path = os.getenv("LOG_FILE", "log.txt") - - # 检查日志文件是否存在 - if os.path.exists(log_file_path): - try: - # 清空文件内容(保留文件),确保以UTF-8编码写入 - # 使用 with 确保文件正确关闭 - with open(log_file_path, "w", encoding="utf-8") as f: - f.write("") - f.flush() # 强制刷新到磁盘 - # with 退出时会自动关闭文件 - log.info(f"日志文件已清空: {log_file_path}") - - # 通知所有WebSocket连接日志已清空 - await manager.broadcast("--- 日志文件已清空 ---") - - return JSONResponse( - content={"message": f"日志文件已清空: {os.path.basename(log_file_path)}"} - ) - except Exception as e: - log.error(f"清空日志文件失败: {e}") - raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}") - else: - return JSONResponse(content={"message": "日志文件不存在"}) - - except Exception as e: - log.error(f"清空日志文件失败: {e}") - raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}") - - -@router.get("/download") -async def download_logs(token: str = Depends(verify_panel_token)): - """下载日志文件""" - try: - # 直接使用环境变量获取日志文件路径 - log_file_path = os.getenv("LOG_FILE", "log.txt") - - # 检查日志文件是否存在 - if not os.path.exists(log_file_path): - raise HTTPException(status_code=404, detail="日志文件不存在") - - # 检查文件是否为空 - file_size = os.path.getsize(log_file_path) - if file_size == 0: - raise HTTPException(status_code=404, detail="日志文件为空") - - # 生成文件名(包含时间戳) - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"gcli2api_logs_{timestamp}.txt" - - log.info(f"下载日志文件: {log_file_path}") - - return FileResponse( - path=log_file_path, - filename=filename, - media_type="text/plain", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) - - except HTTPException: - raise - except Exception as e: - log.error(f"下载日志文件失败: {e}") - raise HTTPException(status_code=500, detail=f"下载日志文件失败: {str(e)}") - - -@router.websocket("/stream") -async def websocket_logs(websocket: WebSocket): - """WebSocket端点,用于实时日志流""" - # WebSocket 认证: 从查询参数获取 token - token = websocket.query_params.get("token") - - if not token: - await websocket.close(code=403, reason="Missing authentication token") - log.warning("WebSocket连接被拒绝: 缺少认证token") - return - - # 验证 token - try: - panel_password = await config.get_panel_password() - if token != panel_password: - await websocket.close(code=403, reason="Invalid authentication token") - log.warning("WebSocket连接被拒绝: token验证失败") - return - except Exception as e: - await websocket.close(code=1011, reason="Authentication error") - log.error(f"WebSocket认证过程出错: {e}") - return - - # 检查连接数限制 - if not await manager.connect(websocket): - return - - try: - # 直接使用环境变量获取日志文件路径 - log_file_path = os.getenv("LOG_FILE", "log.txt") - - # 发送初始日志(限制为最后50行,减少内存占用) - if os.path.exists(log_file_path): - try: - # 使用 with 确保文件正确关闭 - with open(log_file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - # 只发送最后50行,减少初始内存消耗 - for line in lines[-50:]: - if line.strip(): - await websocket.send_text(line.strip()) - except Exception as e: - await websocket.send_text(f"Error reading log file: {e}") - log.error(f"WebSocket初始日志读取错误: {e}") - - # 监控日志文件变化 - last_size = os.path.getsize(log_file_path) if os.path.exists(log_file_path) else 0 - max_read_size = 8192 # 限制单次读取大小为8KB,防止大量日志造成内存激增 - check_interval = 2 # 增加检查间隔,减少CPU和I/O开销 - - # 创建后台任务监听客户端断开 - # 即使没有日志更新,receive_text() 也能即时感知断开 - async def listen_for_disconnect(): - try: - while True: - await websocket.receive_text() - except Exception: - pass - - listener_task = asyncio.create_task(listen_for_disconnect()) - - try: - while websocket.client_state == WebSocketState.CONNECTED: - # 使用 asyncio.wait 同时等待定时器和断开信号 - # timeout=check_interval 替代了 asyncio.sleep - done, pending = await asyncio.wait( - [listener_task], - timeout=check_interval, - return_when=asyncio.FIRST_COMPLETED - ) - - # 如果监听任务结束(通常是因为连接断开),则退出循环 - if listener_task in done: - break - - if os.path.exists(log_file_path): - current_size = os.path.getsize(log_file_path) - if current_size > last_size: - # 限制读取大小,防止单次读取过多内容 - read_size = min(current_size - last_size, max_read_size) - - try: - # 使用 with 确保文件正确关闭,即使发生异常 - with open(log_file_path, "r", encoding="utf-8", errors="replace") as f: - f.seek(last_size) - new_content = f.read(read_size) - # with 退出时自动关闭文件句柄 - - # 处理编码错误的情况 - if not new_content: - last_size = current_size - continue - - # 分行发送,避免发送不完整的行 - lines = new_content.splitlines(keepends=True) - if lines: - # 如果最后一行没有换行符,保留到下次处理 - if not lines[-1].endswith("\n") and len(lines) > 1: - # 除了最后一行,其他都发送 - for line in lines[:-1]: - if line.strip(): - await websocket.send_text(line.rstrip()) - # 更新位置,但要退回最后一行的字节数 - last_size += len(new_content.encode("utf-8")) - len( - lines[-1].encode("utf-8") - ) - else: - # 所有行都发送 - for line in lines: - if line.strip(): - await websocket.send_text(line.rstrip()) - last_size += len(new_content.encode("utf-8")) - except UnicodeDecodeError as e: - # 遇到编码错误时,跳过这部分内容 - log.warning(f"WebSocket日志读取编码错误: {e}, 跳过部分内容") - last_size = current_size - except Exception as e: - await websocket.send_text(f"Error reading new content: {e}") - # 发生其他错误时,重置文件位置 - last_size = current_size - - # 如果文件被截断(如清空日志),重置位置 - elif current_size < last_size: - last_size = 0 - await websocket.send_text("--- 日志已清空 ---") - - finally: - # 确保清理监听任务 - if not listener_task.done(): - listener_task.cancel() - try: - await listener_task - except asyncio.CancelledError: - pass - - except WebSocketDisconnect: - pass - except Exception as e: - log.error(f"WebSocket logs error: {e}") - finally: - manager.disconnect(websocket) diff --git a/src/panel/root.py b/src/panel/root.py deleted file mode 100644 index b2139578d369a5c8e7c3fe0109e475bcc44b6343..0000000000000000000000000000000000000000 --- a/src/panel/root.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -根路由模块 - 处理控制面板主页 -""" - -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse - -from log import log -from .utils import is_mobile_user_agent - - -# 创建路由器 -router = APIRouter(tags=["root"]) - - -@router.get("/", response_class=HTMLResponse) -async def serve_control_panel(request: Request): - """提供统一控制面板""" - try: - user_agent = request.headers.get("user-agent", "") - is_mobile = is_mobile_user_agent(user_agent) - - if is_mobile: - html_file_path = "front/control_panel_mobile.html" - else: - html_file_path = "front/control_panel.html" - - with open(html_file_path, "r", encoding="utf-8") as f: - html_content = f.read() - return HTMLResponse(content=html_content) - - except Exception as e: - log.error(f"加载控制面板页面失败: {e}") - raise HTTPException(status_code=500, detail="服务器内部错误") diff --git a/src/panel/utils.py b/src/panel/utils.py deleted file mode 100644 index f75e47d3b620b13fc8129fb383ca1a7ae00d086d..0000000000000000000000000000000000000000 --- a/src/panel/utils.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -共享工具模块 - 包含WebSocket连接管理、工具函数等 -""" - -import os -import time -from collections import deque -from typing import Set - -from fastapi import HTTPException, WebSocket -from starlette.websockets import WebSocketState - -import config -from log import log - - -# ============================================================================= -# WebSocket连接管理 -# ============================================================================= - - -class ConnectionManager: - def __init__(self, max_connections: int = 3): # 进一步降低最大连接数 - # 使用双端队列严格限制内存使用 - self.active_connections: deque = deque(maxlen=max_connections) - self.max_connections = max_connections - self._last_cleanup = 0 - self._cleanup_interval = 120 # 120秒清理一次死连接 - - async def connect(self, websocket: WebSocket): - # 自动清理死连接 - self._auto_cleanup() - - # 限制最大连接数,防止内存无限增长 - if len(self.active_connections) >= self.max_connections: - await websocket.close(code=1008, reason="Too many connections") - return False - - await websocket.accept() - self.active_connections.append(websocket) - log.debug(f"WebSocket连接建立,当前连接数: {len(self.active_connections)}") - return True - - def disconnect(self, websocket: WebSocket): - # 使用更高效的方式移除连接 - try: - self.active_connections.remove(websocket) - except ValueError: - pass # 连接已不存在 - log.debug(f"WebSocket连接断开,当前连接数: {len(self.active_connections)}") - - async def send_personal_message(self, message: str, websocket: WebSocket): - try: - await websocket.send_text(message) - except Exception: - self.disconnect(websocket) - - async def broadcast(self, message: str): - # 使用更高效的方式处理广播,避免索引操作 - dead_connections = [] - for conn in self.active_connections: - try: - await conn.send_text(message) - except Exception: - dead_connections.append(conn) - - # 批量移除死连接 - for dead_conn in dead_connections: - self.disconnect(dead_conn) - - def _auto_cleanup(self): - """自动清理死连接""" - current_time = time.time() - if current_time - self._last_cleanup > self._cleanup_interval: - self.cleanup_dead_connections() - self._last_cleanup = current_time - - def cleanup_dead_connections(self): - """清理已断开的连接""" - original_count = len(self.active_connections) - # 使用列表推导式过滤活跃连接,更高效 - alive_connections = deque( - [ - conn - for conn in self.active_connections - if hasattr(conn, "client_state") - and conn.client_state != WebSocketState.DISCONNECTED - ], - maxlen=self.max_connections, - ) - - self.active_connections = alive_connections - cleaned = original_count - len(self.active_connections) - if cleaned > 0: - log.debug(f"清理了 {cleaned} 个死连接,剩余连接数: {len(self.active_connections)}") - - -# ============================================================================= -# 工具函数 -# ============================================================================= - - -def is_mobile_user_agent(user_agent: str) -> bool: - """检测是否为移动设备用户代理""" - if not user_agent: - return False - - user_agent_lower = user_agent.lower() - mobile_keywords = [ - "mobile", - "android", - "iphone", - "ipad", - "ipod", - "blackberry", - "windows phone", - "samsung", - "htc", - "motorola", - "nokia", - "palm", - "webos", - "opera mini", - "opera mobi", - "fennec", - "minimo", - "symbian", - "psp", - "nintendo", - "tablet", - ] - - return any(keyword in user_agent_lower for keyword in mobile_keywords) - - -def validate_mode(mode: str = "geminicli") -> str: - """ - 验证 mode 参数 - - Args: - mode: 模式字符串 ("geminicli" 或 "antigravity") - - Returns: - str: 验证后的 mode 字符串 - - Raises: - HTTPException: 如果 mode 参数无效 - """ - if mode not in ["geminicli", "antigravity"]: - raise HTTPException( - status_code=400, - detail=f"无效的 mode 参数: {mode},只支持 'geminicli' 或 'antigravity'" - ) - return mode - - -def get_env_locked_keys() -> Set: - """获取被环境变量锁定的配置键集合""" - env_locked_keys = set() - - # 使用 config.py 中统一维护的映射表 - for env_key, config_key in config.ENV_MAPPINGS.items(): - if os.getenv(env_key): - env_locked_keys.add(config_key) - - return env_locked_keys diff --git a/src/panel/version.py b/src/panel/version.py deleted file mode 100644 index 88c87264096dd1a92342f16445fcba173c46ae28..0000000000000000000000000000000000000000 --- a/src/panel/version.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -版本信息路由模块 - 处理 /version/* 相关的HTTP请求 -""" - -import os - -from fastapi import APIRouter -from fastapi.responses import JSONResponse - -from log import log - - -# 创建路由器 -router = APIRouter(prefix="/version", tags=["version"]) - - -@router.get("/info") -async def get_version_info(check_update: bool = False): - """ - 获取当前版本信息 - 从version.txt读取 - 可选参数 check_update: 是否检查GitHub上的最新版本 - """ - try: - # 获取项目根目录 - project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - version_file = os.path.join(project_root, "version.txt") - - # 读取version.txt - if not os.path.exists(version_file): - return JSONResponse({ - "success": False, - "error": "version.txt文件不存在" - }) - - version_data = {} - with open(version_file, 'r', encoding='utf-8') as f: - for line in f: - line = line.strip() - if '=' in line: - key, value = line.split('=', 1) - version_data[key] = value - - # 检查必要字段 - if 'short_hash' not in version_data: - return JSONResponse({ - "success": False, - "error": "version.txt格式错误" - }) - - response_data = { - "success": True, - "version": version_data.get('short_hash', 'unknown'), - "full_hash": version_data.get('full_hash', ''), - "message": version_data.get('message', ''), - "date": version_data.get('date', '') - } - - # 如果需要检查更新 - if check_update: - try: - from src.httpx_client import get_async - - # 直接获取GitHub上的version.txt文件 - github_version_url = "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/version.txt" - - # 使用统一的httpx客户端 - resp = await get_async(github_version_url, timeout=10.0) - - if resp.status_code == 200: - # 解析远程version.txt - remote_version_data = {} - for line in resp.text.strip().split('\n'): - line = line.strip() - if '=' in line: - key, value = line.split('=', 1) - remote_version_data[key] = value - - latest_hash = remote_version_data.get('full_hash', '') - latest_short_hash = remote_version_data.get('short_hash', '') - current_hash = version_data.get('full_hash', '') - - has_update = (current_hash != latest_hash) if current_hash and latest_hash else None - - response_data['check_update'] = True - response_data['has_update'] = has_update - response_data['latest_version'] = latest_short_hash - response_data['latest_hash'] = latest_hash - response_data['latest_message'] = remote_version_data.get('message', '') - response_data['latest_date'] = remote_version_data.get('date', '') - else: - # GitHub获取失败,但不影响基本版本信息 - response_data['check_update'] = False - response_data['update_error'] = f"GitHub返回错误: {resp.status_code}" - - except Exception as e: - log.debug(f"检查更新失败: {e}") - response_data['check_update'] = False - response_data['update_error'] = str(e) - - return JSONResponse(response_data) - - except Exception as e: - log.error(f"获取版本信息失败: {e}") - return JSONResponse({ - "success": False, - "error": str(e) - }) diff --git a/src/router/antigravity/anthropic.py b/src/router/antigravity/anthropic.py deleted file mode 100644 index e608b8a60fddc83e75c22a9e48ae3d972966056d..0000000000000000000000000000000000000000 --- a/src/router/antigravity/anthropic.py +++ /dev/null @@ -1,614 +0,0 @@ -""" -Anthropic Router - Handles Anthropic/Claude format API requests via Antigravity -通过Antigravity处理Anthropic/Claude格式请求的路由模块 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 标准库 -import asyncio -import json - -# 第三方库 -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse, StreamingResponse - -# 本地模块 - 配置和日志 -from config import get_anti_truncation_max_attempts, get_api_password -from log import log - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - is_anti_truncation_model, - is_fake_streaming_model, - authenticate_bearer, -) - -# 本地模块 - 转换器(假流式需要) -from src.converter.fake_stream import ( - parse_response_for_fake_stream, - build_anthropic_fake_stream_chunks, - create_anthropic_heartbeat_chunk, -) - -# 本地模块 - 基础路由工具 -from src.router.hi_check import is_health_check_request, create_health_check_response -from src.router.stream_passthrough import ( - build_streaming_response_or_error, - prepend_async_item, - read_first_async_item, -) - -# 本地模块 - 数据模型 -from src.models import ClaudeRequest, model_to_dict - -# 本地模块 - 任务管理 -from src.task_manager import create_managed_task - -# 本地模块 - Token估算 -from src.token_estimator import estimate_input_tokens - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.post("/antigravity/v1/messages") -async def messages( - claude_request: ClaudeRequest, - _token: str = Depends(authenticate_bearer) -): - """ - 处理Anthropic/Claude格式的消息请求(流式和非流式) - - Args: - claude_request: Anthropic/Claude格式的请求体 - token: Bearer认证令牌 - """ - log.debug(f"[ANTIGRAVITY-ANTHROPIC] Request for model: {claude_request.model}") - - # 转换为字典 - normalized_dict = model_to_dict(claude_request) - - # 健康检查 - if is_health_check_request(normalized_dict, format="anthropic"): - response = create_health_check_response(format="anthropic") - return JSONResponse(content=response) - - # 处理模型名称和功能检测 - use_fake_streaming = is_fake_streaming_model(claude_request.model) - use_anti_truncation = is_anti_truncation_model(claude_request.model) - real_model = get_base_model_from_feature_model(claude_request.model) - - # 获取流式标志 - is_streaming = claude_request.stream - - # 对于抗截断模型的非流式请求,给出警告 - if use_anti_truncation and not is_streaming: - log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置") - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # 转换为 Gemini 格式 (使用 converter) - from src.converter.anthropic2gemini import anthropic_to_gemini_request - gemini_dict = await anthropic_to_gemini_request(normalized_dict) - - # anthropic_to_gemini_request 不包含 model 字段,需要手动添加 - gemini_dict["model"] = real_model - - # 规范化 Gemini 请求 (使用 antigravity 模式) - from src.converter.gemini_fix import normalize_gemini_request - gemini_dict = await normalize_gemini_request(gemini_dict, mode="antigravity") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": gemini_dict.pop("model"), - "request": gemini_dict - } - - # ========== 非流式请求 ========== - if not is_streaming: - # 调用 API 层的非流式请求 - from src.api.antigravity import non_stream_request - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - status_code = getattr(response, "status_code", 200) - - # 提取响应体 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - except Exception as e: - log.error(f"Failed to parse Gemini response: {e}") - raise HTTPException(status_code=500, detail="Response parsing failed") - - # 转换为 Anthropic 格式 - from src.converter.anthropic2gemini import gemini_to_anthropic_response - anthropic_response = gemini_to_anthropic_response( - gemini_response, - real_model, - status_code - ) - - return JSONResponse(content=anthropic_response, status_code=status_code) - - # ========== 流式请求 ========== - - # ========== 假流式生成器 ========== - async def fake_stream_generator(): - from src.api.antigravity import non_stream_request - - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - if hasattr(response, "status_code") and response.status_code != 200: - log.error(f"Fake streaming got error response: status={response.status_code}") - yield response - return - - # 处理成功响应 - 提取响应内容 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - log.debug(f"Anthropic fake stream Gemini response: {gemini_response}") - - # 检查是否是错误响应(有些错误可能status_code是200但包含error字段) - if "error" in gemini_response: - log.error(f"Fake streaming got error in response body: {gemini_response['error']}") - # 转换错误为 Anthropic 格式 - from src.converter.anthropic2gemini import gemini_to_anthropic_response - anthropic_error = gemini_to_anthropic_response( - gemini_response, - real_model, - 200 - ) - yield f"data: {json.dumps(anthropic_error)}\n\n".encode() - yield "data: [DONE]\n\n".encode() - return - - # 使用统一的解析函数 - content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(gemini_response) - - log.debug(f"Anthropic extracted content: {content}") - log.debug(f"Anthropic extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...") - log.debug(f"Anthropic extracted images count: {len(images)}") - - # 构建响应块 - chunks = build_anthropic_fake_stream_chunks(content, reasoning_content, finish_reason, real_model, images) - for idx, chunk in enumerate(chunks): - chunk_json = json.dumps(chunk) - log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}") - yield f"data: {chunk_json}\n\n".encode() - - except Exception as e: - log.error(f"Response parsing failed: {e}, directly yield error") - # 构建错误响应 - error_chunk = { - "type": "error", - "error": { - "type": "api_error", - "message": str(e) - } - } - yield f"data: {json.dumps(error_chunk)}\n\n".encode() - - yield "data: [DONE]\n\n".encode() - - # ========== 流式抗截断生成器 ========== - async def anti_truncation_generator(): - from src.converter.anti_truncation import AntiTruncationStreamProcessor - from src.api.antigravity import stream_request - from src.converter.anti_truncation import apply_anti_truncation - from src.converter.anthropic2gemini import gemini_stream_to_anthropic_stream - from fastapi import Response - - max_attempts = await get_anti_truncation_max_attempts() - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(api_request) - - first_attempt_stream = stream_request(body=anti_truncation_payload, native=False) - try: - first_chunk = await read_first_async_item(first_attempt_stream) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - first_attempt_pending = True - - async def stream_request_wrapper(payload): - nonlocal first_attempt_pending - - if first_attempt_pending: - first_attempt_pending = False - stream_gen = prepend_async_item(first_chunk, first_attempt_stream) - else: - stream_gen = stream_request(body=payload, native=False) - return StreamingResponse(stream_gen, media_type="text/event-stream") - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - stream_request_wrapper, - anti_truncation_payload, - max_attempts, - enable_prefill_mode=("claude" not in str(api_request.get("model", "")).lower()), - ) - - # 包装以确保是bytes流 - async def bytes_wrapper(): - async for chunk in processor.process_stream(): - if isinstance(chunk, str): - yield chunk.encode('utf-8') - else: - yield chunk - - # 直接将整个流传递给转换器 - async for anthropic_chunk in gemini_stream_to_anthropic_stream( - bytes_wrapper(), - real_model, - 200 - ): - if anthropic_chunk: - yield anthropic_chunk - - # ========== 普通流式生成器 ========== - async def normal_stream_generator(): - from src.api.antigravity import stream_request - from fastapi import Response - from src.converter.anthropic2gemini import gemini_stream_to_anthropic_stream - - # 调用 API 层的流式请求(不使用 native 模式) - stream_gen = stream_request(body=api_request, native=False) - try: - first_chunk = await read_first_async_item(stream_gen) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - # 包装流式生成器以处理错误响应 - async def gemini_chunk_wrapper(): - async for chunk in prepend_async_item(first_chunk, stream_gen): - # 检查是否是Response对象(错误情况) - if isinstance(chunk, Response): - # 错误响应,不进行转换,直接传递 - try: - error_content = chunk.body if isinstance(chunk.body, bytes) else (chunk.body or b'').encode('utf-8') - gemini_error = json.loads(error_content.decode('utf-8')) - from src.converter.anthropic2gemini import gemini_to_anthropic_response - anthropic_error = gemini_to_anthropic_response( - gemini_error, - real_model, - chunk.status_code - ) - yield f"data: {json.dumps(anthropic_error)}\n\n".encode('utf-8') - except Exception: - yield f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': 'Stream error'}})}\n\n".encode('utf-8') - yield b"data: [DONE]\n\n" - return - else: - # 确保是bytes类型 - if isinstance(chunk, str): - yield chunk.encode('utf-8') - else: - yield chunk - - # 使用转换器处理整个流 - async for anthropic_chunk in gemini_stream_to_anthropic_stream( - gemini_chunk_wrapper(), - real_model, - 200 - ): - if anthropic_chunk: - yield anthropic_chunk - - # ========== 根据模式选择生成器 ========== - if use_fake_streaming: - return await build_streaming_response_or_error(fake_stream_generator()) - elif use_anti_truncation: - log.info("启用流式抗截断功能") - return await build_streaming_response_or_error(anti_truncation_generator()) - else: - return await build_streaming_response_or_error(normal_stream_generator()) - - -@router.post("/antigravity/v1/messages/count_tokens") -async def count_tokens( - request: Request, - _token: str = Depends(authenticate_bearer) -): - """ - 处理Anthropic格式的token计数请求 - - Args: - request: FastAPI请求对象 - _token: Bearer认证令牌(由Depends验证) - - Returns: - JSONResponse: 包含input_tokens的响应 - """ - try: - payload = await request.json() - except Exception as e: - return JSONResponse( - status_code=400, - content={"type": "error", "error": {"type": "invalid_request_error", "message": f"JSON 解析失败: {str(e)}"}} - ) - - if not isinstance(payload, dict): - return JSONResponse( - status_code=400, - content={"type": "error", "error": {"type": "invalid_request_error", "message": "请求体必须为 JSON object"}} - ) - - if not payload.get("model") or not isinstance(payload.get("messages"), list): - return JSONResponse( - status_code=400, - content={"type": "error", "error": {"type": "invalid_request_error", "message": "缺少必填字段:model / messages"}} - ) - - try: - client_host = request.client.host if request.client else "unknown" - client_port = request.client.port if request.client else "unknown" - except Exception: - client_host = "unknown" - client_port = "unknown" - - thinking_present = "thinking" in payload - thinking_value = payload.get("thinking") - thinking_summary = None - if thinking_present: - if isinstance(thinking_value, dict): - thinking_summary = { - "type": thinking_value.get("type"), - "budget_tokens": thinking_value.get("budget_tokens"), - } - else: - thinking_summary = thinking_value - - user_agent = request.headers.get("user-agent", "") - log.info( - f"[ANTIGRAVITY-ANTHROPIC] /messages/count_tokens 收到请求: client={client_host}:{client_port}, " - f"model={payload.get('model')}, messages={len(payload.get('messages') or [])}, " - f"thinking_present={thinking_present}, thinking={thinking_summary}, ua={user_agent}" - ) - - # 简单估算 - input_tokens = 0 - try: - input_tokens = estimate_input_tokens(payload) - except Exception as e: - log.error(f"[ANTIGRAVITY-ANTHROPIC] token 估算失败: {e}") - - return JSONResponse(content={"input_tokens": input_tokens}) - - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示Anthropic路由的流式和非流式响应 - 运行方式: python src/router/antigravity/anthropic.py - """ - - from fastapi.testclient import TestClient - from fastapi import FastAPI - - print("=" * 80) - print("Anthropic Router 测试") - print("=" * 80) - - # 创建测试应用 - app = FastAPI() - app.include_router(router) - - # 测试客户端 - client = TestClient(app) - - # 测试请求体 (Anthropic格式) - test_request_body = { - "model": "gemini-2.5-flash", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, tell me a joke in one sentence."} - ] - } - - # 测试Bearer令牌(模拟) - test_token = "Bearer pwd" - - def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试1】非流式请求 (POST /antigravity/v1/messages)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - response = client.post( - "/antigravity/v1/messages", - json=test_request_body, - headers={"Authorization": test_token} - ) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - - try: - content = response.text - print(f"\n响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = response.json() - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试2】流式请求 (POST /antigravity/v1/messages)") - print("=" * 80) - - stream_request_body = test_request_body.copy() - stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/messages", - json=stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("event: ") or chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - def test_fake_stream_request(): - """测试假流式请求""" - print("\n" + "=" * 80) - print("【测试3】假流式请求 (POST /antigravity/v1/messages with 假流式 prefix)") - print("=" * 80) - - fake_stream_request_body = test_request_body.copy() - fake_stream_request_body["model"] = "假流式/gemini-2.5-flash" - fake_stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(fake_stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("假流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/messages", - json=fake_stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - chunk_str = chunk.decode('utf-8') - - print(f"\nChunk #{chunk_count}:") - print(f" 长度: {len(chunk_str)} 字节") - - # 解析chunk中的所有SSE事件 - events = [] - for line in chunk_str.split('\n'): - line = line.strip() - if line.startswith("data: ") or line.startswith("event: "): - events.append(line) - - print(f" 包含 {len(events)} 个SSE事件") - - # 显示每个事件 - for event_idx, event_line in enumerate(events, 1): - if event_line == "data: [DONE]": - print(f" 事件 #{event_idx}: [DONE]") - elif event_line.startswith("data: "): - try: - json_str = event_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - event_type = json_data.get("type", "unknown") - print(f" 事件 #{event_idx}: type={event_type}") - except Exception as e: - print(f" 事件 #{event_idx}: 解析失败 - {e}") - - print(f"\n总共收到 {chunk_count} 个HTTP chunk") - - # 运行测试 - try: - # 测试非流式请求 - test_non_stream_request() - - # 测试流式请求 - test_stream_request() - - # 测试假流式请求 - test_fake_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() diff --git a/src/router/antigravity/gemini.py b/src/router/antigravity/gemini.py deleted file mode 100644 index 8398463c83d18acf29c2a1f592755756772d9589..0000000000000000000000000000000000000000 --- a/src/router/antigravity/gemini.py +++ /dev/null @@ -1,685 +0,0 @@ -""" -Gemini Router - Handles native Gemini format API requests (Antigravity backend) -处理原生Gemini格式请求的路由模块(Antigravity后端) -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 标准库 -import asyncio -import json - -# 第三方库 -from fastapi import APIRouter, Depends, HTTPException, Path, Request -from fastapi.responses import JSONResponse, StreamingResponse - -# 本地模块 - 配置和日志 -from config import get_anti_truncation_max_attempts -from log import log - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - is_anti_truncation_model, - authenticate_gemini_flexible, - is_fake_streaming_model -) - -# 本地模块 - 转换器(假流式需要) -from src.converter.fake_stream import ( - parse_response_for_fake_stream, - build_gemini_fake_stream_chunks, - create_gemini_heartbeat_chunk, -) - -# 本地模块 - 基础路由工具 -from src.router.hi_check import is_health_check_request, create_health_check_response -from src.router.stream_passthrough import ( - build_streaming_response_or_error, - prepend_async_item, - read_first_async_item, -) - -# 本地模块 - 数据模型 -from src.models import GeminiRequest, model_to_dict - -# 本地模块 - 任务管理 -from src.task_manager import create_managed_task - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.post("/antigravity/v1beta/models/{model:path}:generateContent") -@router.post("/antigravity/v1/models/{model:path}:generateContent") -async def generate_content( - gemini_request: "GeminiRequest", - model: str = Path(..., description="Model name"), - api_key: str = Depends(authenticate_gemini_flexible), -): - """ - 处理Gemini格式的内容生成请求(非流式) - - Args: - gemini_request: Gemini格式的请求体 - model: 模型名称 - api_key: API 密钥 - """ - log.debug(f"[ANTIGRAVITY] Non-streaming request for model: {model}") - - # 转换为字典 - normalized_dict = model_to_dict(gemini_request) - - # 健康检查 - if is_health_check_request(normalized_dict, format="gemini"): - response = create_health_check_response(format="gemini") - return JSONResponse(content=response) - - # 处理模型名称和功能检测 - use_anti_truncation = is_anti_truncation_model(model) - real_model = get_base_model_from_feature_model(model) - - # 对于抗截断模型的非流式请求,给出警告 - if use_anti_truncation: - log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置") - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # 规范化 Gemini 请求 (使用 antigravity 模式) - from src.converter.gemini_fix import normalize_gemini_request - normalized_dict = await normalize_gemini_request(normalized_dict, mode="antigravity") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_dict.pop("model"), - "request": normalized_dict - } - - # 调用 API 层的非流式请求 - from src.api.antigravity import non_stream_request - response = await non_stream_request(body=api_request) - - # 解包装响应:Antigravity API 可能返回的格式有额外的 response 包装层 - # 需要提取并返回标准 Gemini 格式 - # 保持 Gemini 原生的 inlineData 格式,不进行 Markdown 转换 - try: - if response.status_code == 200: - response_data = json.loads(response.body if hasattr(response, 'body') else response.content) - # 如果有 response 包装,解包装它 - if "response" in response_data: - unwrapped_data = response_data["response"] - return JSONResponse(content=unwrapped_data) - # 错误响应或没有 response 字段,直接返回 - return response - except Exception as e: - log.warning(f"Failed to unwrap response: {e}, returning original response") - return response - -@router.post("/antigravity/v1beta/models/{model:path}:streamGenerateContent") -@router.post("/antigravity/v1/models/{model:path}:streamGenerateContent") -async def stream_generate_content( - gemini_request: GeminiRequest, - model: str = Path(..., description="Model name"), - api_key: str = Depends(authenticate_gemini_flexible), -): - """ - 处理Gemini格式的流式内容生成请求 - - Args: - gemini_request: Gemini格式的请求体 - model: 模型名称 - api_key: API 密钥 - """ - log.debug(f"[ANTIGRAVITY] Streaming request for model: {model}") - - # 转换为字典 - normalized_dict = model_to_dict(gemini_request) - - # 处理模型名称和功能检测 - use_fake_streaming = is_fake_streaming_model(model) - use_anti_truncation = is_anti_truncation_model(model) - real_model = get_base_model_from_feature_model(model) - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # ========== 假流式生成器 ========== - async def fake_stream_generator(): - from src.converter.gemini_fix import normalize_gemini_request - from src.api.antigravity import non_stream_request - - normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="antigravity") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_req.pop("model"), - "request": normalized_req - } - - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - if hasattr(response, "status_code") and response.status_code != 200: - log.error(f"Fake streaming got error response: status={response.status_code}") - yield response - return - - # 处理成功响应 - 提取响应内容 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - response_data = json.loads(response_body) - log.debug(f"Gemini fake stream response data: {response_data}") - - # 检查是否是错误响应(有些错误可能status_code是200但包含error字段) - if "error" in response_data: - log.error(f"Fake streaming got error in response body: {response_data['error']}") - yield f"data: {json.dumps(response_data)}\n\n".encode() - yield "data: [DONE]\n\n".encode() - return - - # 使用统一的解析函数 - content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(response_data) - - log.debug(f"Gemini extracted content: {content}") - log.debug(f"Gemini extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...") - log.debug(f"Gemini extracted images count: {len(images)}") - - # 构建响应块 - chunks = build_gemini_fake_stream_chunks(content, reasoning_content, finish_reason, images) - for idx, chunk in enumerate(chunks): - chunk_json = json.dumps(chunk) - log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}") - yield f"data: {chunk_json}\n\n".encode() - - except Exception as e: - log.error(f"Response parsing failed: {e}, directly yield original response") - # 直接yield原始响应,不进行包装 - yield f"data: {response_body}\n\n".encode() - - yield "data: [DONE]\n\n".encode() - - # ========== 流式抗截断生成器 ========== - async def anti_truncation_generator(): - from src.converter.gemini_fix import normalize_gemini_request - from src.converter.anti_truncation import AntiTruncationStreamProcessor - from src.converter.anti_truncation import apply_anti_truncation - from src.api.antigravity import stream_request - from fastapi import Response - - # 先进行基础标准化 - normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="antigravity") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_req.pop("model") if "model" in normalized_req else real_model, - "request": normalized_req - } - - max_attempts = await get_anti_truncation_max_attempts() - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(api_request) - - first_attempt_stream = stream_request(body=anti_truncation_payload, native=False) - try: - first_chunk = await read_first_async_item(first_attempt_stream) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - first_attempt_pending = True - - async def stream_request_wrapper(payload): - nonlocal first_attempt_pending - - if first_attempt_pending: - first_attempt_pending = False - stream_gen = prepend_async_item(first_chunk, first_attempt_stream) - else: - stream_gen = stream_request(body=payload, native=False) - return StreamingResponse(stream_gen, media_type="text/event-stream") - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - stream_request_wrapper, - anti_truncation_payload, - max_attempts, - enable_prefill_mode=("claude" not in str(api_request.get("model", "")).lower()), - ) - - # 迭代 process_stream() 生成器,并展开 response 包装 - async for chunk in processor.process_stream(): - if isinstance(chunk, (str, bytes)): - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 解析并展开 response 包装 - if chunk_str.startswith("data: "): - json_str = chunk_str[6:].strip() - - # 跳过 [DONE] 标记 - if json_str == "[DONE]": - yield chunk - continue - - try: - # 解析JSON - data = json.loads(json_str) - - # 展开 response 包装 - if "response" in data and "candidates" not in data: - log.debug(f"[ANTIGRAVITY-ANTI-TRUNCATION] 展开response包装") - unwrapped_data = data["response"] - # 重新构建SSE格式 - yield f"data: {json.dumps(unwrapped_data, ensure_ascii=False)}\n\n".encode('utf-8') - else: - # 已经是展开的格式,直接返回 - yield chunk - except json.JSONDecodeError: - # JSON解析失败,直接返回原始chunk - yield chunk - else: - # 不是SSE格式,直接返回 - yield chunk - else: - # 其他类型,直接返回 - yield chunk - - # ========== 普通流式生成器 ========== - async def normal_stream_generator(): - from src.converter.gemini_fix import normalize_gemini_request - from src.api.antigravity import stream_request - from fastapi import Response - - normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="antigravity") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_req.pop("model"), - "request": normalized_req - } - - # 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装 - log.debug(f"[ANTIGRAVITY] 使用非native模式,将展开response包装") - stream_gen = stream_request(body=api_request, native=False) - try: - first_chunk = await read_first_async_item(stream_gen) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - # 展开 response 包装 - async for chunk in prepend_async_item(first_chunk, stream_gen): - # 检查是否是Response对象(错误情况) - if isinstance(chunk, Response): - # 将Response转换为SSE格式的错误消息 - try: - error_content = chunk.body if isinstance(chunk.body, bytes) else (chunk.body or b'').encode('utf-8') - error_json = json.loads(error_content.decode('utf-8')) - except Exception: - error_json = {"error": {"code": chunk.status_code, "message": "upstream error", "status": "ERROR"}} - log.error(f"[ANTIGRAVITY STREAM] 返回错误给客户端: status={chunk.status_code}, error={str(error_json)[:200]}") - yield f"data: {json.dumps(error_json)}\n\n".encode('utf-8') - yield b"data: [DONE]\n\n" - return - - # 处理SSE格式的chunk - if isinstance(chunk, (str, bytes)): - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 解析并展开 response 包装 - if chunk_str.startswith("data: "): - json_str = chunk_str[6:].strip() - - # 跳过 [DONE] 标记 - if json_str == "[DONE]": - yield chunk - continue - - try: - # 解析JSON - data = json.loads(json_str) - - # 展开 response 包装 - if "response" in data and "candidates" not in data: - log.debug(f"[ANTIGRAVITY] 展开response包装") - unwrapped_data = data["response"] - # 重新构建SSE格式 - yield f"data: {json.dumps(unwrapped_data, ensure_ascii=False)}\n\n".encode('utf-8') - else: - # 已经是展开的格式,直接返回 - yield chunk - except json.JSONDecodeError: - # JSON解析失败,直接返回原始chunk - yield chunk - else: - # 不是SSE格式,直接返回 - yield chunk - - # ========== 根据模式选择生成器 ========== - if use_fake_streaming: - return await build_streaming_response_or_error(fake_stream_generator()) - elif use_anti_truncation: - log.info("启用流式抗截断功能") - return await build_streaming_response_or_error(anti_truncation_generator()) - else: - return await build_streaming_response_or_error(normal_stream_generator()) - -@router.post("/antigravity/v1beta/models/{model:path}:countTokens") -@router.post("/antigravity/v1/models/{model:path}:countTokens") -async def count_tokens( - request: Request = None, - api_key: str = Depends(authenticate_gemini_flexible), -): - """ - 模拟Gemini格式的token计数 - - 使用简单的启发式方法:大约4字符=1token - """ - - try: - request_data = await request.json() - except Exception as e: - log.error(f"Failed to parse JSON request: {e}") - raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") - - # 简单的token计数模拟 - 基于文本长度估算 - total_tokens = 0 - - # 如果有contents字段 - if "contents" in request_data: - for content in request_data["contents"]: - if "parts" in content: - for part in content["parts"]: - if "text" in part: - # 简单估算:大约4字符=1token - text_length = len(part["text"]) - total_tokens += max(1, text_length // 4) - - # 如果有generateContentRequest字段 - elif "generateContentRequest" in request_data: - gen_request = request_data["generateContentRequest"] - if "contents" in gen_request: - for content in gen_request["contents"]: - if "parts" in content: - for part in content["parts"]: - if "text" in part: - text_length = len(part["text"]) - total_tokens += max(1, text_length // 4) - - # 返回Gemini格式的响应 - return JSONResponse(content={"totalTokens": total_tokens}) - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示Gemini路由的流式和非流式响应 - 运行方式: python src/router/antigravity/gemini.py - """ - - from fastapi.testclient import TestClient - from fastapi import FastAPI - - print("=" * 80) - print("Gemini Router (Antigravity Backend) 测试") - print("=" * 80) - - # 创建测试应用 - app = FastAPI() - app.include_router(router) - - # 测试客户端 - client = TestClient(app) - - # 测试请求体 (Gemini格式) - test_request_body = { - "contents": [ - { - "role": "user", - "parts": [{"text": "Hello, tell me a joke in one sentence."}] - } - ] - } - - # 测试API密钥(模拟) - test_api_key = "pwd" - - def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试2】非流式请求 (POST /antigravity/v1/models/gemini-2.5-flash:generateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - response = client.post( - "/antigravity/v1/models/gemini-2.5-flash:generateContent", - json=test_request_body, - params={"key": test_api_key} - ) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - - try: - content = response.text - print(f"\n响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = response.json() - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试3】流式请求 (POST /antigravity/v1/models/gemini-2.5-flash:streamGenerateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent", - json=test_request_body, - params={"key": test_api_key} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - def test_fake_stream_request(): - """测试假流式请求""" - print("\n" + "=" * 80) - print("【测试4】假流式请求 (POST /antigravity/v1/models/假流式/gemini-2.5-flash:streamGenerateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - print("假流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/models/假流式/gemini-2.5-flash:streamGenerateContent", - json=test_request_body, - params={"key": test_api_key} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - chunk_str = chunk.decode('utf-8') - - print(f"\nChunk #{chunk_count}:") - print(f" 长度: {len(chunk_str)} 字节") - - # 解析chunk中的所有SSE事件 - events = [] - for line in chunk_str.split('\n'): - line = line.strip() - if line.startswith("data: "): - events.append(line) - - print(f" 包含 {len(events)} 个SSE事件") - - # 显示每个事件 - for event_idx, event_line in enumerate(events, 1): - if event_line == "data: [DONE]": - print(f" 事件 #{event_idx}: [DONE]") - else: - try: - json_str = event_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - # 提取text内容 - text = json_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") - finish_reason = json_data.get("candidates", [{}])[0].get("finishReason") - print(f" 事件 #{event_idx}: text={repr(text[:50])}{'...' if len(text) > 50 else ''}, finishReason={finish_reason}") - except Exception as e: - print(f" 事件 #{event_idx}: 解析失败 - {e}") - - print(f"\n总共收到 {chunk_count} 个HTTP chunk") - - def test_anti_truncation_stream_request(): - """测试流式抗截断请求""" - print("\n" + "=" * 80) - print("【测试5】流式抗截断请求 (POST /antigravity/v1/models/流式抗截断/gemini-2.5-flash:streamGenerateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式抗截断响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/models/流式抗截断/gemini-2.5-flash:streamGenerateContent", - json=test_request_body, - params={"key": test_api_key} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - # 运行测试 - try: - # 测试非流式请求 - test_non_stream_request() - - # 测试流式请求 - test_stream_request() - - # 测试假流式请求 - test_fake_stream_request() - - # 测试流式抗截断请求 - test_anti_truncation_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() diff --git a/src/router/antigravity/model_list.py b/src/router/antigravity/model_list.py deleted file mode 100644 index f27c81822dbb26b1dc2d6939704ecd193b9f4b25..0000000000000000000000000000000000000000 --- a/src/router/antigravity/model_list.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Antigravity Model List Router - Handles model list requests -Antigravity 模型列表路由 - 处理模型列表请求 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 第三方库 -from fastapi import APIRouter, Depends -from fastapi.responses import JSONResponse - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - authenticate_flexible -) - -# 本地模块 - API -from src.api.antigravity import fetch_available_models - -# 本地模块 - 基础路由工具 -from src.router.base_router import create_gemini_model_list, create_openai_model_list -from src.models import model_to_dict -from log import log - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== 辅助函数 ==================== - -async def get_antigravity_models_with_features(): - """ - 获取 Antigravity 模型列表并添加功能前缀 - - Returns: - 带有功能前缀的模型列表 - """ - # 从 API 获取基础模型列表 - base_models_data = await fetch_available_models() - - if not base_models_data: - log.warning("[ANTIGRAVITY MODEL LIST] 无法获取模型列表,返回空列表") - return [] - - # 提取模型 ID - base_model_ids = [model['id'] for model in base_models_data if 'id' in model] - - # 添加功能前缀 - models = [] - for base_model in base_model_ids: - # 基础模型 - models.append(base_model) - - # 假流式模型 (前缀格式) - models.append(f"假流式/{base_model}") - - # 流式抗截断模型 (仅在流式传输时有效,前缀格式) - models.append(f"流式抗截断/{base_model}") - - log.info(f"[ANTIGRAVITY MODEL LIST] 生成了 {len(models)} 个模型(包含功能前缀)") - return models - - -# ==================== API 路由 ==================== - -@router.get("/antigravity/v1beta/models") -async def list_gemini_models(token: str = Depends(authenticate_flexible)): - """ - 返回 Gemini 格式的模型列表 - - 从 src.api.antigravity.fetch_available_models 动态获取模型列表 - 并添加假流式和流式抗截断前缀 - """ - models = await get_antigravity_models_with_features() - log.info("[ANTIGRAVITY MODEL LIST] 返回 Gemini 格式") - return JSONResponse(content=create_gemini_model_list( - models, - base_name_extractor=get_base_model_from_feature_model - )) - - -@router.get("/antigravity/v1/models") -async def list_openai_models(token: str = Depends(authenticate_flexible)): - """ - 返回 OpenAI 格式的模型列表 - - 从 src.api.antigravity.fetch_available_models 动态获取模型列表 - 并添加假流式和流式抗截断前缀 - """ - models = await get_antigravity_models_with_features() - log.info("[ANTIGRAVITY MODEL LIST] 返回 OpenAI 格式") - model_list = create_openai_model_list(models, owned_by="google") - return JSONResponse(content={ - "object": "list", - "data": [model_to_dict(model) for model in model_list.data] - }) diff --git a/src/router/antigravity/openai.py b/src/router/antigravity/openai.py deleted file mode 100644 index f914bd8735e7d0c7c3b618f6d180394f07ffdab6..0000000000000000000000000000000000000000 --- a/src/router/antigravity/openai.py +++ /dev/null @@ -1,590 +0,0 @@ -""" -OpenAI Router - Handles OpenAI format API requests via Antigravity -通过Antigravity处理OpenAI格式请求的路由模块 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 标准库 -import asyncio -import json - -# 第三方库 -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse - -# 本地模块 - 配置和日志 -from config import get_anti_truncation_max_attempts -from log import log - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - is_anti_truncation_model, - is_fake_streaming_model, - authenticate_bearer, -) - -# 本地模块 - 转换器(假流式需要) -from src.converter.fake_stream import ( - parse_response_for_fake_stream, - build_openai_fake_stream_chunks, - create_openai_heartbeat_chunk, -) - -# 本地模块 - 基础路由工具 -from src.router.hi_check import is_health_check_request, create_health_check_response -from src.router.stream_passthrough import ( - build_streaming_response_or_error, - prepend_async_item, - read_first_async_item, -) - -# 本地模块 - 数据模型 -from src.models import OpenAIChatCompletionRequest, model_to_dict - -# 本地模块 - 任务管理 -from src.task_manager import create_managed_task - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.post("/antigravity/v1/chat/completions") -async def chat_completions( - openai_request: OpenAIChatCompletionRequest, - token: str = Depends(authenticate_bearer) -): - """ - 处理OpenAI格式的聊天完成请求(流式和非流式) - - Args: - openai_request: OpenAI格式的请求体 - token: Bearer认证令牌 - """ - log.debug(f"[ANTIGRAVITY-OPENAI] Request for model: {openai_request.model}") - - # 转换为字典 - normalized_dict = model_to_dict(openai_request) - - # 健康检查 - if is_health_check_request(normalized_dict, format="openai"): - response = create_health_check_response(format="openai") - return JSONResponse(content=response) - - # 处理模型名称和功能检测 - use_fake_streaming = is_fake_streaming_model(openai_request.model) - use_anti_truncation = is_anti_truncation_model(openai_request.model) - real_model = get_base_model_from_feature_model(openai_request.model) - - # 获取流式标志 - is_streaming = openai_request.stream - - # 对于抗截断模型的非流式请求,给出警告 - if use_anti_truncation and not is_streaming: - log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置") - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # 转换为 Gemini 格式 (使用 converter) - from src.converter.openai2gemini import convert_openai_to_gemini_request - gemini_dict = await convert_openai_to_gemini_request(normalized_dict) - - # convert_openai_to_gemini_request 不包含 model 字段,需要手动添加 - gemini_dict["model"] = real_model - - # 规范化 Gemini 请求 (使用 antigravity 模式) - from src.converter.gemini_fix import normalize_gemini_request - gemini_dict = await normalize_gemini_request(gemini_dict, mode="antigravity") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": gemini_dict.pop("model"), - "request": gemini_dict - } - - # ========== 非流式请求 ========== - if not is_streaming: - # 调用 API 层的非流式请求 - from src.api.antigravity import non_stream_request - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - status_code = getattr(response, "status_code", 200) - - # 提取响应体 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - except Exception as e: - log.error(f"Failed to parse Gemini response: {e}") - raise HTTPException(status_code=500, detail="Response parsing failed") - - # 转换为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_response - openai_response = convert_gemini_to_openai_response( - gemini_response, - real_model, - status_code - ) - - return JSONResponse(content=openai_response, status_code=status_code) - - # ========== 流式请求 ========== - - # ========== 假流式生成器 ========== - async def fake_stream_generator(): - from src.api.antigravity import non_stream_request - - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - if hasattr(response, "status_code") and response.status_code != 200: - log.error(f"Fake streaming got error response: status={response.status_code}") - yield response - return - - # 处理成功响应 - 提取响应内容 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - log.debug(f"OpenAI fake stream Gemini response: {gemini_response}") - - # 检查是否是错误响应(有些错误可能status_code是200但包含error字段) - if "error" in gemini_response: - log.error(f"Fake streaming got error in response body: {gemini_response['error']}") - # 转换错误为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_response - openai_error = convert_gemini_to_openai_response( - gemini_response, - real_model, - 200 - ) - yield f"data: {json.dumps(openai_error)}\n\n".encode() - yield "data: [DONE]\n\n".encode() - return - - # 使用统一的解析函数 - content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(gemini_response) - - log.debug(f"OpenAI extracted content: {content}") - log.debug(f"OpenAI extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...") - log.debug(f"OpenAI extracted images count: {len(images)}") - - # 构建响应块 - chunks = build_openai_fake_stream_chunks(content, reasoning_content, finish_reason, real_model, images) - for idx, chunk in enumerate(chunks): - chunk_json = json.dumps(chunk) - log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}") - yield f"data: {chunk_json}\n\n".encode() - - except Exception as e: - log.error(f"Response parsing failed: {e}, directly yield error") - # 构建错误响应 - error_chunk = { - "id": "error", - "object": "chat.completion.chunk", - "created": int(asyncio.get_event_loop().time()), - "model": real_model, - "choices": [{ - "index": 0, - "delta": {"content": f"Error: {str(e)}"}, - "finish_reason": "error" - }] - } - yield f"data: {json.dumps(error_chunk)}\n\n".encode() - - yield "data: [DONE]\n\n".encode() - - # ========== 流式抗截断生成器 ========== - async def anti_truncation_generator(): - from src.converter.anti_truncation import AntiTruncationStreamProcessor - from src.api.antigravity import stream_request - from src.converter.anti_truncation import apply_anti_truncation - from fastapi import Response - - max_attempts = await get_anti_truncation_max_attempts() - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(api_request) - - first_attempt_stream = stream_request(body=anti_truncation_payload, native=False) - try: - first_chunk = await read_first_async_item(first_attempt_stream) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - first_attempt_pending = True - - async def stream_request_wrapper(payload): - nonlocal first_attempt_pending - - if first_attempt_pending: - first_attempt_pending = False - stream_gen = prepend_async_item(first_chunk, first_attempt_stream) - else: - stream_gen = stream_request(body=payload, native=False) - - return StreamingResponse(stream_gen, media_type="text/event-stream") - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - stream_request_wrapper, - anti_truncation_payload, - max_attempts, - enable_prefill_mode=("claude" not in str(api_request.get("model", "")).lower()), - ) - - # 转换为 OpenAI 格式 - import uuid - response_id = str(uuid.uuid4()) - - # 直接迭代 process_stream() 生成器,并转换为 OpenAI 格式 - async for chunk in processor.process_stream(): - if not chunk: - continue - - # 解析 Gemini SSE 格式 - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 跳过空行 - if not chunk_str.strip(): - continue - - # 处理 [DONE] 标记 - if chunk_str.strip() == "data: [DONE]": - yield "data: [DONE]\n\n".encode('utf-8') - return - - # 解析 "data: {...}" 格式 - if chunk_str.startswith("data: "): - try: - # 转换为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_stream - openai_chunk_str = convert_gemini_to_openai_stream( - chunk_str, - real_model, - response_id - ) - - if openai_chunk_str: - yield openai_chunk_str.encode('utf-8') - - except Exception as e: - log.error(f"Failed to convert chunk: {e}") - continue - - # 发送结束标记 - yield "data: [DONE]\n\n".encode('utf-8') - - # ========== 普通流式生成器 ========== - async def normal_stream_generator(): - from src.api.antigravity import stream_request - from fastapi import Response - import uuid - - # 调用 API 层的流式请求(不使用 native 模式) - stream_gen = stream_request(body=api_request, native=False) - try: - first_chunk = await read_first_async_item(stream_gen) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - response_id = str(uuid.uuid4()) - - # yield所有数据,处理可能的错误Response - async for chunk in prepend_async_item(first_chunk, stream_gen): - # 检查是否是Response对象(错误情况) - if isinstance(chunk, Response): - # 将Response转换为SSE格式的错误消息 - try: - error_content = chunk.body if isinstance(chunk.body, bytes) else (chunk.body or b'').encode('utf-8') - gemini_error = json.loads(error_content.decode('utf-8')) - # 转换为 OpenAI 格式错误 - from src.converter.openai2gemini import convert_gemini_to_openai_response - openai_error = convert_gemini_to_openai_response( - gemini_error, - real_model, - chunk.status_code - ) - yield f"data: {json.dumps(openai_error)}\n\n".encode('utf-8') - except Exception: - yield f"data: {json.dumps({'error': 'Stream error'})}\n\n".encode('utf-8') - yield b"data: [DONE]\n\n" - return - else: - # 正常的bytes数据,转换为 OpenAI 格式 - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 跳过空行 - if not chunk_str.strip(): - continue - - # 处理 [DONE] 标记 - if chunk_str.strip() == "data: [DONE]": - yield "data: [DONE]\n\n".encode('utf-8') - return - - # 解析并转换 Gemini chunk 为 OpenAI 格式 - if chunk_str.startswith("data: "): - try: - # 转换为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_stream - openai_chunk_str = convert_gemini_to_openai_stream( - chunk_str, - real_model, - response_id - ) - - if openai_chunk_str: - yield openai_chunk_str.encode('utf-8') - - except Exception as e: - log.error(f"Failed to convert chunk: {e}") - continue - - # 发送结束标记 - yield "data: [DONE]\n\n".encode('utf-8') - - # ========== 根据模式选择生成器 ========== - if use_fake_streaming: - return await build_streaming_response_or_error(fake_stream_generator()) - elif use_anti_truncation: - log.info("启用流式抗截断功能") - return await build_streaming_response_or_error(anti_truncation_generator()) - else: - return await build_streaming_response_or_error(normal_stream_generator()) - - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示OpenAI路由的流式和非流式响应 - 运行方式: python src/router/antigravity/openai.py - """ - - from fastapi.testclient import TestClient - from fastapi import FastAPI - - print("=" * 80) - print("OpenAI Router 测试") - print("=" * 80) - - # 创建测试应用 - app = FastAPI() - app.include_router(router) - - # 测试客户端 - client = TestClient(app) - - # 测试请求体 (OpenAI格式) - test_request_body = { - "model": "gemini-2.5-flash", - "messages": [ - {"role": "user", "content": "Hello, tell me a joke in one sentence."} - ] - } - - # 测试Bearer令牌(模拟) - test_token = "Bearer pwd" - - def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试1】非流式请求 (POST /antigravity/v1/chat/completions)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - response = client.post( - "/antigravity/v1/chat/completions", - json=test_request_body, - headers={"Authorization": test_token} - ) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - - try: - content = response.text - print(f"\n响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = response.json() - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试2】流式请求 (POST /antigravity/v1/chat/completions)") - print("=" * 80) - - stream_request_body = test_request_body.copy() - stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/chat/completions", - json=stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - def test_fake_stream_request(): - """测试假流式请求""" - print("\n" + "=" * 80) - print("【测试3】假流式请求 (POST /antigravity/v1/chat/completions with 假流式 prefix)") - print("=" * 80) - - fake_stream_request_body = test_request_body.copy() - fake_stream_request_body["model"] = "假流式/gemini-2.5-flash" - fake_stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(fake_stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("假流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/antigravity/v1/chat/completions", - json=fake_stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - chunk_str = chunk.decode('utf-8') - - print(f"\nChunk #{chunk_count}:") - print(f" 长度: {len(chunk_str)} 字节") - - # 解析chunk中的所有SSE事件 - events = [] - for line in chunk_str.split('\n'): - line = line.strip() - if line.startswith("data: "): - events.append(line) - - print(f" 包含 {len(events)} 个SSE事件") - - # 显示每个事件 - for event_idx, event_line in enumerate(events, 1): - if event_line == "data: [DONE]": - print(f" 事件 #{event_idx}: [DONE]") - else: - try: - json_str = event_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - # 提取content内容 - content = json_data.get("choices", [{}])[0].get("delta", {}).get("content", "") - finish_reason = json_data.get("choices", [{}])[0].get("finish_reason") - print(f" 事件 #{event_idx}: content={repr(content[:50])}{'...' if len(content) > 50 else ''}, finish_reason={finish_reason}") - except Exception as e: - print(f" 事件 #{event_idx}: 解析失败 - {e}") - - print(f"\n总共收到 {chunk_count} 个HTTP chunk") - - # 运行测试 - try: - # 测试非流式请求 - test_non_stream_request() - - # 测试流式请求 - test_stream_request() - - # 测试假流式请求 - test_fake_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() diff --git a/src/router/base_router.py b/src/router/base_router.py deleted file mode 100644 index f9d2b37d76d003a34510b7454eb5254bfa0a6cf8..0000000000000000000000000000000000000000 --- a/src/router/base_router.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Base Router - 共用的路由基础功能 -提供模型列表处理、通用响应等共同功能 -""" - -from typing import List - -from src.models import Model, ModelList - -def create_openai_model_list( - model_ids: List[str], - owned_by: str = "google" -) -> ModelList: - """ - 创建OpenAI格式的模型列表 - - Args: - model_ids: 模型ID列表 - owned_by: 模型所有者 - - Returns: - ModelList对象 - """ - from datetime import datetime, timezone - current_timestamp = int(datetime.now(timezone.utc).timestamp()) - - models = [ - Model( - id=model_id, - object='model', - created=current_timestamp, - owned_by=owned_by - ) - for model_id in model_ids - ] - - return ModelList(data=models) - - -def create_gemini_model_list( - model_ids: List[str], - base_name_extractor=None -) -> dict: - """ - 创建Gemini格式的模型列表 - - Args: - model_ids: 模型ID列表 - base_name_extractor: 可选的基础模型名提取函数 - - Returns: - 包含模型列表的字典 - """ - gemini_models = [] - - for model_id in model_ids: - base_model = model_id - if base_name_extractor: - try: - base_model = base_name_extractor(model_id) - except Exception: - pass - - model_info = { - "name": f"models/{model_id}", - "baseModelId": base_model, - "version": "001", - "displayName": model_id, - "description": f"Gemini {base_model} model", - "supportedGenerationMethods": ["generateContent", "streamGenerateContent"], - } - gemini_models.append(model_info) - - return {"models": gemini_models} \ No newline at end of file diff --git a/src/router/geminicli/anthropic.py b/src/router/geminicli/anthropic.py deleted file mode 100644 index f9f9e4dc2bb0bb1e9c709060e6c10d1a3b6ab8d7..0000000000000000000000000000000000000000 --- a/src/router/geminicli/anthropic.py +++ /dev/null @@ -1,614 +0,0 @@ -""" -Anthropic Router - Handles Anthropic/Claude format API requests via GeminiCLI -通过GeminiCLI处理Anthropic/Claude格式请求的路由模块 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 标准库 -import asyncio -import json - -# 第三方库 -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse, StreamingResponse - -# 本地模块 - 配置和日志 -from config import get_anti_truncation_max_attempts -from log import log - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - is_anti_truncation_model, - is_fake_streaming_model, - authenticate_bearer, -) - -# 本地模块 - 转换器(假流式需要) -from src.converter.fake_stream import ( - parse_response_for_fake_stream, - build_anthropic_fake_stream_chunks, - create_anthropic_heartbeat_chunk, -) - -# 本地模块 - 基础路由工具 -from src.router.hi_check import is_health_check_request, create_health_check_response -from src.router.stream_passthrough import ( - build_streaming_response_or_error, - prepend_async_item, - read_first_async_item, -) - -# 本地模块 - 数据模型 -from src.models import ClaudeRequest, model_to_dict - -# 本地模块 - 任务管理 -from src.task_manager import create_managed_task - -# 本地模块 - Token估算 -from src.token_estimator import estimate_input_tokens - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.post("/v1/messages") -async def messages( - claude_request: ClaudeRequest, - token: str = Depends(authenticate_bearer) -): - """ - 处理Anthropic/Claude格式的消息请求(流式和非流式) - - Args: - claude_request: Anthropic/Claude格式的请求体 - token: Bearer认证令牌 - """ - log.debug(f"[GEMINICLI-ANTHROPIC] Request for model: {claude_request.model}") - - # 转换为字典 - normalized_dict = model_to_dict(claude_request) - - # 健康检查 - if is_health_check_request(normalized_dict, format="anthropic"): - response = create_health_check_response(format="anthropic") - return JSONResponse(content=response) - - # 处理模型名称和功能检测 - use_fake_streaming = is_fake_streaming_model(claude_request.model) - use_anti_truncation = is_anti_truncation_model(claude_request.model) - real_model = get_base_model_from_feature_model(claude_request.model) - - # 获取流式标志 - is_streaming = claude_request.stream - - # 对于抗截断模型的非流式请求,给出警告 - if use_anti_truncation and not is_streaming: - log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置") - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # 转换为 Gemini 格式 (使用 converter) - from src.converter.anthropic2gemini import anthropic_to_gemini_request - gemini_dict = await anthropic_to_gemini_request(normalized_dict) - - # anthropic_to_gemini_request 不包含 model 字段,需要手动添加 - gemini_dict["model"] = real_model - - # 规范化 Gemini 请求 (使用 geminicli 模式) - from src.converter.gemini_fix import normalize_gemini_request - gemini_dict = await normalize_gemini_request(gemini_dict, mode="geminicli") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": gemini_dict.pop("model"), - "request": gemini_dict - } - - # ========== 非流式请求 ========== - if not is_streaming: - # 调用 API 层的非流式请求 - from src.api.geminicli import non_stream_request - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - status_code = getattr(response, "status_code", 200) - - # 提取响应体 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - except Exception as e: - log.error(f"Failed to parse Gemini response: {e}") - raise HTTPException(status_code=500, detail="Response parsing failed") - - # 转换为 Anthropic 格式 - from src.converter.anthropic2gemini import gemini_to_anthropic_response - anthropic_response = gemini_to_anthropic_response( - gemini_response, - real_model, - status_code - ) - - return JSONResponse(content=anthropic_response, status_code=status_code) - - # ========== 流式请求 ========== - - # ========== 假流式生成器 ========== - async def fake_stream_generator(): - from src.api.geminicli import non_stream_request - - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - if hasattr(response, "status_code") and response.status_code != 200: - log.error(f"Fake streaming got error response: status={response.status_code}") - yield response - return - - # 处理成功响应 - 提取响应内容 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - log.debug(f"Anthropic fake stream Gemini response: {gemini_response}") - - # 检查是否是错误响应(有些错误可能status_code是200但包含error字段) - if "error" in gemini_response: - log.error(f"Fake streaming got error in response body: {gemini_response['error']}") - # 转换错误为 Anthropic 格式 - from src.converter.anthropic2gemini import gemini_to_anthropic_response - anthropic_error = gemini_to_anthropic_response( - gemini_response, - real_model, - 200 - ) - yield f"data: {json.dumps(anthropic_error)}\n\n".encode() - yield "data: [DONE]\n\n".encode() - return - - # 使用统一的解析函数 - content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(gemini_response) - - log.debug(f"Anthropic extracted content: {content}") - log.debug(f"Anthropic extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...") - log.debug(f"Anthropic extracted images count: {len(images)}") - - # 构建响应块 - chunks = build_anthropic_fake_stream_chunks(content, reasoning_content, finish_reason, real_model, images) - for idx, chunk in enumerate(chunks): - chunk_json = json.dumps(chunk) - log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}") - yield f"data: {chunk_json}\n\n".encode() - - except Exception as e: - log.error(f"Response parsing failed: {e}, directly yield error") - # 构建错误响应 - error_chunk = { - "type": "error", - "error": { - "type": "api_error", - "message": str(e) - } - } - yield f"data: {json.dumps(error_chunk)}\n\n".encode() - - yield "data: [DONE]\n\n".encode() - - # ========== 流式抗截断生成器 ========== - async def anti_truncation_generator(): - from src.converter.anti_truncation import AntiTruncationStreamProcessor - from src.api.geminicli import stream_request - from src.converter.anti_truncation import apply_anti_truncation - from src.converter.anthropic2gemini import gemini_stream_to_anthropic_stream - from fastapi import Response - - max_attempts = await get_anti_truncation_max_attempts() - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(api_request) - - first_attempt_stream = stream_request(body=anti_truncation_payload, native=False) - try: - first_chunk = await read_first_async_item(first_attempt_stream) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - first_attempt_pending = True - - async def stream_request_wrapper(payload): - nonlocal first_attempt_pending - - if first_attempt_pending: - first_attempt_pending = False - stream_gen = prepend_async_item(first_chunk, first_attempt_stream) - else: - stream_gen = stream_request(body=payload, native=False) - return StreamingResponse(stream_gen, media_type="text/event-stream") - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - stream_request_wrapper, - anti_truncation_payload, - max_attempts, - enable_prefill_mode=True, - ) - - # 包装以确保是bytes流 - async def bytes_wrapper(): - async for chunk in processor.process_stream(): - if isinstance(chunk, str): - yield chunk.encode('utf-8') - else: - yield chunk - - # 直接将整个流传递给转换器 - async for anthropic_chunk in gemini_stream_to_anthropic_stream( - bytes_wrapper(), - real_model, - 200 - ): - if anthropic_chunk: - yield anthropic_chunk - - # ========== 普通流式生成器 ========== - async def normal_stream_generator(): - from src.api.geminicli import stream_request - from fastapi import Response - from src.converter.anthropic2gemini import gemini_stream_to_anthropic_stream - - # 调用 API 层的流式请求(不使用 native 模式) - stream_gen = stream_request(body=api_request, native=False) - try: - first_chunk = await read_first_async_item(stream_gen) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - # 包装流式生成器以处理错误响应 - async def gemini_chunk_wrapper(): - async for chunk in prepend_async_item(first_chunk, stream_gen): - # 检查是否是Response对象(错误情况) - if isinstance(chunk, Response): - # 错误响应,不进行转换,直接传递 - try: - error_content = chunk.body if isinstance(chunk.body, bytes) else (chunk.body or b'').encode('utf-8') - gemini_error = json.loads(error_content.decode('utf-8')) - from src.converter.anthropic2gemini import gemini_to_anthropic_response - anthropic_error = gemini_to_anthropic_response( - gemini_error, - real_model, - chunk.status_code - ) - yield f"data: {json.dumps(anthropic_error)}\n\n".encode('utf-8') - except Exception: - yield f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': 'Stream error'}})}\n\n".encode('utf-8') - yield b"data: [DONE]\n\n" - return - else: - # 确保是bytes类型 - if isinstance(chunk, str): - yield chunk.encode('utf-8') - else: - yield chunk - - # 使用转换器处理整个流 - async for anthropic_chunk in gemini_stream_to_anthropic_stream( - gemini_chunk_wrapper(), - real_model, - 200 - ): - if anthropic_chunk: - yield anthropic_chunk - - # ========== 根据模式选择生成器 ========== - if use_fake_streaming: - return await build_streaming_response_or_error(fake_stream_generator()) - elif use_anti_truncation: - log.info("启用流式抗截断功能") - return await build_streaming_response_or_error(anti_truncation_generator()) - else: - return await build_streaming_response_or_error(normal_stream_generator()) - - -@router.post("/v1/messages/count_tokens") -async def count_tokens( - request: Request, - _token: str = Depends(authenticate_bearer) -): - """ - 处理Anthropic格式的token计数请求 - - Args: - request: FastAPI请求对象 - _token: Bearer认证令牌(由Depends验证) - - Returns: - JSONResponse: 包含input_tokens的响应 - """ - try: - payload = await request.json() - except Exception as e: - return JSONResponse( - status_code=400, - content={"type": "error", "error": {"type": "invalid_request_error", "message": f"JSON 解析失败: {str(e)}"}} - ) - - if not isinstance(payload, dict): - return JSONResponse( - status_code=400, - content={"type": "error", "error": {"type": "invalid_request_error", "message": "请求体必须为 JSON object"}} - ) - - if not payload.get("model") or not isinstance(payload.get("messages"), list): - return JSONResponse( - status_code=400, - content={"type": "error", "error": {"type": "invalid_request_error", "message": "缺少必填字段:model / messages"}} - ) - - try: - client_host = request.client.host if request.client else "unknown" - client_port = request.client.port if request.client else "unknown" - except Exception: - client_host = "unknown" - client_port = "unknown" - - thinking_present = "thinking" in payload - thinking_value = payload.get("thinking") - thinking_summary = None - if thinking_present: - if isinstance(thinking_value, dict): - thinking_summary = { - "type": thinking_value.get("type"), - "budget_tokens": thinking_value.get("budget_tokens"), - } - else: - thinking_summary = thinking_value - - user_agent = request.headers.get("user-agent", "") - log.info( - f"[GEMINICLI-ANTHROPIC] /messages/count_tokens 收到请求: client={client_host}:{client_port}, " - f"model={payload.get('model')}, messages={len(payload.get('messages') or [])}, " - f"thinking_present={thinking_present}, thinking={thinking_summary}, ua={user_agent}" - ) - - # 简单估算 - input_tokens = 0 - try: - input_tokens = estimate_input_tokens(payload) - except Exception as e: - log.error(f"[GEMINICLI-ANTHROPIC] token 估算失败: {e}") - - return JSONResponse(content={"input_tokens": input_tokens}) - - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示Anthropic路由的流式和非流式响应 - 运行方式: python src/router/geminicli/anthropic.py - """ - - from fastapi.testclient import TestClient - from fastapi import FastAPI - - print("=" * 80) - print("Anthropic Router 测试") - print("=" * 80) - - # 创建测试应用 - app = FastAPI() - app.include_router(router) - - # 测试客户端 - client = TestClient(app) - - # 测试请求体 (Anthropic格式) - test_request_body = { - "model": "gemini-2.5-flash", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, tell me a joke in one sentence."} - ] - } - - # 测试Bearer令牌(模拟) - test_token = "Bearer pwd" - - def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试1】非流式请求 (POST /v1/messages)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - response = client.post( - "/v1/messages", - json=test_request_body, - headers={"Authorization": test_token} - ) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - - try: - content = response.text - print(f"\n响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = response.json() - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试2】流式请求 (POST /v1/messages)") - print("=" * 80) - - stream_request_body = test_request_body.copy() - stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/messages", - json=stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("event: ") or chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - def test_fake_stream_request(): - """测试假流式请求""" - print("\n" + "=" * 80) - print("【测试3】假流式请求 (POST /v1/messages with 假流式 prefix)") - print("=" * 80) - - fake_stream_request_body = test_request_body.copy() - fake_stream_request_body["model"] = "假流式/gemini-2.5-flash" - fake_stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(fake_stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("假流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/messages", - json=fake_stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - chunk_str = chunk.decode('utf-8') - - print(f"\nChunk #{chunk_count}:") - print(f" 长度: {len(chunk_str)} 字节") - - # 解析chunk中的所有SSE事件 - events = [] - for line in chunk_str.split('\n'): - line = line.strip() - if line.startswith("data: ") or line.startswith("event: "): - events.append(line) - - print(f" 包含 {len(events)} 个SSE事件") - - # 显示每个事件 - for event_idx, event_line in enumerate(events, 1): - if event_line == "data: [DONE]": - print(f" 事件 #{event_idx}: [DONE]") - elif event_line.startswith("data: "): - try: - json_str = event_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - event_type = json_data.get("type", "unknown") - print(f" 事件 #{event_idx}: type={event_type}") - except Exception as e: - print(f" 事件 #{event_idx}: 解析失败 - {e}") - - print(f"\n总共收到 {chunk_count} 个HTTP chunk") - - # 运行测试 - try: - # 测试非流式请求 - test_non_stream_request() - - # 测试流式请求 - test_stream_request() - - # 测试假流式请求 - test_fake_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() diff --git a/src/router/geminicli/gemini.py b/src/router/geminicli/gemini.py deleted file mode 100644 index cd21de0330ebbc6703a02ab15c4e4185bb8ffdae..0000000000000000000000000000000000000000 --- a/src/router/geminicli/gemini.py +++ /dev/null @@ -1,683 +0,0 @@ -""" -Gemini Router - Handles native Gemini format API requests -处理原生Gemini格式请求的路由模块 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -import json - -# 第三方库 -from fastapi import APIRouter, Depends, HTTPException, Path, Request -from fastapi.responses import JSONResponse, StreamingResponse - -# 本地模块 - 配置和日志 -from config import get_anti_truncation_max_attempts -from log import log - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - is_anti_truncation_model, - authenticate_gemini_flexible, - is_fake_streaming_model -) - -# 本地模块 - 转换器(假流式需要) -from src.converter.fake_stream import ( - parse_response_for_fake_stream, - build_gemini_fake_stream_chunks, - create_gemini_heartbeat_chunk, -) - -# 本地模块 - 基础路由工具 -from src.router.hi_check import is_health_check_request, create_health_check_response -from src.router.stream_passthrough import ( - build_streaming_response_or_error, - prepend_async_item, - read_first_async_item, -) - -# 本地模块 - 数据模型 -from src.models import GeminiRequest, model_to_dict - -# 本地模块 - 任务管理 -from src.task_manager import create_managed_task - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.post("/v1beta/models/{model:path}:generateContent") -@router.post("/v1/models/{model:path}:generateContent") -async def generate_content( - gemini_request: "GeminiRequest", - model: str = Path(..., description="Model name"), - api_key: str = Depends(authenticate_gemini_flexible), -): - """ - 处理Gemini格式的内容生成请求(非流式) - - Args: - gemini_request: Gemini格式的请求体 - model: 模型名称 - api_key: API 密钥 - """ - log.debug(f"[GEMINICLI] Non-streaming request for model: {model}") - - # 转换为字典 - normalized_dict = model_to_dict(gemini_request) - - # 健康检查 - if is_health_check_request(normalized_dict, format="gemini"): - response = create_health_check_response(format="gemini") - return JSONResponse(content=response) - - # 处理模型名称和功能检测 - use_anti_truncation = is_anti_truncation_model(model) - real_model = get_base_model_from_feature_model(model) - - # 对于抗截断模型的非流式请求,给出警告 - if use_anti_truncation: - log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置") - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # 规范化 Gemini 请求 (使用 geminicli 模式) - from src.converter.gemini_fix import normalize_gemini_request - normalized_dict = await normalize_gemini_request(normalized_dict, mode="geminicli") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_dict.pop("model"), - "request": normalized_dict - } - - # 调用 API 层的非流式请求 - from src.api.geminicli import non_stream_request - response = await non_stream_request(body=api_request) - - # 解包装响应:GeminiCli API 返回的格式有额外的 response 包装层 - # 需要提取 response.response 并返回标准 Gemini 格式 - try: - if response.status_code == 200: - response_data = json.loads(response.body if hasattr(response, 'body') else response.content) - # 如果有 response 包装,解包装它 - if "response" in response_data: - unwrapped_data = response_data["response"] - return JSONResponse(content=unwrapped_data) - # 错误响应或没有 response 字段,直接返回 - return response - except Exception as e: - log.warning(f"Failed to unwrap response: {e}, returning original response") - return response - -@router.post("/v1beta/models/{model:path}:streamGenerateContent") -@router.post("/v1/models/{model:path}:streamGenerateContent") -async def stream_generate_content( - gemini_request: GeminiRequest, - model: str = Path(..., description="Model name"), - api_key: str = Depends(authenticate_gemini_flexible), -): - """ - 处理Gemini格式的流式内容生成请求 - - Args: - gemini_request: Gemini格式的请求体 - model: 模型名称 - api_key: API 密钥 - """ - log.debug(f"[GEMINICLI] Streaming request for model: {model}") - - # 转换为字典 - normalized_dict = model_to_dict(gemini_request) - - # 处理模型名称和功能检测 - use_fake_streaming = is_fake_streaming_model(model) - use_anti_truncation = is_anti_truncation_model(model) - real_model = get_base_model_from_feature_model(model) - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # ========== 假流式生成器 ========== - async def fake_stream_generator(): - from src.converter.gemini_fix import normalize_gemini_request - from src.api.geminicli import non_stream_request - - normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="geminicli") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_req.pop("model"), - "request": normalized_req - } - - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - if hasattr(response, "status_code") and response.status_code != 200: - log.error(f"Fake streaming got error response: status={response.status_code}") - yield response - return - - # 处理成功响应 - 提取响应内容 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - response_data = json.loads(response_body) - log.debug(f"Gemini fake stream response data: {response_data}") - - # 检查是否是错误响应(有些错误可能status_code是200但包含error字段) - if "error" in response_data: - log.error(f"Fake streaming got error in response body: {response_data['error']}") - yield f"data: {json.dumps(response_data)}\n\n".encode() - yield "data: [DONE]\n\n".encode() - return - - # 使用统一的解析函数 - content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(response_data) - - log.debug(f"Gemini extracted content: {content}") - log.debug(f"Gemini extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...") - log.debug(f"Gemini extracted images count: {len(images)}") - - # 构建响应块 - chunks = build_gemini_fake_stream_chunks(content, reasoning_content, finish_reason, images) - for idx, chunk in enumerate(chunks): - chunk_json = json.dumps(chunk) - log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}") - yield f"data: {chunk_json}\n\n".encode() - - except Exception as e: - log.error(f"Response parsing failed: {e}, directly yield original response") - # 直接yield原始响应,不进行包装 - yield f"data: {response_body}\n\n".encode() - - yield "data: [DONE]\n\n".encode() - - # ========== 流式抗截断生成器 ========== - async def anti_truncation_generator(): - from src.converter.gemini_fix import normalize_gemini_request - from src.converter.anti_truncation import AntiTruncationStreamProcessor - from src.converter.anti_truncation import apply_anti_truncation - from src.api.geminicli import stream_request - from fastapi import Response - - # 先进行基础标准化 - normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="geminicli") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_req.pop("model") if "model" in normalized_req else real_model, - "request": normalized_req - } - - max_attempts = await get_anti_truncation_max_attempts() - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(api_request) - - first_attempt_stream = stream_request(body=anti_truncation_payload, native=False) - try: - first_chunk = await read_first_async_item(first_attempt_stream) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - first_attempt_pending = True - - async def stream_request_wrapper(payload): - nonlocal first_attempt_pending - - if first_attempt_pending: - first_attempt_pending = False - stream_gen = prepend_async_item(first_chunk, first_attempt_stream) - else: - stream_gen = stream_request(body=payload, native=False) - return StreamingResponse(stream_gen, media_type="text/event-stream") - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - stream_request_wrapper, - anti_truncation_payload, - max_attempts, - enable_prefill_mode=True, - ) - - # 迭代 process_stream() 生成器,并展开 response 包装 - async for chunk in processor.process_stream(): - if isinstance(chunk, (str, bytes)): - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 解析并展开 response 包装 - if chunk_str.startswith("data: "): - json_str = chunk_str[6:].strip() - - # 跳过 [DONE] 标记 - if json_str == "[DONE]": - yield chunk - continue - - try: - # 解析JSON - data = json.loads(json_str) - - # 展开 response 包装 - if "response" in data and "candidates" not in data: - log.debug(f"[GEMINICLI-ANTI-TRUNCATION] 展开response包装") - unwrapped_data = data["response"] - # 重新构建SSE格式 - yield f"data: {json.dumps(unwrapped_data, ensure_ascii=False)}\n\n".encode('utf-8') - else: - # 已经是展开的格式,直接返回 - yield chunk - except json.JSONDecodeError: - # JSON解析失败,直接返回原始chunk - yield chunk - else: - # 不是SSE格式,直接返回 - yield chunk - else: - # 其他类型,直接返回 - yield chunk - - # ========== 普通流式生成器 ========== - async def normal_stream_generator(): - from src.converter.gemini_fix import normalize_gemini_request - from src.api.geminicli import stream_request - from fastapi import Response - - normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="geminicli") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": normalized_req.pop("model"), - "request": normalized_req - } - - # 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装 - log.debug(f"[GEMINICLI] 使用非native模式,将展开response包装") - stream_gen = stream_request(body=api_request, native=False) - try: - first_chunk = await read_first_async_item(stream_gen) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - # 展开 response 包装 - async for chunk in prepend_async_item(first_chunk, stream_gen): - # 检查是否是Response对象(错误情况) - if isinstance(chunk, Response): - # 将Response转换为SSE格式的错误消息 - try: - error_content = chunk.body if isinstance(chunk.body, bytes) else (chunk.body or b'').encode('utf-8') - error_json = json.loads(error_content.decode('utf-8')) - except Exception: - error_json = {"error": {"code": chunk.status_code, "message": "upstream error", "status": "ERROR"}} - log.error(f"[GEMINICLI STREAM] 返回错误给客户端: status={chunk.status_code}, error={str(error_json)[:200]}") - # 以SSE格式返回错误,并以[DONE]结束 - yield f"data: {json.dumps(error_json)}\n\n".encode('utf-8') - yield b"data: [DONE]\n\n" - return - - # 处理SSE格式的chunk - if isinstance(chunk, (str, bytes)): - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 解析并展开 response 包装 - if chunk_str.startswith("data: "): - json_str = chunk_str[6:].strip() - - # 跳过 [DONE] 标记 - if json_str == "[DONE]": - yield chunk - continue - - try: - # 解析JSON - data = json.loads(json_str) - - # 展开 response 包装 - if "response" in data and "candidates" not in data: - log.debug(f"[GEMINICLI] 展开response包装") - unwrapped_data = data["response"] - # 重新构建SSE格式 - yield f"data: {json.dumps(unwrapped_data, ensure_ascii=False)}\n\n".encode('utf-8') - else: - # 已经是展开的格式,直接返回 - yield chunk - except json.JSONDecodeError: - # JSON解析失败,直接返回原始chunk - yield chunk - else: - # 不是SSE格式,直接返回 - yield chunk - - # ========== 根据模式选择生成器 ========== - if use_fake_streaming: - return await build_streaming_response_or_error(fake_stream_generator()) - elif use_anti_truncation: - log.info("启用流式抗截断功能") - return await build_streaming_response_or_error(anti_truncation_generator()) - else: - return await build_streaming_response_or_error(normal_stream_generator()) - -@router.post("/v1beta/models/{model:path}:countTokens") -@router.post("/v1/models/{model:path}:countTokens") -async def count_tokens( - request: Request = None, - api_key: str = Depends(authenticate_gemini_flexible), -): - """ - 模拟Gemini格式的token计数 - - 使用简单的启发式方法:大约4字符=1token - """ - - try: - request_data = await request.json() - except Exception as e: - log.error(f"Failed to parse JSON request: {e}") - raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") - - # 简单的token计数模拟 - 基于文本长度估算 - total_tokens = 0 - - # 如果有contents字段 - if "contents" in request_data: - for content in request_data["contents"]: - if "parts" in content: - for part in content["parts"]: - if "text" in part: - # 简单估算:大约4字符=1token - text_length = len(part["text"]) - total_tokens += max(1, text_length // 4) - - # 如果有generateContentRequest字段 - elif "generateContentRequest" in request_data: - gen_request = request_data["generateContentRequest"] - if "contents" in gen_request: - for content in gen_request["contents"]: - if "parts" in content: - for part in content["parts"]: - if "text" in part: - text_length = len(part["text"]) - total_tokens += max(1, text_length // 4) - - # 返回Gemini格式的响应 - return JSONResponse(content={"totalTokens": total_tokens}) - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示Gemini路由的流式和非流式响应 - 运行方式: python src/router/geminicli/gemini.py - """ - - from fastapi.testclient import TestClient - from fastapi import FastAPI - - print("=" * 80) - print("Gemini Router 测试") - print("=" * 80) - - # 创建测试应用 - app = FastAPI() - app.include_router(router) - - # 测试客户端 - client = TestClient(app) - - # 测试请求体 (Gemini格式) - test_request_body = { - "contents": [ - { - "role": "user", - "parts": [{"text": "Hello, tell me a joke in one sentence."}] - } - ] - } - - # 测试API密钥(模拟) - test_api_key = "pwd" - - def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试2】非流式请求 (POST /v1/models/gemini-2.5-flash:generateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - response = client.post( - "/v1/models/gemini-2.5-flash:generateContent", - json=test_request_body, - params={"key": test_api_key} - ) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - - try: - content = response.text - print(f"\n响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = response.json() - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试3】流式请求 (POST /v1/models/gemini-2.5-flash:streamGenerateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/models/gemini-2.5-flash:streamGenerateContent", - json=test_request_body, - params={"key": test_api_key} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - def test_fake_stream_request(): - """测试假流式请求""" - print("\n" + "=" * 80) - print("【测试4】假流式请求 (POST /v1/models/假流式/gemini-2.5-flash:streamGenerateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - print("假流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/models/假流式/gemini-2.5-flash:streamGenerateContent", - json=test_request_body, - params={"key": test_api_key} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - chunk_str = chunk.decode('utf-8') - - print(f"\nChunk #{chunk_count}:") - print(f" 长度: {len(chunk_str)} 字节") - - # 解析chunk中的所有SSE事件 - events = [] - for line in chunk_str.split('\n'): - line = line.strip() - if line.startswith("data: "): - events.append(line) - - print(f" 包含 {len(events)} 个SSE事件") - - # 显示每个事件 - for event_idx, event_line in enumerate(events, 1): - if event_line == "data: [DONE]": - print(f" 事件 #{event_idx}: [DONE]") - else: - try: - json_str = event_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - # 提取text内容 - text = json_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") - finish_reason = json_data.get("candidates", [{}])[0].get("finishReason") - print(f" 事件 #{event_idx}: text={repr(text[:50])}{'...' if len(text) > 50 else ''}, finishReason={finish_reason}") - except Exception as e: - print(f" 事件 #{event_idx}: 解析失败 - {e}") - - print(f"\n总共收到 {chunk_count} 个HTTP chunk") - - def test_anti_truncation_stream_request(): - """测试流式抗截断请求""" - print("\n" + "=" * 80) - print("【测试5】流式抗截断请求 (POST /v1/models/流式抗截断/gemini-2.5-flash:streamGenerateContent)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式抗截断响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/models/流式抗截断/gemini-2.5-flash:streamGenerateContent", - json=test_request_body, - params={"key": test_api_key} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - # 运行测试 - try: - # 测试非流式请求 - test_non_stream_request() - - # 测试流式请求 - test_stream_request() - - # 测试假流式请求 - test_fake_stream_request() - - # 测试流式抗截断请求 - test_anti_truncation_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() diff --git a/src/router/geminicli/model_list.py b/src/router/geminicli/model_list.py deleted file mode 100644 index 333a987a8f2d2b99155436ad2bc35349bd2e325f..0000000000000000000000000000000000000000 --- a/src/router/geminicli/model_list.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Gemini CLI Model List Router - Handles model list requests -Gemini CLI 模型列表路由 - 处理模型列表请求 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 第三方库 -from fastapi import APIRouter, Depends -from fastapi.responses import JSONResponse - -# 本地模块 - 工具和认证 -from src.utils import ( - get_available_models, - get_base_model_from_feature_model, - authenticate_flexible -) - -# 本地模块 - 基础路由工具 -from src.router.base_router import create_gemini_model_list, create_openai_model_list -from src.models import model_to_dict -from log import log - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.get("/v1beta/models") -async def list_gemini_models(token: str = Depends(authenticate_flexible)): - """ - 返回 Gemini 格式的模型列表 - - 使用 create_gemini_model_list 工具函数创建标准格式 - """ - models = get_available_models("gemini") - log.info("[GEMINICLI MODEL LIST] 返回 Gemini 格式") - return JSONResponse(content=create_gemini_model_list( - models, - base_name_extractor=get_base_model_from_feature_model - )) - - -@router.get("/v1/models") -async def list_openai_models(token: str = Depends(authenticate_flexible)): - """ - 返回 OpenAI 格式的模型列表 - - 使用 create_openai_model_list 工具函数创建标准格式 - """ - models = get_available_models("gemini") - log.info("[GEMINICLI MODEL LIST] 返回 OpenAI 格式") - model_list = create_openai_model_list(models, owned_by="google") - return JSONResponse(content={ - "object": "list", - "data": [model_to_dict(model) for model in model_list.data] - }) diff --git a/src/router/geminicli/openai.py b/src/router/geminicli/openai.py deleted file mode 100644 index 353b725005c16269000616bbab69b2be80d88a4d..0000000000000000000000000000000000000000 --- a/src/router/geminicli/openai.py +++ /dev/null @@ -1,590 +0,0 @@ -""" -OpenAI Router - Handles OpenAI format API requests via GeminiCLI -通过GeminiCLI处理OpenAI格式请求的路由模块 -""" - -import sys -from pathlib import Path - -# 添加项目根目录到Python路径 -project_root = Path(__file__).resolve().parent.parent.parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - -# 标准库 -import asyncio -import json - -# 第三方库 -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse - -# 本地模块 - 配置和日志 -from config import get_anti_truncation_max_attempts -from log import log - -# 本地模块 - 工具和认证 -from src.utils import ( - get_base_model_from_feature_model, - is_anti_truncation_model, - is_fake_streaming_model, - authenticate_bearer, -) - -# 本地模块 - 转换器(假流式需要) -from src.converter.fake_stream import ( - parse_response_for_fake_stream, - build_openai_fake_stream_chunks, - create_openai_heartbeat_chunk, -) - -# 本地模块 - 基础路由工具 -from src.router.hi_check import is_health_check_request, create_health_check_response -from src.router.stream_passthrough import ( - build_streaming_response_or_error, - prepend_async_item, - read_first_async_item, -) - -# 本地模块 - 数据模型 -from src.models import OpenAIChatCompletionRequest, model_to_dict - -# 本地模块 - 任务管理 -from src.task_manager import create_managed_task - - -# ==================== 路由器初始化 ==================== - -router = APIRouter() - - -# ==================== API 路由 ==================== - -@router.post("/v1/chat/completions") -async def chat_completions( - openai_request: OpenAIChatCompletionRequest, - token: str = Depends(authenticate_bearer) -): - """ - 处理OpenAI格式的聊天完成请求(流式和非流式) - - Args: - openai_request: OpenAI格式的请求体 - token: Bearer认证令牌 - """ - log.debug(f"[GEMINICLI-OPENAI] Request for model: {openai_request.model}") - - # 转换为字典 - normalized_dict = model_to_dict(openai_request) - - # 健康检查 - if is_health_check_request(normalized_dict, format="openai"): - response = create_health_check_response(format="openai") - return JSONResponse(content=response) - - # 处理模型名称和功能检测 - use_fake_streaming = is_fake_streaming_model(openai_request.model) - use_anti_truncation = is_anti_truncation_model(openai_request.model) - real_model = get_base_model_from_feature_model(openai_request.model) - - # 获取流式标志 - is_streaming = openai_request.stream - - # 对于抗截断模型的非流式请求,给出警告 - if use_anti_truncation and not is_streaming: - log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置") - - # 更新模型名为真实模型名 - normalized_dict["model"] = real_model - - # 转换为 Gemini 格式 (使用 converter) - from src.converter.openai2gemini import convert_openai_to_gemini_request - gemini_dict = await convert_openai_to_gemini_request(normalized_dict) - - # convert_openai_to_gemini_request 不包含 model 字段,需要手动添加 - gemini_dict["model"] = real_model - - # 规范化 Gemini 请求 (使用 geminicli 模式) - from src.converter.gemini_fix import normalize_gemini_request - gemini_dict = await normalize_gemini_request(gemini_dict, mode="geminicli") - - # 准备API请求格式 - 提取model并将其他字段放入request中 - api_request = { - "model": gemini_dict.pop("model"), - "request": gemini_dict - } - - # ========== 非流式请求 ========== - if not is_streaming: - # 调用 API 层的非流式请求 - from src.api.geminicli import non_stream_request - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - status_code = getattr(response, "status_code", 200) - - # 提取响应体 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - except Exception as e: - log.error(f"Failed to parse Gemini response: {e}") - raise HTTPException(status_code=500, detail="Response parsing failed") - - # 转换为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_response - openai_response = convert_gemini_to_openai_response( - gemini_response, - real_model, - status_code - ) - - return JSONResponse(content=openai_response, status_code=status_code) - - # ========== 流式请求 ========== - - # ========== 假流式生成器 ========== - async def fake_stream_generator(): - from src.api.geminicli import non_stream_request - - response = await non_stream_request(body=api_request) - - # 检查响应状态码 - if hasattr(response, "status_code") and response.status_code != 200: - log.error(f"Fake streaming got error response: status={response.status_code}") - yield response - return - - # 处理成功响应 - 提取响应内容 - if hasattr(response, "body"): - response_body = response.body.decode() if isinstance(response.body, bytes) else response.body - elif hasattr(response, "content"): - response_body = response.content.decode() if isinstance(response.content, bytes) else response.content - else: - response_body = str(response) - - try: - gemini_response = json.loads(response_body) - log.debug(f"OpenAI fake stream Gemini response: {gemini_response}") - - # 检查是否是错误响应(有些错误可能status_code是200但包含error字段) - if "error" in gemini_response: - log.error(f"Fake streaming got error in response body: {gemini_response['error']}") - # 转换错误为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_response - openai_error = convert_gemini_to_openai_response( - gemini_response, - real_model, - 200 - ) - yield f"data: {json.dumps(openai_error)}\n\n".encode() - yield "data: [DONE]\n\n".encode() - return - - # 使用统一的解析函数 - content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(gemini_response) - - log.debug(f"OpenAI extracted content: {content}") - log.debug(f"OpenAI extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...") - log.debug(f"OpenAI extracted images count: {len(images)}") - - # 构建响应块 - chunks = build_openai_fake_stream_chunks(content, reasoning_content, finish_reason, real_model, images) - for idx, chunk in enumerate(chunks): - chunk_json = json.dumps(chunk) - log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}") - yield f"data: {chunk_json}\n\n".encode() - - except Exception as e: - log.error(f"Response parsing failed: {e}, directly yield error") - # 构建错误响应 - error_chunk = { - "id": "error", - "object": "chat.completion.chunk", - "created": int(asyncio.get_event_loop().time()), - "model": real_model, - "choices": [{ - "index": 0, - "delta": {"content": f"Error: {str(e)}"}, - "finish_reason": "error" - }] - } - yield f"data: {json.dumps(error_chunk)}\n\n".encode() - - yield "data: [DONE]\n\n".encode() - - # ========== 流式抗截断生成器 ========== - async def anti_truncation_generator(): - from src.converter.anti_truncation import AntiTruncationStreamProcessor - from src.api.geminicli import stream_request - from src.converter.anti_truncation import apply_anti_truncation - from fastapi import Response - - max_attempts = await get_anti_truncation_max_attempts() - - # 首先对payload应用反截断指令 - anti_truncation_payload = apply_anti_truncation(api_request) - - first_attempt_stream = stream_request(body=anti_truncation_payload, native=False) - try: - first_chunk = await read_first_async_item(first_attempt_stream) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - first_attempt_pending = True - - async def stream_request_wrapper(payload): - nonlocal first_attempt_pending - - if first_attempt_pending: - first_attempt_pending = False - stream_gen = prepend_async_item(first_chunk, first_attempt_stream) - else: - stream_gen = stream_request(body=payload, native=False) - - return StreamingResponse(stream_gen, media_type="text/event-stream") - - # 创建反截断处理器 - processor = AntiTruncationStreamProcessor( - stream_request_wrapper, - anti_truncation_payload, - max_attempts, - enable_prefill_mode=True, - ) - - # 转换为 OpenAI 格式 - import uuid - response_id = str(uuid.uuid4()) - - # 直接迭代 process_stream() 生成器,并转换为 OpenAI 格式 - async for chunk in processor.process_stream(): - if not chunk: - continue - - # 解析 Gemini SSE 格式 - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 跳过空行 - if not chunk_str.strip(): - continue - - # 处理 [DONE] 标记 - if chunk_str.strip() == "data: [DONE]": - yield "data: [DONE]\n\n".encode('utf-8') - return - - # 解析 "data: {...}" 格式 - if chunk_str.startswith("data: "): - try: - # 转换为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_stream - openai_chunk_str = convert_gemini_to_openai_stream( - chunk_str, - real_model, - response_id - ) - - if openai_chunk_str: - yield openai_chunk_str.encode('utf-8') - - except Exception as e: - log.error(f"Failed to convert chunk: {e}") - continue - - # 发送结束标记 - yield "data: [DONE]\n\n".encode('utf-8') - - # ========== 普通流式生成器 ========== - async def normal_stream_generator(): - from src.api.geminicli import stream_request - from fastapi import Response - import uuid - - # 调用 API 层的流式请求(不使用 native 模式) - stream_gen = stream_request(body=api_request, native=False) - try: - first_chunk = await read_first_async_item(stream_gen) - except StopAsyncIteration: - return - - if isinstance(first_chunk, Response): - yield first_chunk - return - - response_id = str(uuid.uuid4()) - - # yield所有数据,处理可能的错误Response - async for chunk in prepend_async_item(first_chunk, stream_gen): - # 检查是否是Response对象(错误情况) - if isinstance(chunk, Response): - # 将Response转换为SSE格式的错误消息 - try: - error_content = chunk.body if isinstance(chunk.body, bytes) else (chunk.body or b'').encode('utf-8') - gemini_error = json.loads(error_content.decode('utf-8')) - # 转换为 OpenAI 格式错误 - from src.converter.openai2gemini import convert_gemini_to_openai_response - openai_error = convert_gemini_to_openai_response( - gemini_error, - real_model, - chunk.status_code - ) - yield f"data: {json.dumps(openai_error)}\n\n".encode('utf-8') - except Exception: - yield f"data: {json.dumps({'error': 'Stream error'})}\n\n".encode('utf-8') - yield b"data: [DONE]\n\n" - return - else: - # 正常的bytes数据,转换为 OpenAI 格式 - chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk - - # 跳过空行 - if not chunk_str.strip(): - continue - - # 处理 [DONE] 标记 - if chunk_str.strip() == "data: [DONE]": - yield "data: [DONE]\n\n".encode('utf-8') - return - - # 解析并转换 Gemini chunk 为 OpenAI 格式 - if chunk_str.startswith("data: "): - try: - # 转换为 OpenAI 格式 - from src.converter.openai2gemini import convert_gemini_to_openai_stream - openai_chunk_str = convert_gemini_to_openai_stream( - chunk_str, - real_model, - response_id - ) - - if openai_chunk_str: - yield openai_chunk_str.encode('utf-8') - - except Exception as e: - log.error(f"Failed to convert chunk: {e}") - continue - - # 发送结束标记 - yield "data: [DONE]\n\n".encode('utf-8') - - # ========== 根据模式选择生成器 ========== - if use_fake_streaming: - return await build_streaming_response_or_error(fake_stream_generator()) - elif use_anti_truncation: - log.info("启用流式抗截断功能") - return await build_streaming_response_or_error(anti_truncation_generator()) - else: - return await build_streaming_response_or_error(normal_stream_generator()) - - -# ==================== 测试代码 ==================== - -if __name__ == "__main__": - """ - 测试代码:演示OpenAI路由的流式和非流式响应 - 运行方式: python src/router/geminicli/openai.py - """ - - from fastapi.testclient import TestClient - from fastapi import FastAPI - - print("=" * 80) - print("OpenAI Router 测试") - print("=" * 80) - - # 创建测试应用 - app = FastAPI() - app.include_router(router) - - # 测试客户端 - client = TestClient(app) - - # 测试请求体 (OpenAI格式) - test_request_body = { - "model": "gemini-2.5-flash", - "messages": [ - {"role": "user", "content": "Hello, tell me a joke in one sentence."} - ] - } - - # 测试Bearer令牌(模拟) - test_token = "Bearer pwd" - - def test_non_stream_request(): - """测试非流式请求""" - print("\n" + "=" * 80) - print("【测试1】非流式请求 (POST /v1/chat/completions)") - print("=" * 80) - print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n") - - response = client.post( - "/v1/chat/completions", - json=test_request_body, - headers={"Authorization": test_token} - ) - - print("非流式响应数据:") - print("-" * 80) - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}") - - try: - content = response.text - print(f"\n响应内容 (原始):\n{content}\n") - - # 尝试解析JSON - try: - json_data = response.json() - print(f"响应内容 (格式化JSON):") - print(json.dumps(json_data, indent=2, ensure_ascii=False)) - except json.JSONDecodeError: - print("(非JSON格式)") - except Exception as e: - print(f"内容解析失败: {e}") - - def test_stream_request(): - """测试流式请求""" - print("\n" + "=" * 80) - print("【测试2】流式请求 (POST /v1/chat/completions)") - print("=" * 80) - - stream_request_body = test_request_body.copy() - stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/chat/completions", - json=stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - print(f"\nChunk #{chunk_count}:") - print(f" 类型: {type(chunk).__name__}") - print(f" 长度: {len(chunk)}") - - # 解码chunk - try: - chunk_str = chunk.decode('utf-8') - print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}") - - # 如果是SSE格式,尝试解析每一行 - if chunk_str.startswith("data: "): - # 按行分割,处理每个SSE事件 - for line in chunk_str.strip().split('\n'): - line = line.strip() - if not line: - continue - - if line == "data: [DONE]": - print(f" => 流结束标记") - elif line.startswith("data: "): - try: - json_str = line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}") - except Exception as e: - print(f" SSE解析失败: {e}") - except Exception as e: - print(f" 解码失败: {e}") - - print(f"\n总共收到 {chunk_count} 个chunk") - - def test_fake_stream_request(): - """测试假流式请求""" - print("\n" + "=" * 80) - print("【测试3】假流式请求 (POST /v1/chat/completions with 假流式 prefix)") - print("=" * 80) - - fake_stream_request_body = test_request_body.copy() - fake_stream_request_body["model"] = "假流式/gemini-2.5-flash" - fake_stream_request_body["stream"] = True - - print(f"请求体: {json.dumps(fake_stream_request_body, indent=2, ensure_ascii=False)}\n") - - print("假流式响应数据 (每个chunk):") - print("-" * 80) - - with client.stream( - "POST", - "/v1/chat/completions", - json=fake_stream_request_body, - headers={"Authorization": test_token} - ) as response: - print(f"状态码: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n") - - chunk_count = 0 - for chunk in response.iter_bytes(): - if chunk: - chunk_count += 1 - chunk_str = chunk.decode('utf-8') - - print(f"\nChunk #{chunk_count}:") - print(f" 长度: {len(chunk_str)} 字节") - - # 解析chunk中的所有SSE事件 - events = [] - for line in chunk_str.split('\n'): - line = line.strip() - if line.startswith("data: "): - events.append(line) - - print(f" 包含 {len(events)} 个SSE事件") - - # 显示每个事件 - for event_idx, event_line in enumerate(events, 1): - if event_line == "data: [DONE]": - print(f" 事件 #{event_idx}: [DONE]") - else: - try: - json_str = event_line[6:] # 去掉 "data: " 前缀 - json_data = json.loads(json_str) - # 提取content内容 - content = json_data.get("choices", [{}])[0].get("delta", {}).get("content", "") - finish_reason = json_data.get("choices", [{}])[0].get("finish_reason") - print(f" 事件 #{event_idx}: content={repr(content[:50])}{'...' if len(content) > 50 else ''}, finish_reason={finish_reason}") - except Exception as e: - print(f" 事件 #{event_idx}: 解析失败 - {e}") - - print(f"\n总共收到 {chunk_count} 个HTTP chunk") - - # 运行测试 - try: - # 测试非流式请求 - test_non_stream_request() - - # 测试流式请求 - test_stream_request() - - # 测试假流式请求 - test_fake_stream_request() - - print("\n" + "=" * 80) - print("测试完成") - print("=" * 80) - - except Exception as e: - print(f"\n❌ 测试过程中出现异常: {e}") - import traceback - traceback.print_exc() diff --git a/src/router/hi_check.py b/src/router/hi_check.py deleted file mode 100644 index b3ec7a2928235624e3f1217f78dc26a9090e6909..0000000000000000000000000000000000000000 --- a/src/router/hi_check.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -统一的健康检查(Hi消息)处理模块 - -提供对OpenAI、Gemini和Anthropic格式的Hi消息的解析和响应 -""" -import time -from typing import Any, Dict, List - - -# ==================== Hi消息检测 ==================== - -def is_health_check_request(request_data: dict, format: str = "openai") -> bool: - """ - 检查是否是健康检查请求(Hi消息) - - Args: - request_data: 请求数据 - format: 请求格式("openai"、"gemini" 或 "anthropic") - - Returns: - 是否是健康检查请求 - """ - if format == "openai": - # OpenAI格式健康检查: {"messages": [{"role": "user", "content": "Hi"}]} - messages = request_data.get("messages", []) - if len(messages) == 1: - msg = messages[0] - if msg.get("role") == "user" and msg.get("content") == "Hi": - return True - - elif format == "gemini": - # Gemini格式健康检查: {"contents": [{"role": "user", "parts": [{"text": "Hi"}]}]} - contents = request_data.get("contents", []) - if len(contents) == 1: - content = contents[0] - if (content.get("role") == "user" and - content.get("parts", [{}])[0].get("text") == "Hi"): - return True - - elif format == "anthropic": - # Anthropic格式健康检查: {"messages": [{"role": "user", "content": "Hi"}]} - messages = request_data.get("messages", []) - if (len(messages) == 1 - and messages[0].get("role") == "user" - and messages[0].get("content") == "Hi"): - return True - - return False - - -def is_health_check_message(messages: List[Dict[str, Any]]) -> bool: - """ - 直接检查消息列表是否为健康检查消息(Anthropic专用) - - 这是一个便捷函数,用于已经提取出消息列表的场景。 - - Args: - messages: 消息列表 - - Returns: - 是否为健康检查消息 - """ - return ( - len(messages) == 1 - and messages[0].get("role") == "user" - and messages[0].get("content") == "Hi" - ) - - -# ==================== Hi消息响应生成 ==================== - -def create_health_check_response(format: str = "openai", **kwargs) -> dict: - """ - 创建健康检查响应 - - Args: - format: 响应格式("openai"、"gemini" 或 "anthropic") - **kwargs: 格式特定的额外参数 - - model: 模型名称(anthropic格式需要) - - message_id: 消息ID(anthropic格式需要) - - Returns: - 健康检查响应字典 - """ - if format == "openai": - # OpenAI格式响应 - return { - "id": "healthcheck", - "object": "chat.completion", - "created": int(time.time()), - "model": "healthcheck", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "API is working" - }, - "finish_reason": "stop" - }] - } - - elif format == "gemini": - # Gemini格式响应 - return { - "candidates": [{ - "content": { - "parts": [{"text": "gcli2api工作中"}], - "role": "model" - }, - "finishReason": "STOP", - "index": 0, - }] - } - - elif format == "anthropic": - # Anthropic格式响应 - model = kwargs.get("model", "claude-unknown") - message_id = kwargs.get("message_id", "msg_healthcheck") - return { - "id": message_id, - "type": "message", - "role": "assistant", - "model": str(model), - "content": [{"type": "text", "text": "antigravity Anthropic Messages 正常工作中"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": {"input_tokens": 0, "output_tokens": 0}, - } - - # 未知格式返回空字典 - return {} diff --git a/src/router/stream_passthrough.py b/src/router/stream_passthrough.py deleted file mode 100644 index 95747107ae14b351569a1d52a84b85f0ac7bb696..0000000000000000000000000000000000000000 --- a/src/router/stream_passthrough.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any, AsyncIterator - -from fastapi import Response -from fastapi.responses import StreamingResponse - - -async def prepend_async_item(first_item: Any, iterator: AsyncIterator[Any]): - """Yield a prefetched item before continuing the original iterator.""" - yield first_item - async for item in iterator: - yield item - - -async def read_first_async_item(iterator: AsyncIterator[Any]) -> Any: - """Python 3.9-compatible async equivalent of built-in anext().""" - return await iterator.__anext__() - - -async def build_streaming_response_or_error( - iterator: AsyncIterator[Any], - media_type: str = "text/event-stream", -): - """ - Prefetch the first async item so router code can return an upstream error - response directly before FastAPI commits a 200 streaming response. - """ - try: - first_item = await read_first_async_item(iterator) - except StopAsyncIteration: - return Response(status_code=204) - - if isinstance(first_item, Response): - return first_item - - return StreamingResponse( - prepend_async_item(first_item, iterator), - media_type=media_type, - ) diff --git a/src/router/xtc_recognize/__init__.py b/src/router/xtc_recognize/__init__.py deleted file mode 100644 index f5db50527fad25b36dc6926041b61cf8b7635158..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""XTC 听歌识曲模块 - 无侵入集成到 gcli2api。 - -提供路由:/xtc/recognize, /xtc/song/url, /xtc/song/detail, /xtc/health -""" -from .router import router - -__all__ = ["router"] diff --git a/src/router/xtc_recognize/afp/afp.js b/src/router/xtc_recognize/afp/afp.js deleted file mode 100644 index c387e0bb15e88b113c6cfd8f0143f970d8dc7182..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/afp/afp.js +++ /dev/null @@ -1,1626 +0,0 @@ -'use strict' -const WASM_BINARY_PLACEHOLDER = 'WASM_BINARY_PLACEHOLDER'; -// See https://github.com/Distributive-Network/PythonMonkey/issues/266 -if (typeof globalThis.setInterval != 'function'){ - globalThis.setInterval = function pm$$setInterval(fn, timeout) { - const timerHnd = { cancel: false }; - function fnWrapper() - { - if (timerHnd.cancel) - return; - setTimeout(fnWrapper, timeout); - fn(); - } - timerHnd.id = setTimeout(fnWrapper, timeout); - return timerHnd; - } - globalThis.clearInterval = function pm$$clearInterval(timerHnd) { - timerHnd.clear = true; - clearTimeout(timerHnd.id); - } -} -globalThis.b64decode = function b64decode(data) { - return Uint8Array.from(atob(data), c => c.charCodeAt(0)); -} -globalThis.b64encode = function b64encode(data) { - return btoa(String.fromCharCode(...data)); -} -// https://fn.music.163.com/g/chrome-extension-home-page-beta/ -let AudioFingerprintRuntime = (() => { - var n, o = void 0 !== o ? o : {},i = {}; - for (n in o) - o.hasOwnProperty(n) && (i[n] = o[n]); - var read_, readSync, readBinary, c, f, l = [], - p = "./this.program", - readSync = function(t, e, r) { - switch (t) { - case WASM_BINARY_PLACEHOLDER: - if (typeof WASM_BINARY == 'undefined') { - const { WASM_BINARY } = require('./afp.wasm.js'); - e(globalThis.b64decode(WASM_BINARY)); - } else { - e(globalThis.b64decode(WASM_BINARY)); - } - default: - throw "Reading " + t + " is not supported"; - break; - } - } - var v = o.print || console.log.bind(console), - g = o.printErr || console.warn.bind(console); - for (n in i) - i.hasOwnProperty(n) && (o[n] = i[n]); - i = null, - o.arguments && (l = o.arguments), - o.thisProgram && (p = o.thisProgram), - o.quit && o.quit; - var w; - o.wasmBinary && (w = o.wasmBinary); - var b, _ = o.noExitRuntime || !0; - "object" != typeof WebAssembly && abort("no native wasm support detected"); - var C = !1; - - function T(t, e) { - t || abort("Assertion failed: " + e) - } - var UTF8Decoder = "undefined" != typeof TextDecoder ? new TextDecoder("utf8") : void 0; - - function UTF8ArrayToString(heap, idx, maxBytesToRead) { - var endIdx = idx + maxBytesToRead; - var endPtr = idx; - while (heap[endPtr] && !(endPtr >= endIdx)) ++endPtr; - if (endPtr - idx > 16 && heap.subarray && UTF8Decoder) { - return UTF8Decoder.decode(heap.subarray(idx, endPtr)) - } else { - var str = ""; - while (idx < endPtr) { - var u0 = heap[idx++]; - if (!(u0 & 128)) { - str += String.fromCharCode(u0); - continue - } - var u1 = heap[idx++] & 63; - if ((u0 & 224) == 192) { - str += String.fromCharCode((u0 & 31) << 6 | u1); - continue - } - var u2 = heap[idx++] & 63; - if ((u0 & 240) == 224) { - u0 = (u0 & 15) << 12 | u1 << 6 | u2 - } else { - u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | heap[idx++] & 63 - } - if (u0 < 65536) { - str += String.fromCharCode(u0) - } else { - var ch = u0 - 65536; - str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023) - } - } - } - return str - } - - function UTF8ToString(ptr, maxBytesToRead) { - return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : "" - } - function UTF8ToString(t, e) { - return t ? UTF8ArrayToString(O, t, e) : "" - } - - - function stringToUTF8Array(str, heap, outIdx, maxBytesToWrite) { - if (!(maxBytesToWrite > 0)) return 0; - var startIdx = outIdx; - var endIdx = outIdx + maxBytesToWrite - 1; - for (var i = 0; i < str.length; ++i) { - var u = str.charCodeAt(i); - if (u >= 55296 && u <= 57343) { - var u1 = str.charCodeAt(++i); - u = 65536 + ((u & 1023) << 10) | u1 & 1023 - } - if (u <= 127) { - if (outIdx >= endIdx) break; - heap[outIdx++] = u - } else if (u <= 2047) { - if (outIdx + 1 >= endIdx) break; - heap[outIdx++] = 192 | u >> 6; - heap[outIdx++] = 128 | u & 63 - } else if (u <= 65535) { - if (outIdx + 2 >= endIdx) break; - heap[outIdx++] = 224 | u >> 12; - heap[outIdx++] = 128 | u >> 6 & 63; - heap[outIdx++] = 128 | u & 63 - } else { - if (outIdx + 3 >= endIdx) break; - heap[outIdx++] = 240 | u >> 18; - heap[outIdx++] = 128 | u >> 12 & 63; - heap[outIdx++] = 128 | u >> 6 & 63; - heap[outIdx++] = 128 | u & 63 - } - } - heap[outIdx] = 0; - return outIdx - startIdx - } - - - function UTF8CharCount(t) { - for (var e = 0, r = 0; r < t.length; ++r) { - var n = t.charCodeAt(r); - n >= 55296 && n <= 57343 && (n = 65536 + ((1023 & n) << 10) | 1023 & t.charCodeAt(++r)), - n <= 127 ? ++e : e += n <= 2047 ? 2 : n <= 65535 ? 3 : 4 - } - return e - } - var E, S, O, k, W, j, R, M, I, UTF16Decoder = "undefined" != typeof TextDecoder ? new TextDecoder("utf-16le") : void 0; - - function UTF16ArrayToString(t, e) { - for (var r = t, n = r >> 1, o = n + e / 2; !(n >= o) && W[n];) - ++n; - if ((r = n << 1) - t > 32 && UTF16Decoder) - return UTF16Decoder.decode(O.subarray(t, r)); - for (var i = "", a = 0; !(a >= e / 2); ++a) { - var u = k[t + 2 * a >> 1]; - if (0 == u) - break; - i += String.fromCharCode(u) - } - return i - } - - function H(t, e, r) { - if (void 0 === r && (r = 2147483647), - r < 2) - return 0; - for (var n = e, o = (r -= 2) < 2 * t.length ? r / 2 : t.length, i = 0; i < o; ++i) { - var a = t.charCodeAt(i); - k[e >> 1] = a, - e += 2 - } - return k[e >> 1] = 0, - e - n - } - - function Y(t) { - return 2 * t.length - } - - function V(t, e) { - for (var r = 0, n = ""; !(r >= e / 4);) { - var o = j[t + 4 * r >> 2]; - if (0 == o) - break; - if (++r, - o >= 65536) { - var i = o - 65536; - n += String.fromCharCode(55296 | i >> 10, 56320 | 1023 & i) - } else - n += String.fromCharCode(o) - } - return n - } - - function z(t, e, r) { - if (void 0 === r && (r = 2147483647), - r < 4) - return 0; - for (var n = e, o = n + r - 4, i = 0; i < t.length; ++i) { - var a = t.charCodeAt(i); - if (a >= 55296 && a <= 57343) - a = 65536 + ((1023 & a) << 10) | 1023 & t.charCodeAt(++i); - if (j[e >> 2] = a, - (e += 4) + 4 > o) - break - } - return j[e >> 2] = 0, - e - n - } - - function B(t) { - for (var e = 0, r = 0; r < t.length; ++r) { - var n = t.charCodeAt(r); - n >= 55296 && n <= 57343 && ++r, - e += 4 - } - return e - } - o.INITIAL_MEMORY; - var L, G = [], - N = [], - q = []; - var J = 0, - X = null, - Z = null; - - function abort(what) { - throw o.onAbort && o.onAbort(what), - g(what = "Aborted(" + what + ")"), - C = !0, - 1, - what += ". Build with -s ASSERTIONS=1 for more info.", - new WebAssembly.RuntimeError(what) - } - o.preloadedImages = {}, - o.preloadedAudios = {}; - var Q; - - function isDataURI(t) { - return t.startsWith("data:application/octet-stream;base64,") - } - - function isFileURI(t) { - return t.startsWith("file://") - } - - function getBinary(file) { - try { - if (file == Q && w) - return new Uint8Array(w); - if (readBinary) - return readBinary(file); - throw "both async and sync fetching of the wasm failed" - } catch (t) { - abort(t) - } - } - - function callRuntimeCallbacks(cb) { - for (; cb.length > 0;) { - var func = cb.shift(); - if ("function" != typeof func) { - var r = func.func; - "number" == typeof r ? void 0 === func.arg ? it(r)() : it(r)(func.arg) : r(void 0 === func.arg ? null : func.arg) - } else - func(o) - } - } - var wasmBinaryFile = WASM_BINARY_PLACEHOLDER; - isDataURI(wasmBinaryFile) || (Q = function(t) { - return wasmBinaryFile - }(Q)); - var ot = []; - - function it(t) { - var e = ot[t]; - return e || (t >= ot.length && (ot.length = t + 1), - ot[t] = e = L.get(t)), - e - } - - function ExceptionInfo(excPtr) { - this.excPtr = excPtr, - this.ptr = excPtr - 16, - this.set_type = function(t) { - j[this.ptr + 4 >> 2] = t - }, - this.get_type = function() { - return j[this.ptr + 4 >> 2] - }, - this.set_destructor = function(t) { - j[this.ptr + 8 >> 2] = t - }, - this.get_destructor = function() { - return j[this.ptr + 8 >> 2] - }, - this.set_refcount = function(t) { - j[this.ptr >> 2] = t - }, - this.set_caught = function(t) { - t = t ? 1 : 0, - S[this.ptr + 12 >> 0] = t - }, - this.get_caught = function() { - return 0 != S[this.ptr + 12 >> 0] - }, - this.set_rethrown = function(t) { - t = t ? 1 : 0, - S[this.ptr + 13 >> 0] = t - }, - this.get_rethrown = function() { - return 0 != S[this.ptr + 13 >> 0] - }, - this.init = function(t, e) { - this.set_type(t), - this.set_destructor(e), - this.set_refcount(0), - this.set_caught(!1), - this.set_rethrown(!1) - }, - this.add_ref = function() { - var t = j[this.ptr >> 2]; - j[this.ptr >> 2] = t + 1 - }, - this.release_ref = function() { - var t = j[this.ptr >> 2]; - return j[this.ptr >> 2] = t - 1, - 1 === t - } - } - - function ut(t) { - switch (t) { - case 1: - return 0; - case 2: - return 1; - case 4: - return 2; - case 8: - return 3; - default: - throw new TypeError("Unknown type size: " + t) - } - } - var st = void 0; - - function ct(t) { - for (var e = "", r = t; O[r];) - e += st[O[r++]]; - return e - } - var ft = {}, - lt = {}, - pt = {}; - - function dt(t) { - if (void 0 === t) - return "_unknown"; - var e = (t = t.replace(/[^a-zA-Z0-9_]/g, "$")).charCodeAt(0); - return e >= 48 && e <= 57 ? "_" + t : t - } - - function ht(t, e) { - return t = dt(t), - new Function("body", "return function " + t + '() {\n "use strict"; return body.apply(this, arguments);\n};\n')(e) - } - - function yt(t, e) { - var r = ht(e, (function(t) { - this.name = e, - this.message = t; - var r = new Error(t).stack; - void 0 !== r && (this.stack = this.toString() + "\n" + r.replace(/^Error(:[^\n]*)?\n/, "")) - })); - return r.prototype = Object.create(t.prototype), - r.prototype.constructor = r, - r.prototype.toString = function() { - return void 0 === this.message ? this.name : this.name + ": " + this.message - }, - r - } - var mt = void 0; - - function vt(t) { - throw new mt(t) - } - var gt = void 0; - - function wt(t) { - throw new gt(t) - } - - function bt(t, e, r) { - function n(e) { - var n = r(e); - n.length !== t.length && wt("Mismatched type converter count"); - for (var o = 0; o < t.length; ++o) - _t(t[o], n[o]) - } - t.forEach((function(t) { - pt[t] = e - })); - var o = new Array(e.length), - i = [], - a = 0; - e.forEach((function(t, e) { - lt.hasOwnProperty(t) ? o[e] = lt[t] : (i.push(t), - ft.hasOwnProperty(t) || (ft[t] = []), - ft[t].push((function() { - o[e] = lt[t], - ++a === i.length && n(o) - }))) - })), - 0 === i.length && n(o) - } - - function _t(t, e, r) { - if (r = r || {}, - !("argPackAdvance" in e)) - throw new TypeError("registerType registeredInstance requires argPackAdvance"); - var n = e.name; - if (t || vt('type "' + n + '" must have a positive integer typeid pointer'), - lt.hasOwnProperty(t)) { - if (r.ignoreDuplicateRegistrations) - return; - vt("Cannot register type '" + n + "' twice") - } - if (lt[t] = e, - delete pt[t], - ft.hasOwnProperty(t)) { - var o = ft[t]; - delete ft[t], - o.forEach((function(t) { - t() - })) - } - } - - function Ct(t) { - if (!(this instanceof Rt)) - return !1; - if (!(t instanceof Rt)) - return !1; - for (var e = this.$$.ptrType.registeredClass, r = this.$$.ptr, n = t.$$.ptrType.registeredClass, o = t.$$.ptr; e.baseClass;) - r = e.upcast(r), - e = e.baseClass; - for (; n.baseClass;) - o = n.upcast(o), - n = n.baseClass; - return e === n && r === o - } - - function Tt(t) { - vt(t.$$.ptrType.registeredClass.name + " instance already deleted") - } - var $t = !1; - - function Pt(t) {} - - function At(t) { - t.count.value -= 1, - 0 === t.count.value && function(t) { - t.smartPtr ? t.smartPtrType.rawDestructor(t.smartPtr) : t.ptrType.registeredClass.rawDestructor(t.ptr) - }(t) - } - - function Dt(t) { - return "undefined" == typeof FinalizationRegistry ? (Dt = function(t) { - return t - }, - t) : ($t = new FinalizationRegistry((function(t) { - At(t.$$) - })), - Dt = function(t) { - var e = { - $$: t.$$ - }; - return $t.register(t, e, t), - t - }, - Pt = function(t) { - $t.unregister(t) - }, - Dt(t)) - } - - function Ft() { - if (this.$$.ptr || Tt(this), - this.$$.preservePointerOnDelete) - return this.$$.count.value += 1, - this; - var t, e = Dt(Object.create(Object.getPrototypeOf(this), { - $$: { - value: (t = this.$$, { - count: t.count, - deleteScheduled: t.deleteScheduled, - preservePointerOnDelete: t.preservePointerOnDelete, - ptr: t.ptr, - ptrType: t.ptrType, - smartPtr: t.smartPtr, - smartPtrType: t.smartPtrType - }) - } - })); - return e.$$.count.value += 1, - e.$$.deleteScheduled = !1, - e - } - - function Et() { - this.$$.ptr || Tt(this), - this.$$.deleteScheduled && !this.$$.preservePointerOnDelete && vt("Object already scheduled for deletion"), - Pt(this), - At(this.$$), - this.$$.preservePointerOnDelete || (this.$$.smartPtr = void 0, - this.$$.ptr = void 0) - } - - function St() { - return !this.$$.ptr - } - var Ot = void 0, - kt = []; - - function Wt() { - for (; kt.length;) { - var t = kt.pop(); - t.$$.deleteScheduled = !1, - t.delete() - } - } - - function jt() { - return this.$$.ptr || Tt(this), - this.$$.deleteScheduled && !this.$$.preservePointerOnDelete && vt("Object already scheduled for deletion"), - kt.push(this), - 1 === kt.length && Ot && Ot(Wt), - this.$$.deleteScheduled = !0, - this - } - - function Rt() {} - var Mt = {}; - - function It(t, e, r) { - if (void 0 === t[e].overloadTable) { - var n = t[e]; - t[e] = function() { - return t[e].overloadTable.hasOwnProperty(arguments.length) || vt("Function '" + r + "' called with an invalid number of arguments (" + arguments.length + ") - expects one of (" + t[e].overloadTable + ")!"), - t[e].overloadTable[arguments.length].apply(this, arguments) - }, - t[e].overloadTable = [], - t[e].overloadTable[n.argCount] = n - } - } - - function xt(t, e, r) { - o.hasOwnProperty(t) ? ((void 0 === r || void 0 !== o[t].overloadTable && void 0 !== o[t].overloadTable[r]) && vt("Cannot register public name '" + t + "' twice"), - It(o, t, t), - o.hasOwnProperty(r) && vt("Cannot register multiple overloads of a function with the same number of arguments (" + r + ")!"), - o[t].overloadTable[r] = e) : (o[t] = e, - void 0 !== r && (o[t].numArguments = r)) - } - - function Ut(t, e, r, n, o, i, a, u) { - this.name = t, - this.constructor = e, - this.instancePrototype = r, - this.rawDestructor = n, - this.baseClass = o, - this.getActualType = i, - this.upcast = a, - this.downcast = u, - this.pureVirtualFunctions = [] - } - - function Ht(t, e, r) { - for (; e !== r;) - e.upcast || vt("Expected null or instance of " + r.name + ", got an instance of " + e.name), - t = e.upcast(t), - e = e.baseClass; - return t - } - - function Yt(t, e) { - if (null === e) - return this.isReference && vt("null is not a valid " + this.name), - 0; - e.$$ || vt('Cannot pass "' + ge(e) + '" as a ' + this.name), - e.$$.ptr || vt("Cannot pass deleted object as a pointer of type " + this.name); - var r = e.$$.ptrType.registeredClass; - return Ht(e.$$.ptr, r, this.registeredClass) - } - - function Vt(t, e) { - var r; - if (null === e) - return this.isReference && vt("null is not a valid " + this.name), - this.isSmartPointer ? (r = this.rawConstructor(), - null !== t && t.push(this.rawDestructor, r), - r) : 0; - e.$$ || vt('Cannot pass "' + ge(e) + '" as a ' + this.name), - e.$$.ptr || vt("Cannot pass deleted object as a pointer of type " + this.name), - !this.isConst && e.$$.ptrType.isConst && vt("Cannot convert argument of type " + (e.$$.smartPtrType ? e.$$.smartPtrType.name : e.$$.ptrType.name) + " to parameter type " + this.name); - var n = e.$$.ptrType.registeredClass; - if (r = Ht(e.$$.ptr, n, this.registeredClass), - this.isSmartPointer) - switch (void 0 === e.$$.smartPtr && vt("Passing raw pointer to smart pointer is illegal"), - this.sharingPolicy) { - case 0: - e.$$.smartPtrType === this ? r = e.$$.smartPtr : vt("Cannot convert argument of type " + (e.$$.smartPtrType ? e.$$.smartPtrType.name : e.$$.ptrType.name) + " to parameter type " + this.name); - break; - case 1: - r = e.$$.smartPtr; - break; - case 2: - if (e.$$.smartPtrType === this) - r = e.$$.smartPtr; - else { - var o = e.clone(); - r = this.rawShare(r, ve.toHandle((function() { - o.delete() - }))), - null !== t && t.push(this.rawDestructor, r) - } - break; - default: - vt("Unsupporting sharing policy") - } - return r - } - - function zt(t, e) { - if (null === e) - return this.isReference && vt("null is not a valid " + this.name), - 0; - e.$$ || vt('Cannot pass "' + ge(e) + '" as a ' + this.name), - e.$$.ptr || vt("Cannot pass deleted object as a pointer of type " + this.name), - e.$$.ptrType.isConst && vt("Cannot convert argument of type " + e.$$.ptrType.name + " to parameter type " + this.name); - var r = e.$$.ptrType.registeredClass; - return Ht(e.$$.ptr, r, this.registeredClass) - } - - function Bt(t) { - return this.fromWireType(R[t >> 2]) - } - - function Lt(t) { - return this.rawGetPointee && (t = this.rawGetPointee(t)), - t - } - - function Gt(t) { - this.rawDestructor && this.rawDestructor(t) - } - - function Nt(t) { - null !== t && t.delete() - } - - function qt(t, e, r) { - if (e === r) - return t; - if (void 0 === r.baseClass) - return null; - var n = qt(t, e, r.baseClass); - return null === n ? null : r.downcast(n) - } - - function Jt() { - return Object.keys(Kt).length - } - - function Xt() { - var t = []; - for (var e in Kt) - Kt.hasOwnProperty(e) && t.push(Kt[e]); - return t - } - - function Zt(t) { - Ot = t, - kt.length && Ot && Ot(Wt) - } - var Kt = {}; - - function Qt(t, e) { - return e = function(t, e) { - for (void 0 === e && vt("ptr should not be undefined"); t.baseClass;) - e = t.upcast(e), - t = t.baseClass; - return e - }(t, e), - Kt[e] - } - - function te(t, e) { - return e.ptrType && e.ptr || wt("makeClassHandle requires ptr and ptrType"), - !!e.smartPtrType !== !!e.smartPtr && wt("Both smartPtrType and smartPtr must be specified"), - e.count = { - value: 1 - }, - Dt(Object.create(t, { - $$: { - value: e - } - })) - } - - function ee(t) { - var e = this.getPointee(t); - if (!e) - return this.destructor(t), - null; - var r = Qt(this.registeredClass, e); - if (void 0 !== r) { - if (0 === r.$$.count.value) - return r.$$.ptr = e, - r.$$.smartPtr = t, - r.clone(); - var n = r.clone(); - return this.destructor(t), - n - } - - function o() { - return this.isSmartPointer ? te(this.registeredClass.instancePrototype, { - ptrType: this.pointeeType, - ptr: e, - smartPtrType: this, - smartPtr: t - }) : te(this.registeredClass.instancePrototype, { - ptrType: this, - ptr: t - }) - } - var i, a = this.registeredClass.getActualType(e), - u = Mt[a]; - if (!u) - return o.call(this); - i = this.isConst ? u.constPointerType : u.pointerType; - var s = qt(e, this.registeredClass, i.registeredClass); - return null === s ? o.call(this) : this.isSmartPointer ? te(i.registeredClass.instancePrototype, { - ptrType: i, - ptr: s, - smartPtrType: this, - smartPtr: t - }) : te(i.registeredClass.instancePrototype, { - ptrType: i, - ptr: s - }) - } - - function re(t, e, r, n, o, i, a, u, s, c, f) { - this.name = t, - this.registeredClass = e, - this.isReference = r, - this.isConst = n, - this.isSmartPointer = o, - this.pointeeType = i, - this.sharingPolicy = a, - this.rawGetPointee = u, - this.rawConstructor = s, - this.rawShare = c, - this.rawDestructor = f, - o || void 0 !== e.baseClass ? this.toWireType = Vt : n ? (this.toWireType = Yt, - this.destructorFunction = null) : (this.toWireType = zt, - this.destructorFunction = null) - } - - function ne(t, e, r) { - o.hasOwnProperty(t) || wt("Replacing nonexistant public symbol"), - void 0 !== o[t].overloadTable && void 0 !== r ? o[t].overloadTable[r] = e : (o[t] = e, - o[t].argCount = r) - } - - function oe(t, e, r) { - return t.includes("j") ? function(t, e, r) { - var n = o["dynCall_" + t]; - return r && r.length ? n.apply(null, [e].concat(r)) : n.call(null, e) - }(t, e, r) : it(e).apply(null, r) - } - - function ie(t, e) { - var r, n, o, i = (t = ct(t)).includes("j") ? (r = t, - n = e, - o = [], - function() { - o.length = arguments.length; - for (var t = 0; t < arguments.length; t++) - o[t] = arguments[t]; - return oe(r, n, o) - } - ) : it(e); - return "function" != typeof i && vt("unknown function pointer with signature " + t + ": " + e), - i - } - var ae = void 0; - - function ue(t) { - var e = je(t), - r = ct(e); - return We(e), - r - } - - function se(t, e) { - var r = [], - n = {}; - throw e.forEach((function t(e) { - n[e] || lt[e] || (pt[e] ? pt[e].forEach(t) : (r.push(e), - n[e] = !0)) - })), - new ae(t + ": " + r.map(ue).join([", "])) - } - - function ce(t, e) { - for (var r = [], n = 0; n < t; n++) - r.push(j[(e >> 2) + n]); - return r - } - - function fe(t) { - for (; t.length;) { - var e = t.pop(); - t.pop()(e) - } - } - - function le(t, e, r, n, o) { - var i = e.length; - i < 2 && vt("argTypes array size mismatch! Must at least get return value and 'this' types!"); - for (var a = null !== e[1] && null !== r, u = !1, s = 1; s < e.length; ++s) - if (null !== e[s] && void 0 === e[s].destructorFunction) { - u = !0; - break - } - var c = "void" !== e[0].name, - f = "", - l = ""; - for (s = 0; s < i - 2; ++s) - f += (0 !== s ? ", " : "") + "arg" + s, - l += (0 !== s ? ", " : "") + "arg" + s + "Wired"; - var p = "return function " + dt(t) + "(" + f + ") {\nif (arguments.length !== " + (i - 2) + ") {\nthrowBindingError('function " + t + " called with ' + arguments.length + ' arguments, expected " + (i - 2) + " args!');\n}\n"; - u && (p += "var destructors = [];\n"); - var d = u ? "destructors" : "null", - h = ["throwBindingError", "invoker", "fn", "runDestructors", "retType", "classParam"], - y = [vt, n, o, fe, e[0], e[1]]; - a && (p += "var thisWired = classParam.toWireType(" + d + ", this);\n"); - for (s = 0; s < i - 2; ++s) - p += "var arg" + s + "Wired = argType" + s + ".toWireType(" + d + ", arg" + s + "); // " + e[s + 2].name + "\n", - h.push("argType" + s), - y.push(e[s + 2]); - if (a && (l = "thisWired" + (l.length > 0 ? ", " : "") + l), - p += (c ? "var rv = " : "") + "invoker(fn" + (l.length > 0 ? ", " : "") + l + ");\n", - u) - p += "runDestructors(destructors);\n"; - else - for (s = a ? 1 : 2; s < e.length; ++s) { - var m = 1 === s ? "thisWired" : "arg" + (s - 2) + "Wired"; - null !== e[s].destructorFunction && (p += m + "_dtor(" + m + "); // " + e[s].name + "\n", - h.push(m + "_dtor"), - y.push(e[s].destructorFunction)) - } - return c && (p += "var ret = retType.fromWireType(rv);\nreturn ret;\n"), - p += "}\n", - h.push(p), - function(t, e) { - if (!(t instanceof Function)) - throw new TypeError("new_ called with constructor type " + typeof t + " which is not a function"); - var r = ht(t.name || "unknownFunctionName", (function() {})); - r.prototype = t.prototype; - var n = new r, - o = t.apply(n, e); - return o instanceof Object ? o : n - }(Function, h).apply(null, y) - } - var pe = [], - de = [{}, { - value: void 0 - }, { - value: null - }, { - value: !0 - }, { - value: !1 - }]; - - function he(t) { - t > 4 && 0 == --de[t].refcount && (de[t] = void 0, - pe.push(t)) - } - - function ye() { - for (var t = 0, e = 5; e < de.length; ++e) - void 0 !== de[e] && ++t; - return t - } - - function me() { - for (var t = 5; t < de.length; ++t) - if (void 0 !== de[t]) - return de[t]; - return null - } - var ve = { - toValue: function(t) { - return t || vt("Cannot use deleted val. handle = " + t), - de[t].value - }, - toHandle: function(t) { - switch (t) { - case void 0: - return 1; - case null: - return 2; - case !0: - return 3; - case !1: - return 4; - default: - var e = pe.length ? pe.pop() : de.length; - return de[e] = { - refcount: 1, - value: t - }, - e - } - } - }; - - function ge(t) { - if (null === t) - return "null"; - var e = typeof t; - return "object" === e || "array" === e || "function" === e ? t.toString() : "" + t - } - - function we(t, e) { - switch (e) { - case 2: - return function(t) { - return this.fromWireType(M[t >> 2]) - }; - case 3: - return function(t) { - return this.fromWireType(I[t >> 3]) - }; - default: - throw new TypeError("Unknown float type: " + t) - } - } - - function be(t, e, r) { - switch (e) { - case 0: - return r ? function(t) { - return S[t] - } : - function(t) { - return O[t] - }; - case 1: - return r ? function(t) { - return k[t >> 1] - } : - function(t) { - return W[t >> 1] - }; - case 2: - return r ? function(t) { - return j[t >> 2] - } : - function(t) { - return R[t >> 2] - }; - default: - throw new TypeError("Unknown integer type: " + t) - } - } - var Te = { - mappings: {}, - buffers: [null, [], - [] - ], - printChar: function(t, e) { - var r = Te.buffers[t]; - 0 === e || 10 === e ? ((1 === t ? v : g)(UTF8ArrayToString(r, 0)), - r.length = 0) : r.push(e) - }, - varargs: void 0, - get: function() { - return Te.varargs += 4, - j[Te.varargs - 4 >> 2] - }, - getStr: function(t) { - return UTF8ToString(t) - }, - get64: function(t, e) { - return t - } - }; - - function $e(t) { - return t % 4 == 0 && (t % 100 != 0 || t % 400 == 0) - } - - function Pe(t, e) { - for (var r = 0, n = 0; n <= e; r += t[n++]) - ; - return r - } - var Ae = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], - De = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - - function Fe(t, e) { - for (var r = new Date(t.getTime()); e > 0;) { - var n = $e(r.getFullYear()), - o = r.getMonth(), - i = (n ? Ae : De)[o]; - if (!(e > i - r.getDate())) - return r.setDate(r.getDate() + e), - r; - e -= i - r.getDate() + 1, - r.setDate(1), - o < 11 ? r.setMonth(o + 1) : (r.setMonth(0), - r.setFullYear(r.getFullYear() + 1)) - } - return r - } - - for (var t = new Array(256), e = 0; e < 256; ++e) - t[e] = String.fromCharCode(e); - st = t - - mt = o.BindingError = yt(Error, "BindingError"), - gt = o.InternalError = yt(Error, "InternalError"), - Rt.prototype.isAliasOf = Ct, - Rt.prototype.clone = Ft, - Rt.prototype.delete = Et, - Rt.prototype.isDeleted = St, - Rt.prototype.deleteLater = jt, - re.prototype.getPointee = Lt, - re.prototype.destructor = Gt, - re.prototype.argPackAdvance = 8, - re.prototype.readValueFromPointer = Bt, - re.prototype.deleteObject = Nt, - re.prototype.fromWireType = ee, - o.getInheritedInstanceCount = Jt, - o.getLiveInheritedInstances = Xt, - o.flushPendingDeletes = Wt, - o.setDelayFunction = Zt, - ae = o.UnboundTypeError = yt(Error, "UnboundTypeError"), - o.count_emval_handles = ye, - o.get_first_emval = me; - var Se, import_table_impl = { - d: function(t, e, r, n) { - abort("Assertion failed: " + UTF8ToString(t) + ", at: " + [e ? UTF8ToString(e) : "unknown filename", r, n ? UTF8ToString(n) : "unknown function"]) - }, - g: function(t) { - return ke(t + 16) + 16 - }, - f: function(t, e, r) { - throw new ExceptionInfo(t).init(e, r),t,t - }, - p: function(t, e, r, n, o) {}, - y: function(t, e, r, n, o) { - var i = ut(r); - _t(t, { - name: e = ct(e), - fromWireType: function(t) { - return !!t - }, - toWireType: function(t, e) { - return e ? n : o - }, - argPackAdvance: 8, - readValueFromPointer: function(t) { - var n; - if (1 === r) - n = S; - else if (2 === r) - n = k; - else { - if (4 !== r) - throw new TypeError("Unknown boolean type size: " + e); - n = j - } - return this.fromWireType(n[t >> i]) - }, - destructorFunction: null - }) - }, - A: function(t, e, r, n, o, i, a, u, s, c, f, l, p) { - f = ct(f), - i = ie(o, i), - u && (u = ie(a, u)), - c && (c = ie(s, c)), - p = ie(l, p); - var d = dt(f); - xt(d, (function() { - se("Cannot construct " + f + " due to unbound types", [n]) - })), - bt([t, e, r], n ? [n] : [], (function(e) { - var r, o; - e = e[0], - o = n ? (r = e.registeredClass).instancePrototype : Rt.prototype; - var a = ht(d, (function() { - if (Object.getPrototypeOf(this) !== s) - throw new mt("Use 'new' to construct " + f); - if (void 0 === l.constructor_body) - throw new mt(f + " has no accessible constructor"); - var t = l.constructor_body[arguments.length]; - if (void 0 === t) - throw new mt("Tried to invoke ctor of " + f + " with invalid number of parameters (" + arguments.length + ") - expected (" + Object.keys(l.constructor_body).toString() + ") parameters instead!"); - return t.apply(this, arguments) - })), - s = Object.create(o, { - constructor: { - value: a - } - }); - a.prototype = s; - var l = new Ut(f, a, s, p, r, i, u, c), - h = new re(f, l, !0, !1, !1), - y = new re(f + "*", l, !1, !1, !1), - m = new re(f + " const*", l, !1, !0, !1); - return Mt[t] = { - pointerType: y, - constPointerType: m - }, - ne(d, a), - [h, y, m] - })) - }, - w: function(t, e, r, n, o, i) { - T(e > 0); - var a = ce(e, r); - o = ie(n, o), - bt([], [t], (function(t) { - var r = "constructor " + (t = t[0]).name; - if (void 0 === t.registeredClass.constructor_body && (t.registeredClass.constructor_body = []), - void 0 !== t.registeredClass.constructor_body[e - 1]) - throw new mt("Cannot register multiple constructors with identical number of parameters (" + (e - 1) + ") for class '" + t.name + "'! Overload resolution is currently only performed using the parameter count, not actual type info!"); - return t.registeredClass.constructor_body[e - 1] = function() { - se("Cannot construct " + t.name + " due to unbound types", a) - }, - bt([], a, (function(n) { - return n.splice(1, 0, null), - t.registeredClass.constructor_body[e - 1] = le(r, n, null, o, i), - [] - })), - [] - })) - }, - c: function(t, e, r, n, o, i, a, u) { - var s = ce(r, n); - e = ct(e), - i = ie(o, i), - bt([], [t], (function(t) { - var n = (t = t[0]).name + "." + e; - - function o() { - se("Cannot call " + n + " due to unbound types", s) - } - e.startsWith("@@") && (e = Symbol[e.substring(2)]), - u && t.registeredClass.pureVirtualFunctions.push(e); - var c = t.registeredClass.instancePrototype, - f = c[e]; - return void 0 === f || void 0 === f.overloadTable && f.className !== t.name && f.argCount === r - 2 ? (o.argCount = r - 2, - o.className = t.name, - c[e] = o) : (It(c, e, n), - c[e].overloadTable[r - 2] = o), - bt([], s, (function(o) { - var u = le(n, o, t, i, a); - return void 0 === c[e].overloadTable ? (u.argCount = r - 2, - c[e] = u) : c[e].overloadTable[r - 2] = u, - [] - })), - [] - })) - }, - x: function(t, e) { - _t(t, { - name: e = ct(e), - fromWireType: function(t) { - var e = ve.toValue(t); - return he(t), - e - }, - toWireType: function(t, e) { - return ve.toHandle(e) - }, - argPackAdvance: 8, - readValueFromPointer: Bt, - destructorFunction: null - }) - }, - j: function(t, e, r) { - var n = ut(r); - _t(t, { - name: e = ct(e), - fromWireType: function(t) { - return t - }, - toWireType: function(t, e) { - if ("number" != typeof e && "boolean" != typeof e) - throw new TypeError('Cannot convert "' + ge(e) + '" to ' + this.name); - return e - }, - argPackAdvance: 8, - readValueFromPointer: we(e, n), - destructorFunction: null - }) - }, - l: function(t, e, r, n, o, i) { - // Registering functions from constructor (;204;) - var a = ce(e, r); - t = ct(t), - o = ie(n, o), - xt(t, (function() { - se("Cannot call " + t + " due to unbound types", a) - }), e - 1), - bt([], a, (function(r) { - var n = [r[0], null].concat(r.slice(1)); - return ne(t, le(t, n, null, o, i), e - 1), - [] - })) - }, - b: function(t, e, r, n, o) { - e = ct(e), - -1 === o && (o = 4294967295); - var i = ut(r), - a = function(t) { - return t - }; - if (0 === n) { - var u = 32 - 8 * r; - a = function(t) { - return t << u >>> u - } - } - var s = e.includes("unsigned"); - _t(t, { - name: e, - fromWireType: a, - toWireType: function(t, r) { - if ("number" != typeof r && "boolean" != typeof r) - throw new TypeError('Cannot convert "' + ge(r) + '" to ' + this.name); - if (r < n || r > o) - throw new TypeError('Passing a number "' + ge(r) + '" from JS side to C/C++ side to an argument of type "' + e + '", which is outside the valid range [' + n + ", " + o + "]!"); - return s ? r >>> 0 : 0 | r - }, - argPackAdvance: 8, - readValueFromPointer: be(e, i, 0 !== n), - destructorFunction: null - }) - }, - a: function(t, e, r) { - var n = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array][e]; - - function o(t) { - var e = R, - r = e[t >>= 2], - o = e[t + 1]; - return new n(E, o, r) - } - _t(t, { - name: r = ct(r), - fromWireType: o, - argPackAdvance: 8, - readValueFromPointer: o - }, { - ignoreDuplicateRegistrations: !0 - }) - }, - k: function(t, e) { - var r = "std::string" === (e = ct(e)); - _t(t, { - name: e, - fromWireType: function(t) { - var e, n = R[t >> 2]; - if (r) - for (var o = t + 4, i = 0; i <= n; ++i) { - var a = t + 4 + i; - if (i == n || 0 == O[a]) { - var u = UTF8ToString(o, a - o); - void 0 === e ? e = u : (e += String.fromCharCode(0), - e += u), - o = a + 1 - } - } - else { - var s = new Array(n); - for (i = 0; i < n; ++i) - s[i] = String.fromCharCode(O[t + 4 + i]); - e = s.join("") - } - return We(t), - e - }, - toWireType: function(t, e) { - e instanceof ArrayBuffer && (e = new Uint8Array(e)); - var n = "string" == typeof e; - n || e instanceof Uint8Array || e instanceof Uint8ClampedArray || e instanceof Int8Array || vt("Cannot pass non-string to std::string"); - var o = (r && n ? function() { - return UTF8CharCount(e) - } : - function() { - return e.length - } - )(), - i = ke(4 + o + 1); - if (R[i >> 2] = o, - r && n) - stringToUTF8Array(e, O, i + 4, o + 1); - else if (n) - for (var a = 0; a < o; ++a) { - var u = e.charCodeAt(a); - u > 255 && (We(i), - vt("String has UTF-16 code units that do not fit in 8 bits")), - O[i + 4 + a] = u - } - else - for (a = 0; a < o; ++a) - O[i + 4 + a] = e[a]; - return null !== t && t.push(We, i), - i - }, - argPackAdvance: 8, - readValueFromPointer: Bt, - destructorFunction: function(t) { - We(t) - } - }) - }, - e: function(t, e, r) { - var n, o, i, a, u; - r = ct(r), - 2 === e ? (n = UTF16ArrayToString, - o = H, - a = Y, - i = function() { - return W - }, - u = 1) : 4 === e && (n = V, - o = z, - a = B, - i = function() { - return R - }, - u = 2), - _t(t, { - name: r, - fromWireType: function(t) { - for (var r, o = R[t >> 2], a = i(), s = t + 4, c = 0; c <= o; ++c) { - var f = t + 4 + c * e; - if (c == o || 0 == a[f >> u]) { - var l = n(s, f - s); - void 0 === r ? r = l : (r += String.fromCharCode(0), - r += l), - s = f + e - } - } - return We(t), - r - }, - toWireType: function(t, n) { - "string" != typeof n && vt("Cannot pass non-string to C++ string type " + r); - var i = a(n), - s = ke(4 + i + e); - return R[s >> 2] = i >> u, - o(n, s + 4, i + e), - null !== t && t.push(We, s), - s - }, - argPackAdvance: 8, - readValueFromPointer: Bt, - destructorFunction: function(t) { - We(t) - } - }) - }, - z: function(t, e) { - _t(t, { - isVoid: !0, - name: e = ct(e), - argPackAdvance: 0, - fromWireType: function() {}, - toWireType: function(t, e) {} - }) - }, - m: he, - n: function(t) { - t > 4 && (de[t].refcount += 1) - }, - o: function(t, e) { - var r, n, o; - n = "_emval_take_value", - void 0 === (o = lt[r = t]) && vt(n + " has unknown type " + ue(r)); - var i = (t = o).readValueFromPointer(e); - return ve.toHandle(i) - }, - h: function() { - abort("") - }, - r: function(t, e, r) { - O.copyWithin(t, e, e + r) - }, - s: function(t) { - O.length, - abort("OOM") - }, - u: function(t, e) {}, - v: function(t, e) {}, - i: function(t, e, r, n) { - for (var o = 0, i = 0; i < r; i++) { - var a = j[e >> 2], - u = j[e + 4 >> 2]; - e += 8; - for (var s = 0; s < u; s++) - Te.printChar(t, O[a + s]); - o += u - } - return j[n >> 2] = o, - 0 - }, - q: function(t) { - t - }, - t: function(t, e, r, n) { - return Ee(t, e, r, n) - } - }, - ke = (function() { - var import_table = { - a: import_table_impl - }; - - function updateGlobalBufferAndViews(t, e) { - var r, n, exports = t.exports; - o.asm = exports; - b = o.asm.B; // Mem - r = b.buffer - E = r - o.HEAP8 = S = new Int8Array(r), - o.HEAP16 = k = new Int16Array(r), - o.HEAP32 = j = new Int32Array(r), - o.HEAPU8 = O = new Uint8Array(r), - o.HEAPU16 = W = new Uint16Array(r), - o.HEAPU32 = R = new Uint32Array(r), - o.HEAPF32 = M = new Float32Array(r), - o.HEAPF64 = I = new Float64Array(r), - L = o.asm.D // Table - n = o.asm.C // ctor - N.unshift(n), - function(t) { - if (J--, - o.monitorRunDependencies && o.monitorRunDependencies(J), - 0 == J && (null !== X && (clearInterval(X), - X = null), - Z)) { - var e = Z; - Z = null, - e() - } - }() - } - - function load_wasm(t) { - updateGlobalBufferAndViews(t.instance) - } - - function getBinaryPromise(e) { - return function() { - if (!w) { - if (readSync) - return new Promise((function(t, e) { - readSync(Q, (function(e) { - t(new Uint8Array(e)) - }), e) - })) - } - return Promise.resolve().then((function() { - return getBinary(Q) - })) - }().then((function(e) { - return WebAssembly.instantiate(e, import_table) - })).then((function(t) { - return t - })).then(e, (function(t) { - g("failed to asynchronously prepare wasm: " + t,Q), - abort(t) - })) - } - if (J++, - o.monitorRunDependencies && o.monitorRunDependencies(J), - o.instantiateWasm) - try { - return o.instantiateWasm(import_table, updateGlobalBufferAndViews) - } catch (t) { - return g("Module.instantiateWasm callback failed with error: " + t), - !1 - } - w || "function" != typeof WebAssembly.instantiate || isDataURI(Q) || isFileURI(Q) || getBinaryPromise(load_wasm) - }(), - o.___wasm_call_ctors = function() { - return (o.___wasm_call_ctors = o.asm.C).apply(null, arguments) - }, - o._malloc = function() { - return (ke = o._malloc = o.asm.E).apply(null, arguments) - } - ), - We = o._free = function() { - return (We = o._free = o.asm.F).apply(null, arguments) - }, - je = o.___getTypeName = function() { - return (je = o.___getTypeName = o.asm.G).apply(null, arguments) - }; - o.___embind_register_native_and_builtin_types = function() { - return (o.___embind_register_native_and_builtin_types = o.asm.H).apply(null, arguments) - }, - o.dynCall_jiji = function() { - return (o.dynCall_jiji = o.asm.I).apply(null, arguments) - }, - o.dynCall_iiiiij = function() { - return (o.dynCall_iiiiij = o.asm.J).apply(null, arguments) - }, - o.dynCall_iiiiijj = function() { - return (o.dynCall_iiiiijj = o.asm.K).apply(null, arguments) - }, - o.dynCall_iiiiiijj = function() { - return (o.dynCall_iiiiiijj = o.asm.L).apply(null, arguments) - }, - o.dynCall_viijii = function() { - return (o.dynCall_viijii = o.asm.M).apply(null, arguments) - }; - - function ExitStatus(t) { - this.name = "ExitStatus", - this.message = "Program terminated with exit(" + t + ")", - this.status = t - } - - function doRun(t) { - function postRun() { - Se || (Se = !0,o.calledRun = !0,C || (!0, - callRuntimeCallbacks(N), - o.onRuntimeInitialized && o.onRuntimeInitialized(), - function() { - if (o.postRun) - for ("function" == typeof o.postRun && (o.postRun = [o.postRun]); o.postRun.length;) - t = o.postRun.shift(), - q.unshift(t); - var t; - callRuntimeCallbacks(q) - }())) - } - t = t || l - J > 0 || (! function preRun() { - if (o.preRun) - for ("function" == typeof o.preRun && (o.preRun = [o.preRun]); o.preRun.length;) - t = o.preRun.shift(), - G.unshift(t); - var t; - callRuntimeCallbacks(G) - }(), - J > 0 || (o.setStatus ? (o.setStatus("Running..."), - setTimeout((function() { - setTimeout((function() { - o.setStatus("") - }), 1), - postRun() - }), 1)) : postRun())) - } - if (Z = function t() { - Se || doRun(), - Se || (Z = t) - }, - o.run = doRun, - o.preInit) - for ("function" == typeof o.preInit && (o.preInit = [o.preInit]); o.preInit.length > 0;) - o.preInit.pop()(); - doRun(); - return o; -}) - -// XXX: With PythonMonkey, the required module -// is destructed(?) once the function is called -// This is probably not what actaully happened, but -// for now, everytime an FP is generated, the entire -// WASM module is reloaded as a workaround -function instantiateRuntime(){ - return new Promise((resolve, reject) => { - var fpRuntime = AudioFingerprintRuntime() - var monitor = setInterval(() => { - if (typeof fpRuntime.ExtractQueryFP == "function") - clearInterval(monitor) || resolve(fpRuntime) - }) - }) -} - -function GenerateFP(floatArray) { - let PCMBuffer = Float32Array.from(floatArray) - console.info('[afp] input samples n=', PCMBuffer.length) - return instantiateRuntime().then((fpRuntime) => { - console.info('[afp] begin fingerprinting') - let fp_vector = fpRuntime.ExtractQueryFP(PCMBuffer.buffer) - let result_buf = new Uint8Array(fp_vector.size()); - for (let t = 0; t < fp_vector.size(); t++) - result_buf[t] = fp_vector.get(t); - return globalThis.b64encode(result_buf) - }); -} - -if (typeof exports != 'undefined') /* Node, PythonMonkey */ - exports.GenerateFP = GenerateFP; diff --git a/src/router/xtc_recognize/afp/afp.wasm.js b/src/router/xtc_recognize/afp/afp.wasm.js deleted file mode 100644 index 8de2271ef926a68564eabd0547cfc4448772c995..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/afp/afp.wasm.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict' -const WASM_BINARY = "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AAwBCyAAQeSzASkCADcCACAAQQhqIgIgAyAFEB4aIAIgBWpBADoAACAAQZydAigCADYCGEGcnQIgADYCAAsgAEHkswEgACABchshAAsgAAsiADYCACAGIABBAEdqIQYgAUEBaiIBQQZHDQALQYC0ASEBAkACQAJAIAYOAgIAAQsgBCgCCEHkswFHDQBBmLQBIQEMAQtBGBAoIgFFDQAgASAEKQMINwIAIAEgBCkDGDcCECABIAQpAxA3AggLIARBIGokAEHEngIgATYCAEHIngIQLgtBxJ4CKAIAC98KAgV/D34jAEHgAGsiBSQAIARC////////P4MiDUIPhiADQjGIhCEOIAIgBIVCgICAgICAgICAf4MhCiACQv///////z+DIgtCIIghDyANQhGIIRAgBEIwiKdB//8BcSEHAkACQCACQjCIp0H//wFxIglBAWtB/f8BTQRAIAdBAWtB/v8BSQ0BCyABUCACQv///////////wCDIgxCgICAgICAwP//AFQgDEKAgICAgIDA//8AURtFBEAgAkKAgICAgIAghCEKDAILIANQIARC////////////AIMiAkKAgICAgIDA//8AVCACQoCAgICAgMD//wBRG0UEQCAEQoCAgICAgCCEIQogAyEBDAILIAEgDEKAgICAgIDA//8AhYRQBEAgAiADhFAEQEKAgICAgIDg//8AIQpCACEBDAMLIApCgICAgICAwP//AIQhCkIAIQEMAgsgAyACQoCAgICAgMD//wCFhFAEQCABIAyEIQJCACEBIAJQBEBCgICAgICA4P//ACEKDAMLIApCgICAgICAwP//AIQhCgwCCyABIAyEUARAQgAhAQwCCyACIAOEUARAQgAhAQwCCyAMQv///////z9YBEAgBUHQAGogASALIAEgCyALUCIGG3kgBkEGdK18pyIGQQ9rEDxBECAGayEGIAUpA1giC0IgiCEPIAUpA1AhAQsgAkL///////8/Vg0AIAVBQGsgAyANIAMgDSANUCIIG3kgCEEGdK18pyIIQQ9rEDwgBiAIa0EQaiEGIAUpA0giAkIPhiAFKQNAIgNCMYiEIQ4gAkIRiCEQCyADQg+GQoCA/v8PgyICIAFCIIgiBH4iEiADQhGIQv////8PgyIMIAFC/////w+DIgF+fCIRQiCGIg0gASACfnwiAyANVK0gAiALQv////8PgyILfiIVIAQgDH58IhMgDkL/////D4MiDSABfnwiFCARIBJUrUIghiARQiCIhHwiESACIA9CgIAEhCIOfiIWIAsgDH58Ig8gBCANfnwiEiAQQv////8Hg0KAgICACIQiAiABfnwiEEIghnwiF3whASAHIAlqIAZqQf//AGshBgJAIAsgDX4iGCAMIA5+fCIMIBhUrSAMIAIgBH58IgQgDFStfCACIA5+fCAEIAQgEyAVVK0gEyAUVq18fCIEVq18IAIgC34iCyANIA5+fCICIAtUrUIghiACQiCIhHwgBCACQiCGfCICIARUrXwgAiACIBAgElStIA8gFlStIA8gElatfHxCIIYgEEIgiIR8IgJWrXwgAiACIBEgFFStIBEgF1atfHwiAlatfCIEQoCAgICAgMAAg1BFBEAgBkEBaiEGDAELIANCP4ghCyAEQgGGIAJCP4iEIQQgAkIBhiABQj+IhCECIANCAYYhAyALIAFCAYaEIQELIAZB//8BTgRAIApCgICAgICAwP//AIQhCkIAIQEMAQsCfiAGQQBMBEBBASAGayIHQYABTwRAQgAhAQwDCyAFQTBqIAMgASAGQf8AaiIGEDwgBUEgaiACIAQgBhA8IAVBEGogAyABIAcQbiAFIAIgBCAHEG4gBSkDMCAFKQM4hEIAUq0gBSkDICAFKQMQhIQhAyAFKQMoIAUpAxiEIQEgBSkDACECIAUpAwgMAQsgBEL///////8/gyAGrUIwhoQLIAqEIQogA1AgAUIAWSABQoCAgICAgICAgH9RG0UEQCAKIAJCAXwiASACVK18IQoMAQsgAyABQoCAgICAgICAgH+FhFBFBEAgAiEBDAELIAogAiACQgGDfCIBIAJUrXwhCgsgACABNwMAIAAgCjcDCCAFQeAAaiQAC40uAQt/IwBBEGsiCyQAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQfQBTQRAQZCtAigCACIGQRAgAEELakF4cSAAQQtJGyIHQQN2IgJ2IgFBA3EEQCABQX9zQQFxIAJqIgNBA3QiAUHArQJqKAIAIgRBCGohAAJAIAQoAggiAiABQbitAmoiAUYEQEGQrQIgBkF+IAN3cTYCAAwBCyACIAE2AgwgASACNgIICyAEIANBA3QiAUEDcjYCBCABIARqIgEgASgCBEEBcjYC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AiAHNgIEIAIgACADQQJ0ajYCDCAEQRBqJAAjAEEQayIAJAAgACACKAIINgIAIAIoAgghAyAAIAJBCGo2AgggACADIAZBAnRqNgIEIAAoAgAhAwNAIAAoAgQgA0cEQCACKAIQGiAAKAIAQQA2AgAgACAAKAIAQQRqIgM2AgAMAQsLIAAoAgggACgCADYCACAAQRBqJABBoKsCKAIAIgYiAEGoqwIoAgAgAGtBAnVBAnRqGiACQQRqIgMiACAAKAIAQaSrAigCACAGayIAayIENgIAIABBAEoEQCAEIAYgABAeGgtBoKsCIAMQoQFBpKsCIAJBCGoQoQFBqKsCIAJBDGoQoQEgAiACKAIENgIAQaSrAigCAEGgqwIoAgAiAGsaQairAigCABogAigCBCEAA0AgACACKAIIRwRAIAIoAhAaIAIgAigCCEEEazYCCAwBCwsgAigCAARAIAIoAhAgAigCACIAIAIoAgwgAGtBAnUQ/AELCyAIQSBqJAAMAQsgACACSQRAQaSrAigCAEGgqwIoAgAiAmsaQaCrAiAAQQJ0IAJqEPoBQaCrAigCACIAQairAigCACAAa0ECdUECdGoaQaSrAigCABoLCwtBoKsCKAIAIAFBAnRqKAIABEBBoKsCKAIAIAFBAnRqKAIAIgAgACgCBEEBayICNgIEIAJBf0YEQCAAIAAoAgAoAggRAQALCyAFKAIIIQAgBUEANgIIQaCrAigCACABQQJ0aiAANgIAIAUoAgghACAFQQA2AgggAARAIAAgACgCBEEBayIBNgIEIAFBf0YEQCAAIAAoAgAoAggRAQALCyAFQRBqJAALNAEBfyMAQRBrIgMkACADIAE2AgwgACADQQxqKAIANgIAIAAgAigCADYCBCADQRBqJAAgAAs2AQF/An8gACgCACIAKAIMIgEgACgCEEYEQCAAIAAoAgAoAiQRAAAMAQsgAS0AAAtBGHRBGHULagEDfyMAQRBrIgEkACABQQA2AgwgASAANgIEIAEgADYCACABIABBAWo2AgggASECIwBBEGsiAyQAIANBCGoiACACKAIENgIAIAAoAgBBAToAACACKAIIQQE6AAAgA0EQaiQAIAFBEGokAAuUAQEEfyMAQRBrIgEkACABQQA2AgwgASAANgIEIAEgADYCACABIABBAWo2AgggASEDIwBBEGsiBCQAIARBCGoiACADKAIENgIAIAAoAgAtAABFBEACfwJAIAMoAggiAi0AACIAQQFHBH8gAEECcQ0BIAJBAjoAAEEBBUEACwwBCwALIQILIARBEGokACABQRBqJAAgAgsNACAAKAIAELYCGiAACw0AIAAoAgAQuwIaIAALZgEBfyACRQRAIAAoAgQgASgCBEYPCyAAIAFGBEBBAQ8LAn8jAEEQayIDIgIgADYCCCACIAIoAggoAgQ2AgwgAigCDAsCfyADIgAgATYCCCAAIAAoAggoAgQ2AgwgACgCDAsQhwFFCwkAIAAgARC3AgsJACAAIAEQvAILrAEBAX8CQCAAAn8gACgCvC0iAUEQRgRAIAAgACgCFCIBQQFqNgIUIAEgACgCCGogAC0AuC06AAAgACAAKAIUIgFBAWo2AhQgASAAKAIIaiAAQbktai0AADoAACAAQQA7AbgtQQAMAQsgAUEISA0BIAAgACgCFCIBQQFqNgIUIAEgACgCCGogAC0AuC06AAAgACAAQbktai0AADsBuC0gACgCvC1BCGsLNgK8LQsLkwIBA38gAC0AAEEgcUUEQAJAIAEhAwJAIAIgACIBKAIQIgAEfyAABQJ/IAEgAS0ASiIAQQFrIAByOgBKIAEoAgAiAEEIcQRAIAEgAEEgcjYCAEF/DAELIAFCADcCBCABIAEoAiwiADYCHCABIAA2AhQgASAAIAEoAjBqNgIQQQALDQEgASgCEAsgASgCFCIFa0sEQCABIAMgAiABKAIkEQQAGgwCCwJAIAEsAEtBAEgNACACIQADQCAAIgRFDQEgAyAEQQFrIgBqLQAAQQpHDQALIAEgAyAEIAEoAiQRBAAgBEkNASADIARqIQMgAiAEayECIAEoAhQhBQsgBSADIAIQHhogASABKAIUIAJqNgIUCwsLCwUAEAcAC3UBAX4gACABIAR+IAIgA358IANCIIgiAiABQiCIIgR+fCADQv////8PgyIDIAFC/////w+DIgF+IgVCIIggAyAEfnwiA0IgiHwgASACfiADQv////8Pg3wiAUIgiHw3AwggACAFQv////8PgyABQiCGhDcDAAtYAQF/IwBBEGsiAiQAIAAtAAtBB3YEQCAAKAIIGiAAKAIAEBsLIAAgASgCCDYCCCAAIAEpAgA3AgAgAUEAOgALIAJBADoADyABIAItAA86AAAgAkEQaiQAC2kBAX8jAEEQayIFJAAgBSACNgIMIAUgBDYCCCAFIAVBDGoQUCECIAAgASADIAUoAggQoAEhASACKAIAIgAEQEHUnAIoAgAaIAAEQEHUnAJBkJsCIAAgAEF/Rhs2AgALCyAFQRBqJAAgAQvkAQECfwJAAn8gAC0AC0EHdgRAIAAoAgQMAQsgAC0ACwtFDQAgAiABa0EFSA0AIAEgAhCYASACQQRrIQQCfyAALQALQQd2BEAgACgCBAwBCyAALQALCwJ/IAAtAAtBB3YEQCAAKAIADAELIAALIgJqIQUCQANAAkAgAiwAACEAIAEgBE8NAAJAIABBAEwNACAAQf8ATg0AIAEoAgAgAiwAAEcNAwsgAUEEaiEBIAIgBSACa0EBSmohAgwBCwsgAEEATA0BIABB/wBODQEgAiwAACAEKAIAQQFrSw0BCyADQQQ2AgALC1ABAX4CQCADQcAAcQRAIAEgA0FAaq2GIQJCACEBDAELIANFDQAgAiADrSIEhiABQcAAIANrrYiEIQIgASAEhiEBCyAAIAE3AwAgACACNwMICwwAIAAgARC3AkEBcwsMACAAIAEQvAJBAXMLbwEBfyMAQYACayIFJAACQCAEQYDABHENACACIANMDQAgBSABQf8BcSACIANrIgJBgAIgAkGAAkkiARsQIRogAUUEQANAIAAgBUGAAhA2IAJBgAJrIgJB/wFLDQALCyAAIAUgAhA2CyAFQYACaiQACwoAIABB6J4CEGcLCABB2gsQXAALiwIBBn8CQCAAKAIEIgBFDQAgASgCACABIAEtAAsiAkEYdEEYdUEASCIDGyEGIAEoAgQgAiADGyEBA0ACQAJAAkACQAJAAkAgACgCFCAALQAbIgIgAkEYdEEYdUEASCIEGyICIAEgASACSyIHGyIDBEAgBiAAQRBqIgUoAgAgBSAEGyIEIAMQJSIFRQRAIAEgAkkNAgwDCyAFQQBODQIMAQsgASACTw0CCyAAKAIAIgANBQwGCyAEIAYgAxAlIgINAQsgBw0BDAILIAJBAE4NAQsgACgCBCIADQEMAgsLIABFDQAgAEEcag8LQQgQBiIAIgFBpxMQ5gEgAUGAigI2AgAgAEGgigJBEhAFAAsKACAAQfCeAhBnC9EJAgR/BH4jAEHwAGsiBSQAIARC////////////AIMhCgJAAkAgAUIBfSILQn9RIAJC////////////AIMiCSABIAtWrXxCAX0iC0L///////+///8AViALQv///////7///wBRG0UEQCADQgF9IgtCf1IgCiADIAtWrXxCAX0iC0L///////+///8AVCALQv///////7///wBRGw0BCyABUCAJQoCAgICAgMD//wBUIAlCgICAgICAwP//AFEbRQRAIAJCgICAgICAIIQhBCABIQMMAgsgA1AgCkKAgICAgIDA//8AVCAKQoCAgICAgMD//wBRG0UEQCAEQoCAgICAgCCEIQQMAgsgASAJQoCAgICAgMD//wCFhFAEQEKAgICAgIDg//8AIAIgASADhSACIASFQoCAgICAgICAgH+FhFAiBhshBEIAIAEgBhshAwwCCyADIApCgICAgICAwP//AIWEUA0BIAEgCYRQBEAgAyAKhEIAUg0CIAEgA4MhAyACIASDIQQMAgsgAyAKhFBFDQAgASEDIAIhBAwBCyADIAEgASADVCAJIApUIAkgClEbIgcbIQogBCACIAcbIgtC////////P4MhCSACIAQgBxsiAkIwiKdB//8BcSEIIAtCMIinQf//AXEiBkUEQCAFQeAAaiAKIAkgCiAJIAlQIgYbeSAGQQZ0rXynIgZBD2sQPCAFKQNoIQkgBSkDYCEKQRAgBmshBgsgASADIAcbIQMgAkL///////8/gyEEIAhFBEAgBUHQAGogAyAEIAMgBCAEUCIHG3kgB0EGdK18pyIHQQ9rEDxBECAHayEIIAUpA1ghBCAFKQNQIQMLIARCA4YgA0I9iIRCgICAgICAgASEIQQgCUIDhiAKQj2IhCEJIAIgC4UhDAJ+IANCA4YiASAGIAhrIgdFDQAaIAdB/wBLBEBCACEEQgEMAQsgBUFAayABIARBgAEgB2sQPCAFQTBqIAEgBCAHEG4gBSkDOCEEIAUpAzAgBSkDQCAFKQNIhEIAUq2ECyECIAlCgICAgICAgASEIQkgCkIDhiEDAkAgDEIAUwRAIAMgAn0iASAJIAR9IAIgA1atfSIEhFAEQEIAIQNCACEEDAMLIARC/////////wNWDQEgBUEgaiABIAQgASAEIARQIgcbeSAHQQZ0rXynQQxrIgcQPCAGIAdrIQYgBSkDKCEEIAUpAyAhAQwBCyACIAN8IgEgAlStIAQgCXx8IgRC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AjQRBAAgAUEKbGohAQwBCwtBAiEFIAAgB0EIahAzRQ0BCyACIAIoAgAgBXI2AgALIAdBEGokACABC4sCAQR/IwBBEGsiBSQAIAUgATYCCEEAIQFBBiEGAkACQCAAIAVBCGoQNA0AQQQhBiAAEC0iByIIQQBOBH8gAygCCCAIQf8BcUEBdGovAQBBgBBxQQBHBUEAC0UNACADIAdBACADKAIAKAIkEQQAIQEDQAJAIAAQMRogAUEwayEBIAAgBUEIahA+RQ0AIARBAkgNACAAEC0iBiIHQQBOBH8gAygCCCAHQf8BcUEBdGovAQBBgBBxQQBHBUEAC0UNAyAEQQFrIQQgAyAGQQAgAygCACgCJBEEACABQQpsaiEBDAELC0ECIQYgACAFQQhqEDRFDQELIAIgAigCACAGcjYCAAsgBUEQaiQAIAELvAEBA38jAEEQayIFJAAgBSABNgIMIAUgAzYCCCAFIAVBDGoQUCEGIAUoAgghBCMAQRBrIgMkACADIAQ2AgwgAyAENgIIQX8hAQJAQQBBACACIAQQoAEiBEEASA0AIAAgBEEBaiIEECgiADYCACAARQ0AIAAgBCACIAMoAgwQoAEhAQsgA0EQaiQAIAYoAgAiAARAQdScAigCABogAARAQdScAkGQmwIgACAAQX9GGzYCAAsLIAVBEGokACABCy4AAkAgACgCBEHKAHEiAARAIABBwABGBEBBCA8LIABBCEcNAUEQDwtBAA8LQQoL+QECA34CfyMAQRBrIgUkAAJ+IAG9IgNC////////////AIMiAkKAgICAgICACH1C/////////+//AFgEQCACQjyGIQQgAkIEiEKAgICAgICAgDx8DAELIAJCgICAgICAgPj/AFoEQCADQjyGIQQgA0IEiEKAgICAgIDA//8AhAwBCyACUARAQgAMAQsgBSACQgAgA6dnQSBqIAJCIIinZyACQoCAgIAQVBsiBkExahA8IAUpAwAhBCAFKQMIQoCAgICAgMAAhUGM+AAgBmutQjCGhAshAiAAIAQ3AwAgACACIANCgICAgICAgICAf4OENwMIIAVBEGokAAsEACAAC5QFAQN/IwBBIGsiCCQAIAggAjYCECAIIAE2AhggCEEIaiIBIAMoAhwiAjYCACACIAIoAgRBAWo2AgQgARBAIQkgASgCACIBIAEoAgRBAWsi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ACgCACICIQAgARAqIgMhASABIAAoAgwgACgCCGtBAnVJBH8gACgCCCABQQJ0aigCAEEARwVBAAtFBEAQNwALIAIoAgggA0ECdGooAgAL1QIBAn8CQCAAIAFGDQAgASAAIAJqIgRrQQAgAkEBdGtNBEAgACABIAIQHhoPCyAAIAFzQQNxIQMCQAJAIAAgAUkEQCADDQIgAEEDcUUNAQNAIAJFDQQgACABLQAAOgAAIAFBAWohASACQQFrIQIgAEEBaiIAQQNxDQALDAELAkAgAw0AIARBA3EEQANAIAJFDQUgACACQQFrIgJqIgMgASACai0AADoAACADQQNxDQALCyACQQNNDQADQCAAIAJBBGsiAmogASACaigCADYCACACQQNLDQALCyACRQ0CA0AgACACQQFrIgJqIAEgAmotAAA6AAAgAg0ACwwCCyACQQNNDQADQCAAIAEoAgA2AgAgAUEEaiEBIABBBGohACACQQRrIgJBA0sNAAsLIAJFDQADQCAAIAEtAAA6AAAgAEEBaiEAIAFBAWohASACQQFrIgINAAsLC1IBAn9BnJICKAIAIgEgAEEDakF8cSICaiEAAkAgAkEAIAAgAU0bDQAgAD8AQRB0SwRAIAAQEkUNAQtBnJICIAA2AgAgAQ8LQaibAkEwNgIAQX8LCAAgABDHARoLLAACQCAAIAFGDQADQCAAIAFBAWsiAU8NASAAIAEQowIgAEEBaiEADAALAAsLswEBBX8jAEEQayIFJAAgARB0IQIjAEEQayIEJAACQCACQW9NBEACQCACQQpNBEAgACACOgALIAAhAwwBCyAAIAJBC08EfyACQRBqQXBxIgMgA0EBayIDIANBC0YbBUEKC0EBaiIGEB0iAzYCACAAIAZBgICAgHhyNgIIIAAgAjYCBAsgAyABIAIQTiAEQQA6AA8gAiADaiAELQAPOgAAIARBEGokAAwBCxBFAAsgBUEQaiQAC9sBAgF/An5BASEEAkAgAEIAUiABQv///////////wCDIgVCgICAgICAwP//AFYgBUKAgICAgIDA//8AURsNACACQgBSIANC////////////AIMiBkKAgICAgIDA//8AViAGQoCAgICAgMD//wBRGw0AIAAgAoQgBSAGhIRQBEBBAA8LIAEgA4NCAFkEQEF/IQQgACACVCABIANTIAEgA1Eb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ADcCACACIAUoAgg2AgggAiAFKQMANwIAQQEMAgsgBSAAKAIINgIIIAUgACkCADcDACAAIAEoAgg2AgggACABKQIANwIAIAEgBSgCCDYCCCABIAUpAwA3AgBBASACIAEgBCgCABECAEUNARogBSABKAIINgIIIAUgASkCADcDACABIAIoAgg2AgggASACKQIANwIAIAIgBSgCCDYCCCACIAUpAwA3AgALQQILIQYCQCADIAIgBCgCABECAEUNACAFIAIoAgg2AgggBSACKQIANwMAIAIgAygCCDYCCCACIAMpAgA3AgAgAyAFKAIINgIIIAMgBSkDADcCACACIAEgBCgCABECAEUEQCAGQQFqIQYMAQsgBSABKAIINgIIIAUgASkCADcDACABIAIoAgg2AgggASACKQIANwIAIAIgBSgCCDYCCCACIAUpAwA3AgAgASAAIAQoAgARAgBFBEAgBkECaiEGDAELIAUgACgCCDYCCCAFIAApAgA3AwAgACABKAIINgIIIAAgASkCADcCACABIAUoAgg2AgggASAFKQMANwIAIAZBA2ohBgsgBUEQaiQAIAYL7QMCA30CfyACKgIEIQUCQCABKgIEIgQgACoCBCIGXkUEQCAEIAVdRQRAIAUhBAwCCyABKAIAIQcgASACKAIANgIAIAIgBzYCACABIAU4AgQgAiAEOAIEQQEhByABKgIEIgUgACoCBCIGXkUNASAAKAIAIQcgACABKAIANgIAIAEgBzYCACAAIAU4AgQgASAGOAIEIAIqAgQhBEECIQcMAQsCfyAEIAVdBEAgACgCACEHIAAgAigCADYCACACIAc2AgAgACAFOAIEIAIgBjgCBEEBDAELIAAoAgAhCCAAIAEoAgA2AgAgASAINgIAIAAgBDgCBCABIAY4AgRBASEHIAIqAgQiBCAGXkUNASABIAIoAgA2AgAgAiAINgIAIAEgBDgCBCACIAY4AgRBAgshByAGIQQLIAQgAyoCBCIFXQR/IAIoAgAhCCACIAMoAgA2AgAgAyAINgIAIAIgBTgCBCADIAQ4AgQgAioCBCIEIAEqAgQiBV5FBEAgB0EBag8LIAEoAgAhAyABIAIoAgA2AgAgAiADNgIAIAEgBDgCBCACIAU4AgQgASoCBCIEIAAqAgQiBV5FBEAgB0ECag8LIAAoAgAhAiAAIAEoAgA2AgAgASACNgIAIAAgBDgCBCABIAU4AgQgB0EDagUgBwsLggEBAn8jAEEQayIDJAAgA0EIaiIEIAEoAhwiATYCACABIAEoAgRBAWo2AgQgAiAEEHoiASICIAIoAgAoAhARAAA2AgAgACABIAEoAgAoAhQRAwAgBCgCACIAIAAoAgRBAWsiATYCBCABQX9GBEAgACAAKAIAKAIIEQEACyADQRBqJAALeQECfyMAQRBrIgMkACADQQhqIgIgACgCHCIANgIAIAAgACgCBEEBajYCBCACEEAiAEHg0wFB+tMBIAEgACgCACgCMBEHABogAigCACIAIAAoAgRBAWsiAjYCBCACQX9GBEAgACAAKAIAKAIIEQEACyADQRBqJAAgAQuCAQECfyMAQRBrIgMkACADQQhqIgQgASgCHCIBNgIAIAEgASgCBEEBajYCBCACIAQQfCIBIgIgAigCACgCEBEAADoAACAAIAEgASgCACgCFBEDACAEKAIAIgAgACgCBEEBayIBNgIEIAFBf0YEQCAAIAAoAgAoAggRAQALIANBEGokAAtNAQJ/IAEtAAAhAgJAIAAtAAAiA0UNACACIANHDQADQCABLQABIQIgAC0AASIDRQ0BIAFBAWohASAAQQFqIQAgAiADRg0ACwsgAyACawuZAQEDfCAAIACiIgMgAyADoqIgA0R81c9aOtnlPaJE65wriublWr6goiADIANEff6xV+Mdxz6iRNVhwRmgASq/oKJEpvgQERERgT+goCEFIAMgAKIhBCACRQRAIAQgAyAFokRJVVVVVVXFv6CiIACgDwsgACADIAFEAAAAAAAA4D+iIAQgBaKhoiABoSAERElVVVVVVcU/oqChC5IBAQN8RAAAAAAAAPA/IAAgAKIiAkQAAAAAAADgP6IiA6EiBEQAAAAAAADwPyAEoSADoSACIAIgAiACRJAVyxmgAfo+okR3UcEWbMFWv6CiRExVVVVVVaU/oKIgAiACoiIDIAOiIAIgAkTUOIi+6fqovaJExLG0vZ7uIT6gokStUpyAT36SvqCioKIgACABoqGgoAu8AwECfwJAAn8gACgCvC0iBEEOTgRAIAAgAC8BuC0gAyAEdHIiBDsBuC0gACAAKAIUIgVBAWo2AhQgBSAAKAIIaiAEOgAAIAAgACgCFCIEQQFqNgIUIAQgACgCCGogAEG5LWot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BEAgAigCAAwBCyACCyEEIAUgBjkDACACAn8gBCADQQFqQawRIAUQxQEiBEEATgRAIAMgBE8NAiAEDAELIANBAXRBAXILIgMQHwwBCwsgAiAEEB8gACACKQIANwIAIAAgAigCCDYCCCACEMQBIAVBEGokACACEBwaIAJBEGokAAvBAQEDfyMAQRBrIgMkACADIAE6AA8CQAJAAkACQCAALQALQQd2BEAgACgCBCIEIAAoAghB/////wdxQQFrIgJGDQEMAwtBCiEEQQohAiAALQALIgFBCkcNAQsgACACQQEgAiACEKwBIAQhASAALQALQQd2DQELIAAiAiABQQFqOgALDAELIAAoAgAhAiAAIARBAWo2AgQgBCEBCyABIAJqIgAgAy0ADzoAACADQQA6AA4gACADLQAOOgABIANBEGokAAvEAgEFfyMAQRBrIggkACACIAFBf3NBEWtNBEACfyAALQALQQd2BEAgACgCAAwBCyAACyEJAn8gAUHn////B0kEQCAIIAFBAXQ2AgggCCABIAJqNgIMIwBBEGsiAiQAIAhBDGoiCigCACAIQQhqIgsoAgBJIQwgAkEQaiQAIAsgCiAMGygCACICQQtPBH8gAkEQakFwcSICIAJBAWsiAiACQQtGGwVBCgsMAQtBbgtBAWoiChAdIQIgBARAIAIgCSAEEE4LIAYEQCACIARqIAcgBhBOCyADIAQgBWprIgMEQCACIARqIAZqIAQgCWogBWogAxBOCyABQQpHBEAgCRAbCyAAIAI2AgAgACAKQYCAgIB4cjYCCCAAIAQgBmogA2oiADYCBCAIQQA6AAcgACACaiAILQAHOgAAIAhBEGokAA8LEEUACz8BAX8gACABQeQAbiICQQF0QfCGAmovAQA7AAAgAEECaiIAIAEgAkHkAGxrQQF0QfCGAmovAQA7AAAgAEECagsDAAELGgAgAEH4gAI2AgAgAEEgahAcGiAAEHUaIAAL8hcBB38jAEEQayIDJAADQCABQQxrIQcDQAJAAkACQAJAAkACQCABIABrIgVBDG0OBgUFAAECAwQLIAFBDGsiASAAIAIoAgARAgBFDQQgAyAAKAIINgIIIAMgACkCADcDACAAIAEoAgg2AgggACABKQIANwIAIAEgAygCCDYCCCABIAMpAwA3AgAMBAsgAEEMaiIHIAAgAigCABECACEGIAFBDGsiBSAHIAIoAgARAgAhASAGRQRAIAFFDQQgAyAHKAIINgIIIAMgBykCADcDACAHIAUoAgg2AgggByAFKQIANwIAIAUgAygCCDYCCCAFIAMpAwA3AgAgByAAIAIoAgARAgBFDQQgAyAAKAIINgIIIAMgACkCADcDACAAIAcoAgg2AgggACAHKQIANwIAIAcgAygCCDYCCCAHIAMpAwA3AgAMBAsgAQRAIAMgACgCCDYCCCADIAApAgA3AwAgACAFKAIINgIIIAAgBSkCADcCACAFIAMoAgg2AgggBSADKQMANwIADAQLIAMgACgCCDYCCCADIAApAgA3AwAgACAHKAIINgIIIAAgBykCADcCACAHIAMoAgg2AgggByADKQMANwIAIAUgByACKAIAEQIARQ0DIAMgBygCCDYCCCADIAcpAgA3AwAgByAFKAIINgIIIAcgBSkCADcCACAFIAMoAgg2AgggBSADKQMANwIADAMLIAAgAEEMaiAAQRhqIAFBDGsgAhCCARoMAgsgACAAQQxqIgcgAEEYaiIFIABBJGoiBiACEIIBGiABQQxrIgEgBiACKAIAEQIARQ0BIAMgBigCCDYCCCADIAYpAgA3AwAgBiABKAIINgIIIAYgASkCADcCACABIAMoAgg2AgggASADKQMANwIAIAYgBSACKAIAEQIARQ0BIAMgBSgCCDYCCCADIAUpAgA3AwAgBSAGKAIINgIIIAUgBikCADcCACAGIAMoAgg2AgggBiADKQMANwIAIAUgByACKAIAEQIARQ0BIAMgBygCCDYCCCADIAcpAgA3AwAgByAFKAIINgIIIAcgBSkCADcCACAFIAMoAgg2AgggBSADKQMANwIAIAcgACACKAIAEQIARQ0BIAMgACgCCDYCCCADIAApAgA3AwAgACAHKAIINgIIIAAgBykCADcCACAHIAMoAgg2AgggByADKQMANwIADAELIAVB8wJMBEAgASEHIwBBEGsiCCQAIAAiBkEMaiIEIAAgAiIJKAIAEQIAIQAgBkEYaiIFIAQgAigCABECACEBAkAgAEUEQCABRQ0BIAggBCgCCDYCCCAIIAQpAgA3AwAgBCAFKAIINgIIIAQgBSkCADcCACAFIAgoAgg2AgggBSAIKQMANwIAIAQgBiAJKAIAEQIARQ0BIAggBigCCDYCCCAIIAYpAgA3AwAgBiAEKAIINgIIIAYgBCkCADcCACAEIAgoAgg2AgggBCAIKQMANwIADAELIAEEQCAIIAYoAgg2AgggCCAGKQIANwMAIAYgBSgCCDYCCCAGIAUpAgA3AgAgBSAIKAIINgIIIAUgCCkDADcCAAwBCyAIIAYoAgg2AgggCCAGKQIANwMAIAYgBCgCCDYCCCAGIAQpAgA3AgAgBCAIKAIINgIIIAQgCCkDADcCACAFIAQgCSgCABECAEUNACAIIAQoAgg2AgggCCAEKQIANwMAIAQgBSgCCDYCCCAEIAUpAgA3AgAgBSAIKAIINgIIIAUgCCkDADcCAAsgByAGQSRqIgBHBEADQCAAIgEgBSAJKAIAEQIABEAgCCABKAIINgIIIAggASkCADcDACABIQIDQAJAIAIgBSIAKQIANwIAIAIgACgCCDYCCCAAIAZGBEAgBiEADAELIAAhAiAIIABBDGsiBSAJKAIAEQIADQELCyAAIAgpAwA3AgAgACAIKAIINgIICyABIgVBDGoiACAHRw0ACwsgCEEQaiQADAELAkAgBUHV3QBPBEAgACAAIAVBMG5BDGwiBmoiCSAAIAVBGG5BDGxqIgQgBCAGaiIGIAIQggEhCCAHIAYgAigCABECAEUNASADIAYoAgg2AgggAyAGKQIANwMAIAYgBygCCDYCCCAGIAcpAgA3AgAgByADKAIINgIIIAcgAykDADcCACAGIAQgAigCABECAEUEQCAIQQFqIQgMAgsgAyAEKAIINgIIIAMgBCkCADcDACAEIAYoAgg2AgggBCAGKQIANwIAIAYgAygCCDYCCCAGIAMpAwA3AgAgBCAJIAIoAgARAgBFBEAgCEECaiEIDAILIAMgCSgCCDYCCCADIAkpAgA3AwAgCSAEKAIINgIIIAkgBCkCADcCACAEIAMoAgg2AgggBCADKQMANwIAIAkgACACKAIAEQIARQRAIAhBA2ohCAwCCyADIAAoAgg2AgggAyAAKQIANwMAIAAgCSgCCDYCCCAAIAkpAgA3AgAgCSADKAIINgIIIAkgAykDADcCACAIQQRqIQgMAQsgACAFQf//A3FBGG5BDGxqIgQgACACKAIAEQIAIQYgByAEIAIoAgARAgAhBQJAIAZFBEBBACEIIAVFDQIgAyAEKAIINgIIIAMgBCkCADcDACAEIAcoAgg2AgggBCAHKQIANwIAIAcgAygCCDYCCCAHIAMpAwA3AgBBASEIIAQgACACKAIAEQIARQ0CIAMgACgCCDYCCCADIAApAgA3AwAgACAEKAIINgIIIAAgBCkCADcCACAEIAMoAgg2AgggBCADKQMANwIADAELIAUEQCADIAAoAgg2AgggAyAAKQIANwMAIAAgBygCCDYCCCAAIAcpAgA3AgAgByADKAIINgIIIAcgAykDADcCAEEBIQgMAgsgAyAAKAIINgIIIAMgACkCADcDACAAIAQoAgg2AgggACAEKQIANwIAIAQgAygCCDYCCCAEIAMpAwA3AgBBASEIIAcgBCACKAIAEQIARQ0BIAMgBCgCCDYCCCADIAQpAgA3AwAgBCAHKAIINgIIIAQgBykCADcCACAHIAMoAgg2AgggByADKQMANwIAC0ECIQgLIAchBQJ/AkAgACAEIAIoAgARAgBFBEADQCAFQQxrIgUgAEYEQCAAQQxqIQQgACAHIAIoAgARAgANAyAEIAdGDQUDQCAAIAQgAigCABECAARAIAMgBCgCCDYCCCADIAQpAgA3AwAgBCAHKAIINgIIIAQgBykCADcCACAHIAMoAgg2AgggByADKQMANwIAIARBDGohBAwFCyAHIARBDGoiBEcNAAsMBQsgBSAEIAIoAgARAgBFDQALIAMgACgCCDYCCCADIAApAgA3AwAgACAFKAIINgIIIAAgBSkCADcCACAFIAMoAgg2AgggBSADKQMANwIAIAhBAWohCAsgBSAAQQxqIgZLBEADfyAGIglBDGohBiAJIAQgAigCABECAA0AA0AgBUEMayIFIAQgAigCABECAEUNAAsgBSAJSQR/IAkFIAMgCSgCCDYCCCADIAkpAgA3AwAgCSAFKAIINgIIIAkgBSkCADcCACAFIAMoAgg2AgggBSADKQMANwIAIAUgBCAEIAlGGyEEIAhBAWohCAwBCwshBgsCQCAEIAZGDQAgBCAGIAIoAgARAgBFDQAgAyAGKAIINgIIIAMgBikCADcDACAGIAQoAgg2AgggBiAEKQIANwIAIAQgAygCCDYCCCAEIAMpAwA3AgAgCEEBaiEICyAIRQRAIAAgBiACEPUBIQkgBkEMaiIFIAEgAhD1AQRAIAYhASAJRQ0GDAQLQQIgCQ0CGgsgBiAAa0EMbSABIAZrQQxt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B0ECdGooAgAiBEECdGovAQAiECAIIAIgA0ECdGooAgAiEUECdGovAQAiAk8EQCACIBBHBEAgAyECDAILIAMhAiAAQdgoaiIDIARqLQAAIAMgEWotAABLDQELIAchAgsgCi8BACIHIAggACACQQJ0akHcFmooAgAiA0ECdGovAQAiBEkEQCAFIQIMAgsCQCAEIAdHDQAgDS0AACAAIANqQdgoai0AAEsNACAFIQIMAgsgACAFQQJ0akHcFmogAzYCACACIQUgAkEBdCIDIAAoAtAoIgRMDQALC0ECIQMgAEHcFmoiCiACQQJ0aiALNgIAIAAgACgC1ChBAWsiBTYC1CggACgC4BYhAiAKIAVBAnRqIAw2AgAgACAAKALUKEEBayIFNgLUKCAKIAVBAnRqIAI2AgAgCCAGQQJ0aiINIAggAkECdGoiBS8BACAIIAxBAnRqIgcvAQBqOwEAIABB2ChqIgsgBmoiECACIAtqLQAAIgIgCyAMai0AACIEIAIgBEsbQQFqOgAAIAUgBjsBAiAHIAY7AQIgACAGNgLgFkEBIQVBASECAkAgACgC0CgiBEECSA0AA0ACfyADIAMgBE4NABogCCAKIANBAXIiB0ECdGooAgAiBEECdGovAQAiAiAIIAogA0ECdGooAgAiDEECdGovAQAiEU8EQCADIAIgEUcNARogAyAEIAtqLQAAIAsgDGotAABLDQEaCyAHCyECIA0vAQAiByAIIAAgAkECdGpB3BZqKAIAIgNBAnRqLwEAIgRJBEAgBSECDAILAkAgBCAHRw0AIBAtAAAgACADakHYKGotAABLDQAgBSECDAILIAAgBUECdGpB3BZqIAM2AgAgAiEFIAJBAXQiAyAAKALQKCIETA0ACwsgBkEBaiEHIAAgAkECdGpB3BZqIAY2AgAgACgC0CgiA0EBSg0ACyAAIAAoAtQoQQFrIgI2AtQoIABB3BZqIgUgAkECdGogACgC4BY2AgAgASgCBCEEIAEoAggiAigCECEGIAIoAgghCyACKAIEIREgAigCACEMIAEoAgAhByAAQdQWaiITQgA3AQAgAEHMFmoiFEIANwEAIABBxBZqIhVCADcBACAAQbwWaiIWQgA3AQBBACEKIAcgBSAAKALUKEECdGooAgBBAnRqQQA7AQICQCAAKALUKCIBQbsESg0AIAFBAWohAkEA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FCAAIAE2AhggAEEANgIMIABCgqCAgOAANwIEIAAgAUU2AhAgAEEgakEAQSgQIRogAEEcahCKAguMAQECfyAAQbCuATYCACAAKAIoIQEDQCABBEBBACAAIAFBAWsiAUECdCICIAAoAiRqKAIAIAAoAiAgAmooAgARBgAMAQsLIAAoAhwiASABKAIEQQFrIgI2AgQgAkF/RgRAIAEgASgCACgCCBEBAAsgACgCIBAbIAAoAiQQGyAAKAIwEBsgACgCPBAbIAALRAEBfyMAQRBrIgUkACAFIAEgAiADIARCgICAgICAgICAf4UQRCAFKQMAIQEgACAFKQMINwMIIAAgATcDACAFQRBqJAALxAECAX8CfkF/IQMCQCAAQgBSIAFC////////////AIMiBEKAgICAgIDA//8AViAEQoCAgICAgMD//wBRGw0AQQAgAkL///////////8AgyIFQoCAgICAgMD//wBWIAVCgICAgICAwP//AFEbDQAgACAEIAWEhFAEQEEADwsgASACg0IAWQRAQQAgASACUyABIAJRGw0BIAAgASAChYRCAFIPCyAAQgBSIAEgAlUgASACURsNACAAIAEgAoWEQgBSIQMLIAML1wMCAn4CfyMAQSBrIgQkAAJAIAFC////////////AIMiA0KAgICAgIDAgDx9IANCgICAgICAwP/DAH1UBEAgAUIEhiAAQjyIhCEDIABC//////////8PgyIAQoGAgICAgICACFoEQCADQoGAgICAgICAwAB8IQIMAgsgA0KAgICAgICAgEB9IQIgAEKAgICAgICAgAiFQgBSDQEgAiADQgGDfCECDAELIABQIANCgICAgICAwP//AFQgA0KAgICAgIDA//8AURtFBEAgAUIEhiAAQjyIhEL/////////A4NCgICAgICAgPz/AIQhAgwBC0KAgICAgICA+P8AIQIgA0L///////+//8MAVg0AQgAhAiADQjCIpyIFQZH3AEkNACAEQRBqIAAgAUL///////8/g0KAgICAgIDAAIQiAiAFQYH3AGsQPCAEIAAgAkGB+AAgBWsQbiAEKQMIQgSGIAQpAwAiAEI8iIQhAiAEKQMQIAQpAxiEQgBSrSAAQv//////////D4OEIgBCgYCAgICAgIAIWgRAIAJCAXwhAgwBCyAAQoCAgICAgICACIVCAFIN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QAJAAkACQAJAA0AgAyAHaiICLQABIAQtAANHDQYgAi0AAiAELQAERw0FIAItAAMgBC0ABUcNBCACLQAEIAQtAAZHDQMgAi0ABSAELQAHRw0CIAItAAYgBC0ACEcNASACLQAHIAQtAAlGBEAgByADQQhqIgJqIgYtAAAgBC0ACkcNCCAEQQhqIQQgA0H6AUkhEiACIQMgEg0BDAgLCyACQQdqIQYMBgsgAkEGaiEGDAULIAJBBWohBgwECyACQQRqIQYMAwsgAkEDaiEGDAILIAJBAmohBgwBCyACQQFqIQYLIAYgD2siAkGCAmoiAyAFTA0AIAAgATYCcCADIA1OBEAgAyEFDAILIAMgB2otAAAhCiACIAdqLQCBAiELIAMhBQsgDCARIAEgEHFBAXRqLwEAIgFPDQAgCUEBayIJDQELCyAIIAUgBSAISxsLnw0BCn8gACgCLCICIAAoAgxBBWsiAyACIANJGyEIIAAoAgAiAygCBCEJIAFBBEYhBwJAA0AgAygCECICIAAoArwtQSpqQQN1IgRJBEBBASEFDAILIAggAiAEayIEIAAoAmwgACgCXGsiBiADKAIEaiICQf//AyACQf//A0kbIgUgBCAFSRsiBEsEQEEBIQUgBEEARyAHckUNAiABRQ0CIAIgBEcNAgsgAEEAQQAgByACIARGcSIKEIoBIAAoAhQgACgCCGpBBGsgBDoAACAAKAIUIAAoAghqQQNrIARBCHY6AAAgACgCFCAAKAIIakECayAEQX9zIgI6AAAgACgCFCAAKAIIakEBayACQQh2OgAAIAAoAgAiAigCHCIDEDUCQCACKAIQIgUgAygCFCILIAUgC0kbIgVFDQAgAigCDCADKAIQIAUQHhogAiACKAIMIAVqNgIMIAMgAygCECAFajYCECACIAIoAhQgBWo2AhQgAiACKAIQIAVrNgIQIAMgAygCFCAFayICNgIUIAINACADIAMoAgg2AhALIAYEQCAAKAIAKAIMIAAoAjggACgCXGogBCAGIAQgBkkbIgIQHhogACgCACIDIAMoAgwgAmo2AgwgAyADKAIQIAJrNgIQIAMgAygCFCACajYCFCAAIAAoAlwgAmo2AlwgBCACayEECyAEBEAgACgCACICKAIMIQUgBCACKAIEIgYgBCAGSRsiAwRAIAIgBiADazYCBCAFIAIoAgAgAxAeIQUCQAJAAkAgAigCHCgCGEEBaw4CAAECCyACIAIoAjAgBSADEH82AjAMAQsgAiACKAIwIAUgAxBKNgIwCyACIAIoAgAgA2o2AgAgAiACKAIIIANqNgIIIAAoAgAiAigCDCEFCyACIAQgBWo2AgwgAiACKAIQIARrNgIQIAIgAigCFCAEajYCFAsgACgCACEDIApFDQALQQAhBQsCQCAJIAMoAgRrIgRFBEAgACgCbCECDAELAkAgACgCLCICIARNBEAgAEECNgKwLSAAKAI4IAMoAgAgAmsgAhAeGiAAIAAoAiwiAzYCbCADIQIMAQsCQCAAKAI8IAAoAmwiA2sgBEsNACAAIAMgAmsiAzYCbCAAKAI4IgYgAiAGaiADEB4aIAAoArAtIgJBAUsNACAAIAJBAWo2ArAtCyAAKAI4IAAoAmxqIAAoAgAoAgAgBGsgBBAeGiAAIAAoAmwgBGoiAjYCbCAAKAIsIQMLIAAgAjYCXCAAIAMgACgCtC0iA2siBiAEIAQgBksbIANqNgK0LQsgAiAAKALALUsEQCAAIAI2AsAtC0EDIQQCQCAFRQ0AIAAoAgAiAygCBCEEAkACQCABQXtxRQ0AIAQNAEEBIQQgAiAAKAJcRg0CIAAoAjwgAkF/c2ohBUEAIQQMAQsgBCAAKAI8IAJBf3NqIgVNDQAgACgCXCIHIAAoAiwiBkgNACAAIAIgBmsiAjYCbCAAIAcgBms2AlwgACgCOCIDIAMgBmogAhAeGiAAKAKwLSICQQFNBEAgACACQQFqNgKwLQsgACgCLCAFaiEFIAAoAgAiAygCBCEECwJAIAQgBSAEIAVJGyICRQRAIAAoAmwhBAwBCyAAKAJsIQUgACgCOCEGIAMgBCACazYCBCAFIAZqIAMoAgAgAhAeIQQCQAJAAkAgAygCHCgCGEEBaw4CAAECCyADIAMoAjAgBCACEH82AjAMAQsgAyADKAIwIAQgAhBKNgIwCyADIAMoAgAgAmo2AgAgAyADKAIIIAJqNgIIIAAgACgCbCACaiIENgJsCyAEIAAoAsAtSwRAIAAgBDYCwC0LIAQgACgCXCIGayICIAAoAiwiBCAAKAIMIAAoArwtQSpqQQN1ayIDQf//AyADQf//A0kbIgMgAyAESxtJBEBBACEEIAFBBEYgAkEAR3JFDQEgAUUNASAAKAIAKAIEDQEgAiADSw0BC0EAIQUgAUEERgRAIAAoAgAoAgRFIAIgA01xIQULIAAgACgCOCAGaiADIAIgAiADSxsiASAFEIoBIAAgACgCXCABajYCXCAAKAIAIgAoAhwiARA1AkAgACgCECICIAEoAhQiAyACIANJGyICRQ0AIAAoAgwgASgCECACEB4aIAAgACgCDCACajYCDCABIAEoAhAgAmo2AhAgACAAKAIUIAJqNgIUIAAgACgCECACazYCECABIAEoAhQgAmsiADYCFCAADQAgASABKAIINgIQC0ECQQAgBRshBAsgBAugAgEDfwJAIABFDQAgACgCIEUNACAAKAIkIgJFDQAgACgCHCIBRQ0AIAEoAgAgAEcNAAJAAkAgASgCBCIDQTlrDjkBAgICAgICAgICAgIBAgICAQICAgICAgICAgICAgICAgICAQICAgICAgICAgICAQICAgICAgICAgEACyADQZoFRg0AIANBKkcNAQsgASgCCCIDBEAgACgCKCADIAIRAwAgACgCHCEBCyABKAJEIgIEQCAAKAIoIAIgACgCJBEDACAAKAIcIQELIAEoAkAiAgRAIAAoAiggAiAAKAIkEQMAIAAoAhwhAQsgASgCOCICBEAgACgCKCACIAAoAiQRAwAgACgCHCEBCyAAKAIoIAEgACgCJBEDACAAQQA2AhwLCxwAIAAgAUEIIAKnIAJCIIinIAOnIANCIIinEA8LygYCBH8DfiMAQYABayIFJAACQAJAAkAgAyAEQgBCABBtRQ0AAn8gBEL///////8/gyEJAn8gBEIwiKdB//8BcSIGQf//AUcEQEEEIAYNARpBAkEDIAMgCYRQGwwCCyADIAmEUAsLRQ0AIAJCMIinIghB//8BcSIGQf//AUcNAQsgBUEQaiABIAIgAyAEECcgBSAFKQMQIgEgBSkDGCICIAEgAhDUAiAFKQMIIQIgBSkDACEEDAELIAEgAkL///////8/gyAGrUIwhoQiCiADIARC////////P4MgBEIwiKdB//8BcSIHrUIwhoQiCRBtQQBMBEAgASAKIAMgCRBtBEAgASEEDAILIAVB8ABqIAEgAkIAQgAQJyAFKQN4IQIgBSkDcCEEDAELIAYEfiABBSAFQeAAaiABIApCAEKAgICAgIDAu8AAECcgBSkDaCIKQjCIp0H4AGshBiAFKQNg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Bn8jAEEQayIDJABBASEHAkACQAJAAkACQAJAIAEgAGtBDG0OBgUFAAECAwQLIAFBDGsiASAAIAIoAgARAgBFDQQgAyAAKAIINgIIIAMgACkCADcDACAAIAEoAgg2AgggACABKQIANwIAIAEgAygCCDYCCCABIAMpAwA3AgAMBAsgAEEMaiIEIAAgAigCABECACEFIAFBDGsiASAEIAIoAgARAgAhBiAFRQRAIAZFDQQgAyAEKAIINgIIIAMgBCkCADcDACAEIAEoAgg2AgggBCABKQIANwIAIAEgAygCCDYCCCABIAMpAwA3AgAgBCAAIAIoAgARAgBFDQQgAyAAKAIINgIIIAMgACkCADcDACAAIAQoAgg2AgggACAEKQIANwIAIAQgAygCCDYCCCAEIAMpAwA3AgAMBAsgBgRAIAMgACgCCDYCCCADIAApAgA3AwAgACABKAIINgIIIAAgASkCADcCACABIAMoAgg2AgggASADKQMANwIADAQLIAMgACgCCDYCCCADIAApAgA3AwAgACAEKAIINgIIIAAgBCkCADcCACAEIAMoAgg2AgggBCADKQMANwIAIAEgBCACKAIAEQIARQ0DIAMgBCgCCDYCCCADIAQpAgA3AwAgBCABKAIINgIIIAQgASkCADcCACABIAMoAgg2AgggASADKQMANwIADAMLIAAgAEEMaiAAQRhqIAFBDGsgAhCCARoMAgsgACAAQQxqIgQgAEEYaiIGIABBJGoiBSACEIIBGiABQQxrIgEgBSACKAIAEQIARQ0BIAMgBSgCCDYCCCADIAUpAgA3AwAgBSABKAIINgIIIAUgASkCADcCACABIAMoAgg2AgggASADKQMANwIAIAUgBiACKAIAEQIARQ0BIAMgBigCCDYCCCADIAYpAgA3AwAgBiAFKAIINgIIIAYgBSkCADcCACAFIAMoAgg2AgggBSADKQMANwIAIAYgBCACKAIAEQIARQ0BIAMgBCgCCDYCCCADIAQpAgA3AwAgBCAGKAIINgIIIAQgBikCADcCACAGIAMoAgg2AgggBiADKQMANwIAIAQgACACKAIAEQIARQ0BIAMgACgCCDYCCCADIAApAgA3AwAgACAEKAIINgIIIAAgBCkCADcCACAEIAMoAgg2AgggBCADKQMANwIADAELIABBDGoiBCAAIAIoAgARAgAh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IAsgATYCqAQgC0E4NgJgIAsgC0GIAWogC0GQAWogC0HgAGoiARAsIg8oAgAiCjYChAEgCyAKQZADajYCgAEgARAgIREgC0HQAGoQICEOIAtBQGsQICEMIAtBMGoQICENIAtBIGoQICEQIwBBEGsiASQAIAsCfyACBEAgASADEJcCIgIiAyADKAIAKAIsEQMAIAsgASgCADYAeCABIAIgAigCACgCIBEDACANIAEQUyABEBwaIAEgAiACKAIAKAIcEQMAIAwgARBTIAEQHBogCyACIAIoAgAoAgwRAAA2AnQgCyACIAIoAgAoAhARAAA2AnAgASACIAIoAgAoAhQRAwAgESABEDkgARAcGiABIAIgAigCACgCGBEDACAOIAEQUyABEBwaIAIgAigCACgCJBEAAAwBCyABIAMQlgIiAiIDIAMoAgAoAiwRAwAgCyABKAIANgB4IAEgAiACKAIAKAIgEQMAIA0gARBTIAEQHBogASACIAIoAgAoAhwRAwAgDCABEFMgARAcGiALIAIgAigCACgCDBEAADYCdCALIAIgAigCACgCEBEAADYCcCABIAIgAigCACgCFBEDACARIAEQOSABEBwaIAEgAiACKAIAKAIYEQMAIA4gARBTIAEQHBogAiACKAIAKAIkEQAACzYCHCABQRBqJAAgCSAIKAIANgIAIARBgARxIhJBCXYhE0EAIQFBACECA0AgAiEKAkACQAJAAkAgAUEERg0AIAAgC0GoBGoQPUUNAEEAIQQCQAJAAkACQAJAAkAgC0H4AGogAWosAAAOBQEABAMFCQsgAUEDRg0HIAdBgMAAAn8gACgCACICKAIMIgMgAigCEEYEQCACIAIoAgAoAiQRAAAMAQsgAygCAAsgBygCACgCDBEEAARAIAtBEGogABCYAiAQIAsoAhAQqgEMAgsgBSAFKAIAQQRyNgIAQQAhAAwGCyABQQNGDQYLA0AgACALQagEahA9RQ0GIAdBgMAAAn8gACgCACICKAIMIgMgAigCEEYEQCACIAIoAgAoAiQRAAAMAQsgAygCAAsgBygCACgCDBEEAEUNBiALQRBqIAAQmAIgECALKAIQEKoBDAALAAsCfyAMLQALQQd2BEAgDCgCBAwBCyAMLQALC0EAAn8gDS0AC0EHdgRAIA0oAgQMAQsgDS0ACwtrRg0EAkACfyAMLQAL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AA8gBiAFOgAOIAYgBDoADSAGQSU6AAwgBQRAIAZBDWogBkEOahCjAgsgAiABIAIoAgAgAWsgBkEMaiADIAAoAgAQEyABajYCACAGQRBqJAALQQAgASACIAMgBEEEEFQhASADLQAAQQRxRQRAIAAgAUHQD2ogAUHsDmogASABQeQASBsgAUHFAEgbQewOazYCAAsLQAAgAiADIABBCGogACgCCCgCBBEAACIAIABBoAJqIAUgBEEAEJwBIABrIgBBnwJMBEAgASAAQQxtQQxvNgIACwtAACACIAMgAEEIaiAAKAIIKAIAEQAAIgAgAEGoAWogBSAEQQAQnAEgAGsiAEGnAUwEQCABIABBDG1BB282AgALC0EAIAEgAiADIARBBBBVIQEgAy0AAEEEcUUEQCAAIAFB0A9qIAFB7A5qIAEgAUHkAEgbIAFBxQBIG0HsDms2AgALC0AAIAIgAyAAQQhqIAAoAggoAgQRAAAiACAAQaACaiAFIARBABCeASAAayIAQZ8CTARAIAEgAEEMbUEMbzYCAAsLmwQBA38gASAAIAFGIgI6AAwCQCACDQADQCABKAIIIgItAAwNAQJAAn8gAiACKAIIIgMoAgAiBEYEQAJAIAMoAgQiBEUNACAELQAMDQAMAwsCQCABIAIoAgBGBEAgAiEBDAELIAIgAigCBCIBKAIAIgA2AgQgASAABH8gACACNgIIIAIoAggFIAMLNgIIIAIoAggiACAAKAIAIAJHQQJ0aiABNgIAIAEgAjYCACACIAE2AgggASgCCCEDCyABQQE6AAwgA0EAOgAMIAMgAygCACIAKAIEIgE2AgAgAQRAIAEgAzYCCAsgACADKAIINgIIIAMoAggiASABKAIAIANHQQJ0aiAANgIAIAAgAzYCBCADQQhqDAELAkAgBEUNACAELQAMDQAMAgsCQCABIAIoAgBHBEAgAiEBDAELIAIgASgCBCIANgIAIAEgAAR/IAAgAjYCCCACKAIIBSADCzYCCCACKAIIIgAgACgCACACR0ECdGogATYCACABIAI2AgQgAiABNgIIIAEoAgghAwsgAUEBOgAMIANBADoADCADIAMoAgQiACgCACIBNgIEIAEEQCABIAM2AggLIAAgAygCCDYCCCADKAIIIgEgASgCACADR0ECdGogADYCACAAIAM2AgAgA0EIagsgADYC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KAIcIgJFDQAgAigCACAIRw0AAkACQCACKAIEIg5BOWsOOQECAgICAgICAgICAgECAgIBAgICAgICAgICAgICAgICAgIBAgICAgICAgICAgIBAgICAgICAgICAQALIA5BmgVGDQAgDkEqRw0BCyARQQVLDQACQAJAIAgoAgxFDQAgCCgCBCIBBEAgCCgCAEUNAQsgDkGaBUcNASARQQRGDQELIAhBkI0BKAIANgIYDAELAkACQCAIKAIQRQ0AIAIoAighAyACIBE2AigCQCACKAIUBEAgAhA1AkAgCCgCECIOIAIoAhQiEiAOIBJJGyIBRQ0AIAgoAgwgAigCECABEB4aIAggCCgCDCABajYCDCACIAIoAhAgAWo2AhAgCCAIKAIUIAFqNgIUIAggCCgCECABayIONgIQIAIgAigCFCABayISNgIUIBINACACIAIoAgg2AhBBACESCyAOBEAgAigCBCEODAILDAMLIAENACARQQF0QXdBACARQQRLG2ogA0EBdEF3QQAgA0EEShtqSg0AIBFBBEYNAAwBCwJAAkACQAJAAkAgDkEqRwRAIA5BmgVHDQEgCCgCBEUNAgwGCyACKAIwQQx0QYDwAWshAUEAIQMCQCACKAKIAUEBSg0AIAIoAoQBIgRBAkgNAEHAACEDIARBBkkNAEGAAUHAASAEQQZGGyEDCyACIBJBAWo2AhQgAigCCCASaiABIANyIgFBIHIgASACKAJsGyIDQQh2OgAAIAIgAigCFCIBQQFqNgIUIAEgAigCCGogA0EfcCADckEfczoAACACKAJsBEAgCCgCMCEDIAIgAigCFCIBQQFqNgIUIAEgAigCCGogA0EYdjoAACACIAIoAhQiAUEBajYCFCABIAIoAghqIANBEHY6AAAgCCgCMCEDIAIgAigCFCIBQQFqNgIUIAEgAigCCGogA0EIdjoAACACIAIoAhQiAUEBajYCFCABIAIoAghqIAM6AAALIAhBAEEAQQAQfzYCMCACQfEANgIEIAgQcyACKAIUDQYgAigCBCEOCwJAAkACQAJAAkACQCAOQTlGBH8gCEEAQQBBABBKNgIwIAIgAigCFCIBQQFqNgIUIAEgAigCCGpBHzoAACACIAIoAhQiAUEBajYCFCABIAIoAghqQYsBOgAAIAIgAigCFCIBQQFqNgIUIAEgAigCCGpBCDoA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ASACKAIIaiACQbktai0AADoAACACQQJBECACKAK8LSIBa3YiAzsBuC0gAUENawwBCyABQQNqCyIBNgK8LSACAn8gAUEKTgRAIAIgAigCFCIBQQFqNgIUIAEgAigCCGogAzoAACACIAIoAhQiAUEBajYCFCABIAIoAghqIAJBuS1qLQAAOgAAQQAhAyACQQA7AbgtIAIoArwtQQlrDAELIAFBB2oLIgE2ArwtAkAgAgJ/IAFBEEYEQCACIAIoAhQiAUEBajYCFCABIAIoAghqIAM6AAAgAiACKAIUIgFBAWo2AhQgASACKAIIaiACQbktai0AADoAACACQQA7AbgtQQAMAQsgAUEISA0BIAIgAigCFCIBQQFqNgIUIAEgAigCCGogAzoAACACIAJBuS1qLQAAOwG4LSACKAK8LUEIaws2ArwtCwwBCyACQQBBAEEAEIoBIBFBA0cNACACKAJEIgMgAigCTEEBdEECayIBakEAOwEAIANBACABECEaIAIoAnQNACACQQA2ArQtIAJBADYCXCACQQA2AmwLIAgQcyAIKAIQDQAMAwsgEUEERw0DIAIoAhgiAUEATA0DIAgoAjAhAwJAIAFBAkYEQCACIAIoAhQiAUEBajYCFCABIAIoAghqIAM6AAAgCCgCMCEDIAIgAigCFCIBQQFqNgIUIAEgAigCCGogA0EIdjoAACAILwEyIQMgAiACKAIUIgFBAWo2AhQgASACKAIIaiADOgAAIAgtADMhAyACIAIoAhQiAUEBajYCFCABIAIoAghqIAM6AAAgCCgCCCEDIAIgAigCFCIBQQFqNgIUIAEgAigCCGogAzoAACAIKAIIIQMgAiACKAIUIgFBAWo2AhQgASACKAIIaiADQQh2OgAAIAgvAQohAyACIAIoAhQiAUEBajYCFCABIAIoAghqIAM6AAAgCC0ACyEDDAELIAIgAigCFCIBQQFqNgIUIAEgAigCCGogA0EYdjoAACACIAIoAhQiAUEBajYCFCABIAIoAghqIANBEHY6AAAgCCgCMCEDIAIgAigCFCIBQQFqNgIUIAEgAigCCGogA0EIdjoAAAsgAiACKAIUIgFBAWo2AhQgASACKAIIaiADOgAAIAgQcyACKAIYIgFBAEoEQCACQQAgAWs2AhgLIAIoAhQaDAMLIAJBfzYCKAwCCyAIQZyNASgCADYC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ECACQYAUOwESIAJB4QspAAA3AwggAiACQQhqIgE2AjAgAkE4aiAFIAEgAkEwahBfIAIoAjgiASEDIAEsACdBAEgEQCADKAIcEBsLIAMgAikDGDcCHCADIAIoAiA2AiQgAkEAOgAjIAJBADoAGAJAIAIsABNBAE4NACACKAIIEBsgAiwAI0EATg0AIAIoAhgQGwsgAkEYakEBEIABIAJBADoAESACQfQOLQAAOgAQIAJBCToAEyACQewOKQAANwMIIAIgAkEIaiIBNgIwIAJBOGogBSABIAJBMGoQXyACKAI4IgEhAyABLAAnQQBIBEAgAygCHBAbCyADIAIpAxg3AhwgAyACKAIgNgIkIAJBADoAIyACQQA6ABgCQCACLAATQQBODQAgAigCCBAbIAIsACNBAE4NACACKAIYEBsLIAJBGGpBChCAASACQQA6ABAgAkLtyoXz9qqat8YANwMIIAJBCDoAEyACIAJBCGoiATYCMCACQThqIAUgASACQTBqEF8gAigCOCIBIQMgASwAJ0EASARAIAMoAhwQGwsgAyACKQMYNwIcIAMgAigCIDYCJCACQQA6ACMgAkEAOgAYAkAgAiwAE0EATg0AIAIoAggQGyACLAAjQQBODQAgAigCGBAbCyACQRhqQQUQgAEgAkEAOgAQIAJC7cqF8/aqmrfUADcDCCACQQg6ABMgAiACQQhqIgE2AjAgAkE4aiAFIAEgAkEwahBfIAIoAjgiASEDIAEsACdBAEgEQCADKAIcEBsLIAMgAikDGDcCHCADIAIoAiA2AiQgAkEAOgAjIAJBADoAGAJAIAIsABNBAE4NACACKAIIEBsgAiwAI0EATg0AIAIoAhgQGwsgAkEYakEeEIABIAJBADoAESACQcMULQAAOgAQIAJBCToAEyACQbsUKQAANwMIIAIgAkEIaiIBNgIwIAJBOGogBSABIAJBMGoQXyACKAI4IgEhAyABLAAnQQBIBEAgAygCHBAbCyADIAIpAxg3AhwgAyACKAIgNgIkIAJBADoAIyACQQA6ABgCQCACLAATQQBODQAgAigCCBAbIAIsACNBAE4NACACKAIYEBsLIAJBGGpBCBCAASACQQA6ABEgAkH+Ey0AADoAECACQQk6ABMgAkH2EykAADcDCCACIAJBCGoiATYCMCACQThqIAUgASACQTBqEF8gAigCOCIBIQMgASwAJ0EASARAIAMoAhwQGwsgAyACKQMYNwIcIAMgAigCIDYCJCACQQA6ACMgAkEAOgAYAkAgAiwAE0EATg0AIAIoAggQGyACLAAjQQBODQAgAigCGBAbCyACQUBrJAAjAEFAaiIKJAAgDCARKQIANwIAIAwgESkCEDcCECAMIBEpAgg3AgggDEEcaiIFQgA3AgAgDCAFNgIYIBEoAhgiASARQRxqIhNHBEAgDEEYaiEVA0AgASIIIhRBEGohEiMAQRBrIg0kACAKAn8gDUEMaiEWIA1BCGohDgJAAkACQAJAAkACQCAFIgYgFUEEaiIERg0AIAUoAhQgBS0AGyIBIAFBGHRBGHVBAEgiAxsiDyASKAIEIBItAAsiASABQRh0QRh1IglBAEgiARsiCyALIA9LIgcbIhAEQCASKAIAIBIgARsiAiAFQRBqIgEoAgAgASADGyIDIBAQJSIBRQRAIAsgD0kNAgwDCyABQQBODQIMAQsgCyAPTw0CCyAFKAIAIQICQAJAIAUiASAVKAIARg0AAkAgAgRAIAIhAwNAIAMiASgCBCIDDQALDAELIAZBCGohASAGIAYoAggoAgBGBEADQCABKAIAIgNBCGohASADIAMoAggoAgBGDQALCyABKAIAIQELAkAgEigCBCASLQALIgMgA0EYdEEYdUEASCIEGyIOIAEoAhQgAS0AGyIDIANBGHRBGHVBAEgiCRsiECAOIBBJGyIHBEAgAUEQaiIDKAIAIAMgCRsgEigCACASIAQbIAcQJSIDDQELIA4gEEsNAQwCCyADQQBODQELIAJFBEAgFiAGNgIAIAYMBwsgFiABNgIAIAFBBGoMBgsgFSAWIBIQkgIMBQsgAyACIBAQJSIBDQELIAcNAQwCCyABQQBODQELAkAgBSgCBCICBEAgAiEDA0AgAyIBKAIAIgMNAAsMAQsgBSgCCCIBKAIAIAVGDQAgBUEIaiEDA0AgAygCACIGQQhqIQMgBiAGKAIIIgEoAgBHDQALCwJAAkAgASAERg0AAkAgASgCFCABLQAbIgMgA0EYdEEYdUEASCIHGyIEIAsgBCALSRsiBgRAIBIoAgAgEiAJQQBIGyABQRBqIgMoAgAgAyAHGyAGECUiAw0BCyAEIAtLDQEMAgsgA0EATg0BCyACRQRAIBYgBTYCACAFQQRqDAMLIBYgATYCACABDAILIBUgFiASEJICDAELIBYgBTYCACAOIAU2AgAgDgsiAigCACIBBH9BAAVBKBAdIgFBEGohAwJAIBQsABtBAE4EQCADIBQpAhA3AgAgAyAUKAIYNgIIDAELIAMgFCgCECAUKAIUEKsBCyABQRxqIQMCQCAULAAnQQBOBEAgAyAUKQIcNwIAIAMgFCgCJDYCCAwBCyADIBQoAhwgFCgCIBCrAQsgASANKAIMNgIIIAFCADcCACACIAE2AgAgFSgCACgCACIDBH8gFSADNgIAIAIoAgAFIAELIQMgFSgCBCADEKoCIBUgFSgCCEEBajYCCEEBCzoADCAKIAE2AgggDUEQaiQAAkAgCCgCBCIDRQRAIAgoAggiASgCACAIRg0BIAhBCGohAwNAIAMoAgAiAkEIaiEDIAIgAigCCCIBKAIARw0ACwwBCwNAIAMiASgCACIDDQALCyABIBNHDQALCyAMQgA3AiggDEEAOgAkIBEoAgAhAyAMQgA3AjAgDEEANgI4AkAgAwRAIANBAXQiAUGAgICABE8NASAMIANBA3QiBRAdIgM2AjAgDCADIAFBAnRqNgI4IAwgA0EAIAUQISAFajYCNAsgDEIANwI8IAxCADcCRCAKIBEoAgQ2AjAgCiARKAIINgI0IAogESgCDDYCOCAKIBEoAhA2AjxBHBAdIgUhCSMAQRBrIgQkACAJIAopAjA3AgAgCSAKKQI4NwIIIAlBADYCGCAJQgA3AhBBACEGQRgQHSIIIAkoAgAiBzYCBCAIQgA3AhAgCEGwHzYCACAIIAdBAXEEfyAHQQFqQQJtBSAHQQJtQQFqCzYCCCAHQQN0QYgCahAoIgMEQCADQQA2AgQgAyAHNgIAIAe3IRogB0EASgRAA0AjAEEQayICJAACQCAGt0QYLURU+yEZwKIgGqMiGCIZvUIgiKdB/////wdxIgFB+8Ok/wNNBEAgAUGAgMDyA0kNASAZRAAAAAAAAAAAQQAQiAEhGQwBCyABQYCAwP8HTwRAIBkgGaEhGQwBCwJAAkACQAJAIBkgAhDcAkEDcQ4DAAECAwsgAisDACACKwMIQQEQiAEhGQwDCyACKwMAIAIrAwgQiQEhGQwCCyACKwMAIAIrAwhBARCIAZohGQwBCyACKwMAIAIrAwgQiQGaIRkLIAJBEGokACADIAZBA3RqIgEgGbY4AowCIAEgGBBwtjgCiAIgBkEBaiIGIAdHDQALCyADQQhqIQEgGp+cIRhBBCECA0AgByACbwRAA0BBAiEGAkACQAJAIAJBAmsOAwABAgELQQMhBgwBCyACQQJqIQYLIAcgByAGIBggBrdjGyICbw0ACwsgASACNgIAIAEgByACbSIHNgIEIAFBCGohASAHQQFKDQALCyAIIAM2AgwgCEF/IAgoAgQiAUEDdCABQf////8BcSABRxsiARAdQQAgARAhNgIQIAggARAdQQAgARAhNgIUIAkgCDYCECAJQX8gCSgCBCIGQQJ0IAZB/////wNxIAZHGyIBEB1BACABECE2AhQCQAJAAkAgCSgCDA4CAAECC0EAIQggBEEANgIIIARCADcCAAJAIAZFDQAgBkGAgICABEkEQCAEIAZBAnQiAhAdIgM2AgAgBCACIANqIgE2AgggA0EAIAIQISEDIAQgATYCBCAGQQFqtyEYIAZBAUcEQCAGQX5xIQ8DQCADIAhBAnRqRAAAAAAAAOA/IAhBAXIiAbdEGC1EVPshGUCiIBijEHBEAAAAAAAA4D+iobY4AgAgAyABQQJ0akQAAAAAAADgPyAIQQJqIgi3RBgtRFT7IRlAoiAYoxBwRAAAAAAAAOA/oqG2OAIAIA9BAmsiDw0ACwsgBkEBcUUNASADIAhBAnRqRAAAAAAAAOA/IAhBAWq3RBgtRFT7IRlAoiAYoxBwRAAAAAAAAOA/oqG2OAIADAELDAMLIAQoAgQgBCgCACIDayIBBEAgCSgCFCADIAEQaCAEKAIAIQMLIANFDQEgBCADNgIEIAMQGwwBC0EAIQggBEEANgIIIARCADcCAAJAIAZFDQAgBkGAgICABEkEQCAEIAZBAnQiAhAdIgM2AgAgBCACIANqIgE2AgggA0EAIAIQISEDIAQgATYCBCAGQQFrtyEYIAZBAUcEQCAGQX5xIQ8DQCADIAhBAnRqREjhehSuR+E/IAi3RBgtRFT7IRlAoiAYoxBwRHE9CtejcN0/oqG2OAIAIAMgCEEBciIBQQJ0akRI4XoUrkfhPyABt0QYLURU+yEZQKIgGKMQcERxPQrXo3DdP6KhtjgCACAIQQJqIQggD0ECayIPDQALCyAGQQFxRQ0BIAMgCEECdGogCLdEGC1EVPshGUCiIBijEHBEcT0K16Nw3b+iREjhehSuR+E/oLY4AgAMAQsMAgsgBCgCBCAEKAIAIgNrIgEEQCAJKAIUIAMgARBoIAQoAgAhAwsgA0UNACAEIAM2AgQgAxAbCyAJQX8gCSgCECIDKAIEIgFBAnQgAUH/////A3EgAUcbIgEQHUEAIAEQITYCGCADKAIEIAkoAgRIBEBBkhpBhg1BJkHsCxADAAsgBEEQaiQAIAwgBTYCKAJAAkACQAJAIBEoAhQOAgABAgtBACEOIwBBEGsiBCQAQRAQHSIHQc8NKQAANwAGIAdByQ0pAAA3AAAgB0EAOgAOAkAgEUEYaiIJKAIEIgEEQANAAkACQAJAAkAgASgCFCABLQAbIgMgA0EYdEEYdUEASCIFGyIGQQ4gBkEOSSICGyIIBEACQCAHIAFBEGoiAygCACADIAUbIgUgCBAlIgNFBEAgBkEOTQ0BDAYLIANBAEgNBQsgBSAHIAgQJSIDRQ0BIANBAE4NAgwDCyAGQQ5LDQMLIAINAQsgBxAbIARBBjoACyAEQQA6AAYgBEHWFC8AADsBBCAEQdIUKAAANgIAIAkoAgQiA0UNBCADIQEDQAJAAkACQAJAIAEoAhQgAS0AGyIFIAVBGHRBGHVBAEgiAhsiB0EGIAdBBkkiCBsiBgRAAkAgBCABQRBqIgUoAgAgBSACGyICIAYQJSIFRQRAIAdBBk0NAQwGCyAFQQBIDQULIAIgBCAGECUiBUUNASAFQQBODQIMAwsgB0EGSw0DCyAIDQELIARBBjoACyAEQQA6AAYgBEHLFCgAADYCACAEQc8ULwAAOwEEIANFDQcgAyEBA0ACQAJAAkACQCABKAIUIAEtABsiBSAFQRh0QRh1QQBIIgIbIgdBBiAHQQZJIggbIgYEQAJAIAQgAUEQaiIFKAIAIAUgAhsiAiAGECUiBUUEQCAHQQZNDQEMBgsgBUEASA0FCyACIAQgBhAlIgVFDQEgBUEATg0CDAMLIAdBBksNAwsgCA0BC0EQEB0iB0HVDigAADYACCAHQc0OKQAANwAAIAdBADoADCADBEADQAJAAkACQAJAIAMoAhQgAy0AGyIBIAFBGHRBGHVBAEgiBRsiBkEMIAZBDEkiAhsiCARAAkAgByADQRBqIgEoAgAgASAFGyIFIAgQJSIBRQRAIAZBDE0NAQwGCyABQQBIDQULIAUgByAIECUiAUUNASABQQBODQIMAwsgBkEMSw0DCyACDQELIAcQG0EQEB0iB0H0EikAADcABiAHQe4SKQAANwAAIAdBADoADiAJKAIEIgEEQANAAkACQAJAAkAgASgCFCABLQAbIgMgA0EYdEEYdUEASCIFGyIGQQ4gBkEOSSICGyIIBEACQCAHIAFBEGoiAygCACADIAUbIgUgCBAlIgNFBEAgBkEOTQ0BDAYLIANBAEgNBQsgBSAHIAgQJSIDRQ0BIANBAE4NAgwDCyAGQQ5LDQMLIAINAQsgBxAbQRAQHSIHQYMTKQAANwAGIAdB/RIpAAA3AAAgB0EAOgAOIAkoAgQiAQRAA0ACQAJAAkACQCABKAIUIAEtABsiAyADQRh0QRh1QQBIIgUbIgZBDiAGQQ5JIgIbIggEQAJAIAcgAUEQaiIDKAIAIAMgBRsiBSAIECUiA0UEQCAGQQ5NDQEMBgsgA0EASA0FCyAFIAcgCBAlIgNFDQEgA0EATg0CDAMLIAZBDksNAwsgAg0BCyAHEBsgBEEQEB0iATYCACAEQo6AgICAgoCAgH83AgQgAUEAOgAOIAFBzw0pAAA3AAYgAUHJDSkAADcAACAKIAkgBBBCIgEoAgAgASABLAALQQBIGxBgtjgCCCAELAALQQBIBEAgBCgCABAbCyAEQQY6AAsgBEEAOgAGIARB0hQoAAA2AgAgBEHWFC8AADsBBCAKIAkgBBBCIgEoAgAgASABLAALQQBIGxBgtjgCDCAELAALQQBIBEAgBCgCABAbCyAEQQY6AAsgBEEAOgAGIARByxQoAAA2AgAgBEHPFC8AADsBBCAKIAkgBBBCIgEoAgAgASABLAALQQBIGxBgtjgCECAELAALQQBIBEAgBCgCABAbCyAEQRAQHSIBNgIAIARCjICAgICCgICAfzcCBCABQQA6AAwgAUHVDigAADYACCABQc0OKQAANwAAIAogCSAEEEIiASgCACABIAEsAAtBAEgbEH42AhQgBCwAC0EASARAIAQoAgAQGwsgBEEQEB0iATYCACAEQo6AgICAgoCAgH83AgQgAUEAOgAOIAFB9BIpAAA3AAYgAUHuEikAADcAACAKIAkgBBBCIgEoAgAgASABLAALQQBIGxBgtjgCGCAELAALQQBIBEAgBCgCABAbCyAEQRAQHSIBNgIAIARCjoCAgICCgICAfzcCBCABQQA6AA4gAUGDEykAADcABiABQf0SKQAANwAAIAogCSAEEEIiASgCACABIAEsAAtBAEgbEGC2OAIcIAQsAAtBAEgEQCAEKAIAEBsLQQEhDgwWCyABQQRqIQELIAEoAgAiAQ0ACwsgBxAbDBILIAFBBGohAQsgASgCACIBDQALCyAHEBsMDgsgA0EEaiEDCyADKAIAIgMNAAsLIAcQGwwKCyABQQRqIQELIAEoAgAiAQ0ACwwHCyABQQRqIQELIAEoAgAiAQ0ACwwECyABQQRqIQELIAEoAgAiAQ0ACwsgBxAbCyAEQRBqJAAgDkUNAiAMAn9B6AAQHSELIAwoAigoAhAoAgghAUEAIRBBACEPQQAhDkEAIQdBACEGQQAhCCMAQRBrIgQkACALQfAfNgIAIAsgATYCBCALIAopAgg3AgggCyAKKQIQNwIQIAsgCikCGDcCGCALAn8gCioCDCAKKgIIIhyVIhuLQwAAAE9dBEAgG6gMAQtBgICAgHgLIgM2AiAgCioCECEbIAtCADcCLCALQgA3AjQgC0IANwI8IAtCADcCRCALQQA2AkwgCwJ/IBsgHJUiG4tDAAAAT10EQCAbqAwBC0GAgICAeAsiATYCJCALIAEgA2siEzYCKAJAIBNFBEAgC0IANwJQIAtCADcCYCALQgA3AlhBACEJDAELIBNBgICAgARPDQUgCyATQQJ0IgEQHSIONgJEIAsgASAOaiIINgJMIA5BACABECEaIAtBADYCWCALQgA3AlAgCyAINgJIIBNBgICAgAJPDQUgCyATQQN0IgEQHSIPNgJQIAsgASAPaiIGNgJYIA9BACABECEaIAtBADYCZCALQgA3AlwgCyAGNgJUIAsgARAdIgk2AlwgCyABIAlqIgc2AmQgCUEAIAEQIRogCyAHNgJgCyAKKgIYIRwgBEEANgIIIARCADcDAAJAAkACfyAcQwAAQECUIhuLQwAAAE9dBEAgG6gMAQtBgICAgHgLIgFFBEBBACENDAELIAFBgICAgARPDQEgAUECdCIBEB0iDUEAIAEQISABaiEQCwJAIBAgDWsiAUUNACABQQJ1IgJBASACQQFLGyIBQQFxIQVBACEDIAJBAk8EQCABQX5xIQIDQCANIANBAnRqIAOyIByVuyIYIBiiRAAAAAAAAOC/ohBxtjgCACANIANBAXIiAUECdGogAbIgHJW7IhggGKJEAAAAAAAA4L+iEHG2OAIAIANBAmohAyACQQJrIgINAAsLIAVFDQAgDSADQQJ0aiADsiAclbsiGCAYokQAAAAAAADgv6IQcbY4AgALIAsgEDYCNCALIBA2AjAgCyANNgIsIAoqAhwhG0EAIRAgBEEANgIIIARCADcDAAJAIBNFBEBBACENDAELIBNBgICAgARPDQEgE0ECdCIBEB0iDUEAIAEQISABaiEQCwJAIBAgDWsiAUUNACABQQJ1IgJBASACQQFLGyIBQQFxIQVBACEDIAJBAk8EQCABQX5xIQIDQCANIANBAnRqIAOyIBuVuyIYIBiiRAAAAAAAAOC/ohBxtjgCACANIANBAXIiAUECdGogAbIgG5W7IhggGKJEAAAAAAAA4L+iEHG2OAIAIANBAmohAyACQQJrIgINAAsLIAVFDQAgDSADQQJ0aiADsiAblbsiGCAYokQAAAAAAADgv6IQcbY4AgALIAsgDTYCOCALQUBrIBA2AgAgCyAQNgI8IAggDmsiAUEASgRAIA5BACABQQJ2IAFBA0trQQJ0QQRqECEaCyAGIA9rIgFBAEoEQCAPQQAgAUEDdiABQQdLa0EDdEEIahAhGgsgByAJayIBQQBKBEAgCUEAIAFBA3YgAUEHS2tBA3RBCGoQIRoLIARBEGokACALDAELDAQLNgIsDAELQQAhDiMAQRBrIgQkAEEQEB0iB0HPDSkAADcABiAHQckNKQAANwAAIAdBADoADgJAIBFBGGoiCSgCBCIBBEADQAJAAkACQAJAIAEoAhQgAS0AGyIDIANBGHRBGHVBAEgiBRsiBkEOIAZBDkkiAhsiCARAAkAgByABQRBqIgMoAgAgAyAFGyIFIAgQJSIDRQRAIAZBDk0NAQwGCyADQQBIDQULIAUgByAIECUiA0UNASADQQBODQIMAwsgBkEOSw0DCyACDQELIAcQGyAEQQY6AAsgBEEAOgAGIARB1hQvAAA7AQQgBEHSFCgAADYCACAJKAIEIgNFDQQgAyEBA0ACQAJAAkACQCABKAIUIAEtABsiBSAFQRh0QRh1QQBIIgIbIgdBBiAHQQZJIggbIgYEQAJAIAQgAUEQaiIFKAIAIAUgAhsiAiAGECUiBUUEQCAHQQZNDQEMBgsgBUEASA0FCyACIAQgBhAlIgVFDQEgBUEATg0CDAMLIAdBBksNAwsgCA0BCyAEQQY6AAsgBEEAOgAGIARByxQoAAA2AgAgBEHPFC8AADsBBCADRQ0HIAMhAQNAAkACQAJAAkAgASgCFCABLQAbIgUgBUEYdEEYdUEASCICGyIHQQYgB0EGSSIIGyIGBEACQCAEIAFBEGoiBSgCACAFIAIbIgIgBhAlIgVFBEAgB0EGTQ0BDAYLIAVBAEgNBQsgAiAEIAYQJSIFRQ0BIAVBAE4NAgwDCyAHQQZLDQMLIAgNAQsgBEHpCy8AADsBCCAEQYAUOwEKIARB4QspAAA3AwAgA0UNCiADIQEDQAJAAkACQAJAIAEoAhQgAS0AGyIFIAVBGHRBGHVBAEgiAhsiB0EKIAdBCkkiCBsiBgRAAkAgBCABQRBqIgUoAgAgBSACGyICIAYQJSIFRQRAIAdBCk0NAQwGCyAFQQBIDQULIAIgBCAGECUiBUUNASAFQQBODQIMAwsgB0EKSw0DCyAIDQELIARBADoACSAEQcMULQAAOgAIIARBCToACyAEQbsUKQAANwMAIANFDQ0gAyEBA0ACQAJAAkACQCABKAIUIAEtABsiBSAFQRh0QRh1QQBIIgIbIgdBCSAHQQlJIggbIgYEQAJAIAQgAUEQaiIFKAIAIAUgAhsiAiAGECUiBUUEQCAHQQlNDQEMBgsgBUEASA0FCyACIAQgBhAlIgVFDQEgBUEATg0CDAMLIAdBCUsNAwsgCA0BCyAEQQA6AAkgBEH+Ey0AADoACCAEQQk6AAsgBEH2EykAADcDACADRQ0QA0ACQAJAAkACQCADKAIUIAMtABsiASABQRh0QRh1QQBIIgUbIgZBCSAGQQlJIgIbIggEQAJAIAQgA0EQaiIBKAIAIAEgBRsiBSAIECUiAUUEQCAGQQlNDQEMBgsgAUEASA0FCyAFIAQgCBAlIgFFDQEgAUEATg0CDAMLIAZBCUsNAwsgAg0BCyAEQRAQHSIBNgIAIARCjoCAgICCgICAfzcCBCABQQA6AA4gAUHPDSkAADcABiABQckNKQAANwAAIAogCSAEEEIiASgCACABIAEsAAtBAEgbEGC2OAIIIAQsAAtBAEgEQCAEKAIAEBsLIARBBjoACyAEQQA6AAYgBEHSFCgAADYCACAEQdYULwAAOwEEIAogCSAEEEIiASgCACABIAEsAAtBAEgbEGC2OAIMIAQsAAtBAEgEQCAEKAIAEBsLIARBBjoACyAEQQA6AAYgBEHLFCgAADYCACAEQc8ULwAAOwEEIAogCSAEEEIiASgCACABIAEsAAtBAEgbEGC2OAIQIAQsAAtBAEgEQCAEKAIAEBsLIARB6QsvAAA7AQggBEGAFDsBCiAEQeELKQAANwMAIAogCSAEEEIiASgCACABIAEsAAtBAEgbEGC2OAIUIAQsAAtBAEgEQCAEKAIAEBsLIARBADoACSAEQcMULQAAOgAIIARBCToACyAEQbsUKQAANwMAIAogCSAEEEIiASgCACABIAEsAAtBAEgbEH42AhggBCwAC0EASARAIAQoAgAQGwsgBEEAOgAJIARB/hMtAAA6AAggBEEJOgALIARB9hMpAAA3AwAgCiAJIAQQQiIBKAIAIAEgASwAC0EASBsQfjYCHCAELAALQQBIBEAgBCgCABAbCyAEQQA6AAkgBEH0Di0AADoACCAEQQk6AAsgBEHsDikAADcDAAJAIAkoAgQiAQRAIAEhAwNAAkACQAJAAkAgAygCFCADLQAbIgUgBUEYdEEYdUEASCICGyIHQQkgB0EJSSIIGyIGBEACQCAEIANBEGoiBSgCACAFIAIbIgIgBhAlIgVFBEAgB0EJTQ0BDAYLIAVBAEgNBQsgAiAEIAYQJSIFRQ0BIAVBAE4NAgwDCyAHQQlLDQMLIAgNAQsgBEEAOgAJIARB9A4tAAA6AAggBEEJOgALIARB7A4pAAA3AwAgCiAJIAQQQiIBKAIAIAEgASwAC0EASBsQfjYCKCAELAALQQBIBEAgBCgCABAbCyAJKAIEIQEMBAsgA0EEaiEDCyADKAIAIgMNAAsLIApBADYCKAsgBEEAOgAIIARC7cqF8/aqmrfGADcDACAEQQg6AAsCQCABBEAgASEDA0ACQAJAAkACQCADKAIUIAMtABsiBSAFQRh0QRh1QQBIIgIbIgdBCCAHQQhJIggbIgYEQAJAIAQgA0EQaiIFKAIAIAUgAhsiAiAGECUiBUUEQCAHQQhNDQEMBgsgBUEASA0FCyACIAQgBhAlIgVFDQEgBUEATg0CDAMLIAdBCEsNAwsgCA0BCyAEQu3KhfP2qpq3xgA3AwAgBEEIOgALIARBADoACCAKIAkgBBBCIgEoAgAgASABLAALQQBIGxB+NgIgIAQsAAtBAEgEQCAEKAIAEBsLIAkoAgQhAQwECyADQQRqIQMLIAMoAgAiAw0ACwsgCkEKNgIgCyAEQQA6AAggBELtyoXz9qqat9QANwMAIARBCDoACwJAIAEEQANAAkACQAJAAkAgASgCFCABLQAbIgMgA0EYdEEYdUEASCIFGyIGQQggBkEISSICGyIIBEACQCAEIAFBEGoiAygCACADIAUbIgUgCBAlIgNFBEAgBkEITQ0BDAYLIANBAEgNBQsgBSAEIAgQJSIDRQ0BIANBAE4NAgwDCyAGQQhLDQMLIAINAQsgBELtyoXz9qqat9QANwMAIARBCDoACyAEQQA6AAggCiAJIAQQQiIBKAIAIAEgASwAC0EASBsQfjYCJCAELAALQQBODQQgBCgCABAbDAQLIAFBBGohAQsgASgCACIBDQALCyAKQQU2AiQLQQEhDgwTCyADQQRqIQMLIAMoAgAiAw0ACwwQCyABQQRqIQELIAEoAgAiAQ0ACwwNCyABQQRqIQELIAEoAgAiAQ0ACwwKCyABQQRqIQELIAEoAgAiAQ0ACwwHCyABQQRqIQELIAEoAgAiAQ0ACwwECyABQQRqIQELIAEoAgAiAQ0ACwsgBxAbCyAEQRBqJAAgDkUNAUE8EB0hBSAMKAIoKAIQKAIIIQEgBUHUIDYCACAFIAE2AgQgBSAKKQIINwIIIAUgCikCEDcCECAFIAopAhg3AhggBSAKKQIgNwIgIAUgCigCKDYCKCAFAn8gCioCDCAKKgIIIhyVIhuLQwAAAE9dBEAgG6gMAQtBgICAgHgLIgM2AiwgBQJ/IAoqAhAgHJUiG4tDAAAAT10EQCAbqAwBC0GAgICAeAsiATYCMCAFIAEgA2s2AjQgBSAFKAIoQQBHOgA4IAwgBTYCLAsgDEEBOgAkCyAKQUBrJAAgACAMNgIsIBdBIGogFygCJBCdASAAKAIsLQAkRQRAQa0aQfEMQdIAQdEJEAMACyAXQTBqJAAgAA8LEEEAC7YBAgJ9A38jAEEQayIFJAACQAJAAkAgACABRwRAQaibAigCACEHQaibAkEANgIAECYaIwBBEGsiBiQAIAYgACAFQQxqQQAQywEgBikDACAGKQMIENMCIQMgBkEQaiQAQaibAigCACIARQ0BIAUoAgwgAUcNAiADIQQgAEHEAEcNAwwCCyACQQQ2AgAMAgtBqJsCIAc2AgAgBSgCDCABRg0BCyACQQQ2AgAgBCEDCyAFQRBqJAAgAwvGAQIDfwF+IwBBEGsiBCQAAn4CQAJAIAAgAUcEQAJAAkAgAC0AACIFQS1HDQAgAEEBaiIAIAFHDQAMAQtBqJsCKAIAIQZBqJsCQQA2AgAgACAEQQxqIAMQJhDBASEHAkBBqJsCKAIAIgAEQCAEKAIMIAFHDQEgAEHEAEYNBAwFC0GomwIgBjYCACAEKAIMIAFGDQQLCwsgAkEENgIAQgAMAgsgAkEENgIAQn8MAQtCACAHfSAHIAVBLUYbCyEHIARBEGokACAHC9cBAgN/AX4jAEEQayIEJAACfwJAAkACQCAAIAFHBEACQAJAIAAtAAAiBUEtRw0AIABBAWoiACABRw0ADAELQaibAigCACEGQaibAkEANgIAIAAgBEEMaiADECYQwQEhBwJAQaibAigCACIABEAgBCgCDCABRw0BIABBxABGDQUMBAtBqJsCIAY2AgAgBCgCDCABRg0DCwsLIAJBBDYCAEEADAMLIAdC/////w9YDQELIAJBBDYCAEF/DAELQQAgB6ciAGsgACAFQS1GGwshACAEQRBqJAAgAAu+BAEBfyMAQZACayIAJAAgACACNgKAAiAAIAE2AogCIAMQVyEGIABB0AFqIAMgAEH/AWoQhgEgAEHAAWoQICIBIAEtAAtBB3YEfyABKAIIQf////8HcUEBawVBCgsQHyAAAn8gAS0AC0EHdgRAIAEoAgAMAQsgAQsiAjYCvAEgACAAQRBqNgIMIABBADYCCANAAkAgAEGIAmogAEGAAmoQPkUNACAAKAK8AQJ/IAEtAAtBB3YEQCABKAIEDAELIAEtAAsLIAJqRgRAAn8gASICLQALQQd2BEAgAigCBAwBCyACLQALCyEDIAICfyACLQALQQd2BEAgAigCBAwBCyACLQALC0EBdBAfIAIgAi0AC0EHdgR/IAIoAghB/////wdxQQFrBUEKCxAfIAAgAwJ/IAItAAtBB3YEQCABKAIADAELIAELIgJqNgK8AQsgAEGIAmoQLSAGIAIgAEG8AWogAEEIaiAALAD/ASAAQdABaiAAQRBqIABBDGpB4NMBEHsNACAAQYgCahAxGgwBCwsCQAJ/IAAtANsBQQd2BEAgACgC1AEMAQsgAC0A2wELRQ0AIAAoAgwiAyAAQRBqa0GfAUoNACAAIANBBGo2AgwgAyAAKAIINgIACyAFIAIgACgCvAEgBCAGEMMCNgIAIABB0AFqIABBEGogACgCDCAEEDsgAEGIAmogAEGAAmoQNARAIAQgBCgCAEECcjYCAAsgACgCiAIhAiABEBwaIABB0AFqEBwaIABBkAJqJAAgAgvcAQIDfwF+IwBBEGsiBCQAAn8CQAJAAkAgACABRwRAAkACQCAALQAAIgVBLUcNACAAQQFqIgAgAUcNAAwBC0GomwIoAgAhBkGomwJBADYCACAAIARBDGogAxAmEMEBIQcCQEGomwIoAgAiAARAIAQoAgwgAUcNASAAQcQARg0FDAQLQaibAiAGNgIAIAQoAgwgAUYNAwsLCyACQQQ2AgBBAAwDCyAHQv//A1gNAQsgAkEENgIAQf//AwwBC0EAIAenIgBrIAAgBUEtRhsLIQAgBEEQaiQAIABB//8DcQu2AQIBfgJ/IwBBEGsiBSQAAkACQCAAIAFHBEBBqJsCKAIAIQZBqJsCQQA2AgAgACAFQQxqIAMQJhDLAiEEAkBBqJsCKAIAIgAEQCAFKAIMIAFHDQEgAEHEAEYNAwwEC0GomwIgBjYCACAFKAIMIAFGDQMLCyACQQQ2AgBCACEEDAELIAJBBDYCACAEQgBVBEBC////////////ACEEDAELQoCAgICAgICAgH8hBAsgBUEQaiQAIAQLxAECAn8BfiMAQRBrIgQkAAJ/AkACQCAAIAFHBEBBqJsCKAIAIQVBqJsCQQA2AgAgACAEQQxqIAMQJhDLAiEGAkBBqJsCKAIAIgAEQCAEKAIMIAFHDQEgAEHEAEYNBAwDC0GomwIgBTYCACAEKAIMIAFGDQILCyACQQQ2AgBBAAwCCyAGQoCAgIB4Uw0AIAZC/////wdVDQAgBqcMAQsgAkEENgIAQf////8HIAZCAFUNABpBgICAgHgLIQAgBEEQaiQAIAALwQEBBH8jAEEQayIFJAAgAiABa0ECdSIEQe////8DTQRAAkAgBEEBTQRAIAAgBDoACyAAIQMMAQsgACAAIARBAk8EfyAEQQRqQXxxIgMgA0EBayIDIANBAkYbBUEBC0EBaiIGEHYiAzYCACAAIAZBgICAgHhyNgIIIAAgBDYCBAsDQCABIAJHBEAgAyABKAIANgIAIANBBGohAyABQQRqIQEMAQsLIAVBADYCDCADIAUoAgw2AgAgBUEQaiQADwsQRQALuAEBBH8jAEEQayIFJAAgAiABayIEQW9NBEACQCAEQQpNBEAgACAEOgALIAAhAwwBCyAAIARBC08EfyAEQRBqQXBxIgMgA0EBayIDIANBC0YbBUEKC0EBaiIGEB0iAzYCACAAIAZBgICAgHhyNgIIIAAgBDYCBAsDQCABIAJHBEAgAyABLQAAOgAAIANBAWohAyABQQFqIQEMAQsLIAVBADoADyADIAUtAA86AAAgBUEQaiQADwsQRQALHQEBfyMAQRBrIgMkACAAIAEgAhDJAiADQRBqJAALFgAgACABIAJCgICAgICAgICAfxDMAgumBAIHfwR+IwBBEGsiCCQAAkACQAJAIAJBJEwEQCAALQAAIgUNASAAIQQMAgtBqJsCQRw2AgBCACEDDAILIAAhBAJAA0AgBUEYdEEYdSIFQSBGIAVBCWtBBUlyRQ0BIAQtAAEhBSAEQQFqIgYhBCAFDQALIAYhBAwBCwJAIAQtAAAiBUEraw4DAAEAAQtBf0EAIAVBLUYbIQcgBEEBaiEECwJ/AkAgAkFvcQ0AIAQtAABBMEcNAEEBIQkgBC0AAUHfAXFB2ABGBEAgBEECaiEEQRAMAgsgBEEBaiEEIAJBCCACGwwBCyACQQogAhsLIgqsIQxBACECA0ACQEFQIQUCQCAELAAAIgZBMGtB/wFxQQpJDQBBqX8hBSAGQeEAa0H/AXFBGkkNAEFJIQUgBkHBAGtB/wFxQRlLDQELIAUgBmoiBiAKTg0AIAggDEIAIAtCABA4QQEhBQJAIAgpAwhCAFINACALIAx+Ig0gBqwiDkJ/hVYNACANIA58IQtBASEJIAIhBQsgBEEBaiEEIAUhAgwBCwsgAQRAIAEgBCAAIAkbNgIACwJAAkAgAgRAQaibAkHEADYCACAHQQAgA0IBgyIMUBshByADIQsMAQsgAyALVg0BIANCAYMhDAsCQCAMpw0AIAcNAEGomwJBxAA2AgAgA0IBfSEDDAILIAMgC1oNAEGomwJBxAA2AgAMAQsgCyAHrCIDhSADfSEDCyAIQRBqJAAgAwu4CAEFfyABKAIAIQQCQAJAAkACQAJAAkACQAJ/AkACQAJAAkAgA0UNACADKAIAIgZFDQAgAEUEQCACIQMMAwsgA0EANgIAIAIhAwwBCwJAQdScAigCACgCAEUEQCAARQ0BIAJFDQwgAiEGA0AgBCwAACIDBEAgACADQf+/A3E2AgAgAEEEaiEAIARBAWohBCAGQQFrIgYNAQwOCwsgAEEANgIAIAFBADYCACACIAZrDwsgAiEDIABFDQMMBQsgBBB0DwtBASEFDAMLQQAMAQtBAQshBQNAIAVFBEAgBC0AAEEDdiIFQRBrIAZBGnUgBWpyQQdLDQMCfyAEQQFqIgUgBkGAgIAQcUUNABogBS0AAEHAAXFBgAFHBEAgBEEBayEEDAcLIARBAmoiBSAGQYCAIHFFDQAaIAUtAABBwAFxQYABRwRAIARBAWshBAwHCyAEQQNqCyEEIANBAWshA0EBIQUMAQsDQCAELQAAIQYCQCAEQQNxDQAgBkEBa0H+AEsNACAEKAIAIgZBgYKECGsgBnJBgIGChHhxDQADQCADQQRrIQMgBCgCBCEGIARBBGoiBSEEIAYgBkGBgoQIa3JBgIGChHhxRQ0ACyAFIQQLIAZB/wFxIgVBAWtB/gBNBEAgA0EBayEDIARBAWohBAwBCwsgBUHCAWsiBUEySw0DIARBAWohBCAFQQJ0QYCyAWooAgAhBkEAIQUMAAsACwNAIAVFBEAgA0UNBwNAAkACQAJAIAQtAAAiBUEBayIHQf4ASwRAIAUhBgwBCyAEQQNxDQEgA0EFSQ0BAkADQCAEKAIAIgZBgYKECGsgBnJBgIGChHhxDQEgACAGQf8BcTYCACAAIAQtAAE2AgQgACAELQACNgIIIAAgBC0AAzYCDCAAQRBqIQAgBEEEaiEEIANBBGsiA0EESw0ACyAELQAAIQYLIAZB/wFxIgVBAWshBwsgB0H+AEsNAQsgACAFNgIAIABBBGohACAEQQFqIQQgA0EBayIDDQEMCQsLIAVBwgFrIgVBMksNAyAEQQFqIQQgBUECdEGAsgFqKAIAIQZBASEFDAELIAQtAAAiBUEDdiIHQRBrIAcgBkEadWpyQQdLDQECQAJAAn8gBEEBaiIHIAVBgAFrIAZBBnRyIgVBAE4NABogBy0AAEGAAWsiB0E/Sw0BIARBAmoiCCAHIAVBBnRyIgVBAE4NABogCC0AAEGAAWsiB0E/Sw0BIAcgBUEGdHIhBSAEQQNqCyEEIAAgBTYCACADQQFrIQMgAEEEaiEADAELQaibAkEZNgIAIARBAWshBAwFC0EAIQUMAAsACyAEQQFrIQQgBg0BIAQtAAAhBgsgBkH/AXENACAABEAgAEEANgIAIAFBADYCAAsgAiADaw8LQaibAkEZNgIAIABFDQELIAEgBDYCAAtBfw8LIAEgBDYCACACCyMBAn8gACEBA0AgASICQQRqIQEgAigCAA0ACyACIABrQQJ1Cx4AIABBAEcgAEGAtAFHcSAAQZi0AUdxBEAgABAbCwspAQF/IwBBEGsiAiQAIAIgATYCDCAAQagRIAEQ0QIhACACQRBqJAAgAAvdHQIPfwV+IwBBkAFrIgckACAHQQBBkAEQISIDQX82AkwgAyAANgIsIANBMzYCICADIAA2AlQgAiEOIwBBsAJrIgUkACADKAJMGgJAIAEiAi0AACIBRQ0AAkACQAJAAkADQAJAAkAgAUH/AXEiAEEgRiAAQQlrQQVJcgRAA0AgAiIBQQFqIQIgAS0AASIAQSBGIABBCWtBBUlyDQALIANCABBRA0ACfyADKAIEIgAgAygCaEkEQCADIABBAWo2AgQgAC0AAAwBCyADECQLIgBBIEYgAEEJa0EFSXINAAsgAygCBCECIAMoAmgEQCADIAJBAWsiAjYCBAsgAiADKAIIa6wgAykDeCAUfHwhFAwBCwJ/AkACQCACLQAAIgFBJUYEQCACLQABIgBBKkYNASAAQSVHDQILIANCABBRIAIgAUElRmohAQJ/IAMoAgQiACADKAJoSQRAIAMgAEEBajYCBCAALQAADAELIAMQJAsiACABLQAARwRAIAMoAmgEQCADIAMoAgRBAWs2AgQLIABBAE4NC0EAIQwgDQ0LDAkLIBRCAXwhFAwDC0EAIQcgAkECagwBCwJAIABBMGtBCk8NACACLQACQSRHDQAgAi0AAUEwayEAIwBBEGsiASAONgIMIAEgDiAAQQJ0QQRrQQAgAEEBSxtqIgBBBGo2AgggACgCACEHIAJBA2oMAQsgDigCACEHIA5BBGohDiACQQFqCyEBQQAhDEEAIQIgAS0AAEEwa0EKSQRAA0AgAS0AACACQQpsakEwayECIAEtAAEhACABQQFqIQEgAEEwa0EKSQ0ACwsgAS0AACIEQe0ARwR/IAEFQQAhCSAHQQBHIQwgAS0AASEEQQAhCiABQQFqCyIAQQFqIQFBAyEGAkACQAJAAkACQAJAIARBwQBrDjoECgQKBAQECgoKCgMKCgoKCgoECgoKCgQKCgQKCgoKCgQKBAQEBAQABAUKAQoEBAQKCgQCBAoKBAoCCgsgAEECaiABIAAtAAFB6ABGIgAbIQFBfkF/IAAbIQYMBAsgAEECaiABIAAtAAFB7ABGIgAbIQFBA0EBIAAbIQYMAwtBASEGDAILQQIhBgwBC0EAIQYgACEBC0EBIAYgAS0AACIAQS9xQQNGIgQbIQ8CQCAAQSByIAAgBBsiC0HbAEYNAAJAIAtB7gBHBEAgC0HjAEcNASACQQEgAkEBShshAgwCCyAHIA8gFBDSAgwCCyADQgAQUQNAAn8gAygCBCIAIAMoAmhJBEAgAyAAQQFqNgIEIAAtAAAMAQsgAxAkCyIAQSBGIABBCWtBBUlyDQALIAMoAgQhACADKAJoBEAgAyAAQQFrIgA2AgQLIAAgAygCCGusIAMpA3ggFHx8IRQLIAMgAqwiEhBRAkAgAygCBCIEIAMoAmgiAEkEQCADIARBAWo2AgQMAQsgAxAkQQBIDQUgAygCaCEACyAABEAgAyADKAIEQQFrNgIEC0EQIQACQAJAAkACQAJAAkACQAJAAkACQAJAAkAgC0HYAGsOIQYLCwILCwsLCwELAgQBAQELBQsLCwsLAwYLCwILBAsLBgALIAtBwQBrIgBBBksNCkEBIAB0QfEAcUUNCgsgBUEIaiADIA9BABDYAiADKQN4QgAgAygCBCADKAIIa6x9UQ0QIAdFDQkgBSkDECESIAUpAwghEyAPDgMFBgcJCyALQe8BcUHjAEYEQCAFQSBqQX9BgQIQIRogBUEAOgAgIAtB8wBHDQggBUEAOgBBIAVBADoALiAFQQA2ASoMCAsgBUEgaiABLQABIgBB3gBGIgRBgQIQIRogBUEAOgAgIAFBAmogAUEBaiAEGyEIAn8CQAJAIAFBAkEBIAQbai0AACIBQS1HBEAgAUHdAEYNASAAQd4ARyEGIAgMAwsgBSAAQd4ARyIGOgBODAELIAUgAEHeAEciBjoAfgsgCEEBagshAQNAAkAgAS0AACIAQS1HBEAgAEUNECAAQd0ARw0BDAoLQS0hACABLQABIgRFDQAgBEHdAEYNACABQQFqIQgCQCAEIAFBAWstAAAiAU0EQCAEIQAMAQsDQCABQQFqIgEgBUEgamogBjoAACABIAgtAAAiAEkNAAsLIAghAQsgACAFaiAGOgAhIAFBAWohAQwACwALQQghAAwCC0EKIQAMAQtBACEAC0IAIRJBACEEQQAhCEEAIQYjAEEQayIRJAACfgJAAkACQAJAAkAgAEEkTQRAA0ACfyADKAIEIgIgAygCaEkEQCADIAJBAWo2AgQgAi0AAAwBCyADECQLIgIiEEEgRiAQQQlrQQVJcg0ACwJAAkAgAkEraw4DAAEAAQtBf0EAIAJBLUYbIQYgAygCBCICIAMoAmhJBEAgAyACQQFqNgIEIAItAAAhAgwBCyADECQhAgsCQAJAIABBb3ENACACQTBHDQACfyADKAIEIgIgAygCaEkEQCADIAJBAWo2AgQgAi0AAAwBCyADECQLIgJBX3FB2ABGBEBBECEAAn8gAygCBCICIAMoAmhJBEAgAyACQQFqNgIEIAItAAAMAQsgAxAkCyICQfGvAWotAABBEEkNBSADKAJoRQ0IIAMgAygCBEEBazYCBAwICyAADQFBCCEADAQLIABBCiAAGyIAIAJB8a8Bai0AAEsNACADKAJoBEAgAyADKAIEQQFrNgIECyADQgAQUUGomwJBHDYCAEIADAcLIABBCkcNAiACQTBrIgRBCU0EQEEAIQADQCAAQQpsIARqIgBBmbPmzAFJAn8gAygCBCICIAMoAmhJBEAgAyACQQFqNgIEIAItAAAMAQsgAxAkCyICQTBrIgRBCU1xDQALIACtIRILIARBCUsNASASQgp+IRMgBK0hFQNAIBMgFXwhEgJ/IAMoAgQiACADKAJoSQRAIAMgAEEBajYCBCAALQAADAELIAMQJAsiAkEwayIEQQlLDQIgEkKas+bMmbPmzBlaDQIgEkIKfiITIAStIhVCf4VYDQALQQohAAwDC0GomwJBHDYCAEIADAULQQohACAEQQlNDQEMAgsgACAAQQFrcQRAIAJB8a8Bai0AACIIIABJBEADQCAAIARsIAhqIgRBx+PxOEkCfyADKAIEIgIgAygCaEkEQCADIAJBAWo2AgQgAi0AAAwBCyADECQLIgJB8a8Bai0AACIIIABJcQ0ACyAErSESCyAAIAhNDQEgAK0hEwNAIBIgE34iFSAIrUL/AYMiFkJ/hVYNAiAVIBZ8IRIgAAJ/IAMoAgQiAiADKAJoSQRAIAMgAkEBajYCBCACLQAADAELIAMQJAsiAkHxrwFqLQAAIghNDQIgESATQgAgEkIAEDggESkDCFANAAsMAQsgAEEXbEEFdkEHcUHxsQFqLAAAIRAgAkHxrwFqLQAAIgQgAEkEQANAIAggEHQgBHIiCEGAgIDAAEkCfyADKAIEIgIgAygCaEkEQCADIAJBAWo2AgQgAi0AAAwBCyADECQLIgJB8a8Bai0AACIEIABJcQ0ACyAIrSESCyAAIARNDQBCfyAQrSITiCIVIBJUDQADQCAErUL/AYMgEiAThoQhEiAAAn8gAygCBCICIAMoAmhJBEAgAyACQQFqNgIEIAItAAAMAQsgAxAkCyICQfGvAWotAAAiBE0NASASIBVYDQALCyAAIAJB8a8Bai0AAE0NAANAIAACfyADKAIEIgIgAygCaEkEQCADIAJBAWo2AgQgAi0AAAwBCyADECQLQfGvAWotAABLDQALQaibAkHEADYCAEEAIQZCfyESCyADKAJoBEAgAyADKAIEQQFrNgIECwJAIBJCf1INAAsgEiAGrCIThSATfQwBCyADQgAQUUIACyESIBFBEGokACADKQN4QgAgAygCBCADKAIIa6x9UQ0LAkAgC0HwAEcNACAHRQ0AIAcgEj4CAAwFCyAHIA8gEhDSAgwECyAHIBMgEhDTAjgCAAwDCyAHIBMgEhDKATkDAAwCCyAHIBM3AwAgByASNwMIDAELIAJBAWpBHyALQeMARiIEGyEGAkAgD0EBRyIIRQRAIAchACAMBEAgBkECdBAoIgBFDQcLIAVCADcDqAJBACECA0AgACEKAkADQAJ/IAMoAgQiACADKAJoSQRAIAMgAEEBajYCBCAALQAADAELIAMQJAsiACAFai0AIUUNASAFIAA6ABsgBUEcaiAFQRtqQQEgBUGoAmoQnwEiAEF+Rg0AIABBf0YNByAKBEAgCiACQQJ0aiAFKAIcNgIAIAJBAWohAgsgDCACIAZGcUUNAAsgCiAGQQF0QQFyIgZBAnQQjAEiAA0BDAYLCyAFQagCagR/IAUoAqgCBUEACw0EQQAhCQwBCyAMBEBBACECIAYQKCIARQ0GA0AgACEJA0ACfyADKAIEIgAgAygCaEkEQCADIABBAWo2AgQgAC0AAAwBCyADECQLIgAgBWotACFFBEBBACEKDAQLIAIgCWogADoAACACQQFqIgIgBkcNAAtBACEKIAkgBkEBdEEBciIGEIwBIgANAAsMBwtBACECIAcEQANAAn8gAygCBCIAIAMoAmhJBEAgAyAAQQFqNgIEIAAtAAAMAQsgAxAkCyIAIAVqLQAhBEAgAiAHaiAAOgAAIAJBAWohAgwBBUEAIQogByEJDAMLAAsACwNAAn8gAygCBCIAIAMoAmhJBEAgAyAAQQFqNgIEIAAtAAAMAQsgAxAkCyAFai0AIQ0AC0EAIQlBACEKCyADKAIEIQAgAygCaARAIAMgAEEBayIANgIECyADKQN4IAAgAygCCGusfCITUA0HIAtB4wBGIBIgE1JxDQcCQCAMRQ0AIAhFBEAgByAKNgIADAELIAcgCTYCAAsgBA0AIAoEQCAKIAJBAnRqQQA2AgALIAlFBEBBACEJDAELIAIgCWpBADoAAAsgAygCBCADKAIIa6wgAykDeCAUfHwhFCANIAdBAEdqIQ0LIAFBAWohAiABLQABIgENAQwGCwtBACEJDAELQQAhCUEAIQoLIA0NAQtBfyENCyAMRQ0AIAkQGyAKEBsLIAVBsAJqJAAgDSEAIANBkAFqJAAgAAtDAAJAIABFDQACQAJAAkACQCABQQJqDgYAAQICBAMECyAAIAI8AAAPCyAAIAI9AQAPCyAAIAI+AgAPCyAAIAI3AwALC7QDAgN/AX4jAEEgayIDJAACQCABQv///////////wCDIgVCgICAgICAwMA/fSAFQoCAgICAgMC/wAB9VARAIAFCGYinIQQgAFAgAUL///8PgyIFQoCAgAhUIAVCgICACFEbRQRAIARBgYCAgARqIQIMAgsgBEGAgICABGohAiAAIAVCgICACIWEQgBSDQEgAiAEQQFxaiECDAELIABQIAVCgICAgICAwP//AFQgBUKAgICAgIDA//8AURtFBEAgAUIZiKdB////AXFBgICA/gdyIQIMAQtBgICA/AchAiAFQv///////7+/wABWDQBBACECIAVCMIinIgRBkf4ASQ0AIANBEGogACABQv///////z+DQoCAgICAgMAAhCIFIARBgf4AaxA8IAMgACAFQYH/ACAEaxBuIAMpAwgiAEIZiKchAiADKQMAIAMpAxAgAykDGIRCAFKthCIFUCAAQv///w+DIgBCgICACFQgAEKAgIAIURtFBEAgAkEBaiECDAELIAUgAEKAgIAIhYRCAFINACACQQFxIAJqIQILIANBIGokACACIAFCIIinQYCAgIB4cXK+C6oPAgV/Dn4jAEHQAmsiBSQAIARC////////P4MhCyACQv///////z+DIQogAiAEhUKAgICAgICAgIB/gyENIARCMIinQf//AXEhCAJAAkAgAkIwiKdB//8BcSIJQQFrQf3/AU0EQCAIQQFrQf7/AUkNAQsgAVAgAkL///////////8AgyIMQoCAgICAgMD//wBUIAxCgICAgICAwP//AFEbRQRAIAJCgICAgICAIIQhDQwCCyADUCAEQv///////////wCDIgJCgICAgICAwP//AFQgAkKAgICAgIDA//8AURtFBEAgBEKAgICAgIAghCENIAMhAQwCCyABIAxCgICAgICAwP//AIWEUARAIAMgAkKAgICAgIDA//8AhYRQBEBCACEBQoCAgICAgOD//wAhDQwDCyANQoCAgICAgMD//wCEIQ1CACEBDAILIAMgAkKAgICAgIDA//8AhYRQBEBCACEBDAILIAEgDIRQBEBCgICAgICA4P//ACANIAIgA4RQGyENQgAhAQwCCyACIAOEUARAIA1CgICAgICAwP//AIQhDUIAIQEMAgsgDEL///////8/WARAIAVBwAJqIAEgCiABIAogClAiBht5IAZBBnStfKciBkEPaxA8QRAgBmshBiAFKQPIAiEKIAUpA8ACIQELIAJC////////P1YNACAFQbACaiADIAsgAyALIAtQIgcbeSAHQQZ0rXynIgdBD2sQPCAGIAdqQRBrIQYgBSkDuAIhCyAFKQOwAiEDCyAFQaACaiALQoCAgICAgMAAhCISQg+GIANCMYiEIgJCAEKAgICAsOa8gvUAIAJ9IgRCABA4IAVBkAJqQgAgBSkDqAJ9QgAgBEIAEDggBUGAAmogBSkDmAJCAYYgBSkDkAJCP4iEIgRCACACQgAQOCAFQfABaiAEQgBCACAFKQOIAn1CABA4IAVB4AFqIAUpA/gBQgGGIAUpA/ABQj+IhCIEQgAgAkIAEDggBUHQAWogBEIAQgAgBSkD6AF9QgAQOCAFQcABaiAFKQPYAUIBhiAFKQPQAUI/iIQiBEIAIAJCABA4IAVBsAFqIARCAEIAIAUpA8gBfUIAEDggBUGgAWogAkIAIAUpA7gBQgGGIAUpA7ABQj+IhEIBfSICQgAQOCAFQZABaiADQg+GQgAgAkIAEDggBUHw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QS5HIAtB4QBrQQZPcQ0EIAVBLkcNACAEDQJBASEEIBQhFwwBCyALQdcAayAIIAVBOUobIQUCQCAUQgdXBEAgBSAJQQR0aiEJDAELIBRCHFgEQCACQTBqIAUQSCACQSBqIBkgFUIAQoCAgICAgMD9PxAnIAJBEGogAikDICIZIAIpAygiFSACKQMwIAIpAzgQJyACIBYgGCACKQMQIAIpAxgQRCACKQMIIRggAikDACEWDAELIAVFDQAgBw0AIAJB0ABqIBkgFUIAQoCAgICAgID/PxAnIAJBQGsgFiAYIAIpA1AgAikDWBBEIAIpA0ghGEEBIQcgAikDQCEWCyAUQgF8IRRBASEKCyABKAIEIgUgASgCaEkEfyABIAVBAWo2AgQgBS0AAAUgARAkCyEFDAELC0EuIQULAn4CQAJAIApFBEAgASgCaEUEQCADDQMMAgsgASABKAIEIgVBAWs2AgQgA0UNASABIAVBAms2AgQgBEUNAiABIAVBA2s2AgQMAgsgFEIHVwRAIBQhFQNAIAlBBHQhCSAVQgF8IhVCCFINAAsLAkACQAJAIAVBX3FB0ABGBEAgASADENcCIhVCgICAgICAgICAf1INAyADBEAgASgCaA0CDAMLQgAhFiABQgAQUUIADAYLIAEoAmhFDQELIAEgASgCBEEBazYCBAtCACEVCyAJRQRAIAJB8ABqIAa3RAAAAAAAAAAAohBYIAIpA3AhFiACKQN4DAMLIBcgFCAEG0IChiAVfEIgfSIUQQAgD2utVQRAQaibAkHEADYCACACQaABaiAGEEggAkGQAWogAikDoAEgAikDqAFCf0L///////+///8AECcgAkGAAWogAikDkAEgAikDmAFCf0L///////+///8AECcgAikDgAEhFiACKQOIAQwDCyAPQeIBa6wgFFcEQCAJQQBOBEADQCACQaADaiAWIBhCAEKAgICAgIDA/79/EEQgFiAYQoCAgICAgID/PxDJASEBIAJBkANqIBYgGCAWIAIpA6ADIAFBAEgiAxsgGCACKQOoAyADGxBEIBRCAX0hFCACKQOYAyEYIAIpA5ADIRYgCUEBdCABQQBOciIJQQBODQALCwJ+IBQgD6x9QiB8IhWnIgFBACABQQBKGyAOIBUgDq1TGyIBQfEATgRAIAJBgANqIAYQSCACKQOIAyEXIAIpA4AD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HCIHNgIAIAcgBygCBEEBajYCBCAIEEMiESIHIAAoAtwCIgggCCAJaiAMIAcoAgAoAiARBwAaIAICfyAJQQBKBEAgACgC3AItAABBLUYhDwsgDwsgAEHYAWogAEHQAWogAEHPAWogAEHOAWogAEHAAWoQICIQIABBsAFqECAiByAAQaABahAgIgggAEGcAWoQlAIgAEE3NgIwIABBKGpBACAAQTBqIgIQLCELAn8gACgCnAEiDiAJSARAIAAoApwBAn8gBy0AC0EHdgRAIAcoAgQMAQsgBy0ACwsCfyAILQALQQd2BEAgCCgCBAwBCyAILQALCyAJIA5rQQF0ampqQQFqDAELIAAoApwBAn8gCC0AC0EHdgRAIAgoAgQMAQsgCC0ACwsCfyAHLQALQQd2BEAgBygCBAwBCyAHLQALC2pqQQJqCyIOQeUATwRAIA4QKCEOIAsoAgAhAiALIA42AgAgAgRAIAIgCygCBBEBAAsgCygCACICRQ0BCyACIABBJGogAEEgaiADKAIEIAwgCSAMaiARIA8gAEHQAWogACwAzwEgACwAzgEgECAHIAggACgCnAEQkwIgASACIAAoAiQgACgCICADIAQQXiECIAsoAgAhASALQQA2AgAgAQRAIAEgCygCBBEBAAsgCBAcGiAHEBwaIBAQHBogACgC2AEiASABKAIEQQFrIgM2AgQgA0F/RgRAIAEgASgCACgCCBEBAAsgCigCACEBIApBADYCACABBEAgASAKKAIEEQEACyANKAIAIQEgDUEANgIAIAEEQCABIA0oAgQRAQALIABB0ANqJAAgAg8LEDcAC7oIAQR/IwBBwANrIgAkACAAIAI2ArADIAAgATYCuAMgAEE4NgIUIABBGGogAEEgaiAAQRRqIggQLCEJIABBEGoiByAEKAIcIgE2AgAgASABKAIEQQFqNgIEIAcQQCEBIABBADoADyAAQbgDaiACIAMgByAEKAIEIAUgAEEPaiABIAkgCCAAQbADahCZAgRAIwBBEGsiAiQAAkAgBi0AC0EHdgRAIAYoAgAhAyACQQA2AgwgAyACKAIMNgIAIAZBADYCBAwBCyACQQA2AgggBiACKAIINgIAIAZBADoACwsgAkEQaiQAIAAtAA8EQCAGIAFBLSABKAIAKAIsEQIAEKoBCyABQTAgASgCACgCLBECACEBIAko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BkEQaiQAIAIQHBoMAQsgBiACIANrSwRAIAEgAiADIAZqIAJrIAMgAxCsAQsCfyABLQALQQd2BEAgASgCAAwBCyABCyADaiECA0AgBCAHRwRAIAIgBC0AADoAACAEQQFqIQQgAkEBaiECDAELCyAIQQA6AA8gAiAILQAPOgAAIAMgBmohAgJAIAEtAAtBB3YEQCABIAI2AgQMAQsgASACOgALCwsgCEEgaiQACyAAQZgBaiAAQZABahA0BEAgBSAFKAIAQQJyNgIACyAAKAKYASECIAAoAhAiASABKAIEQQFrIgM2AgQgA0F/RgRAIAEgASgCACgCCBEBAAsgCigCACEBIApBADYCACABBEAgASAKKAIEEQEACyAAQaABaiQAIAILQQEBfyAAQbAfNgIAIAAoAhQiAQRAIAEQGwsgAEEANgIUIAAoAhAiAQRAIAEQGwsgAEEANgIQIAAoAgwQGyAAEBsL2QQBAn8jAEGgAmsiACQAIAAgAjYCkAIgACABNgKYAiAAQTg2AhAgAEGYAWogAEGgAWogAEEQahAsIQcgAEGQAWoiCCAEKAIcIgE2AgAgASABKAIEQQFqNgIEIAgQQyEBIABBADoAjwECQCAAQZgCaiACIAMgCCAEKAIEIAUgAEGPAWogASAHIABBlAFqIABBhAJqEKACRQ0AIABBzRkoAAA2AIcBIABBxhkpAAA3A4ABIAEgAEGAAWogAEGKAWogAEH2AGogASgCACgCIBEHABogAEE3NgIQIABBCGpBACAAQRBqIgIQLCEBAkAgACgClAEgBygCAGtB4wBOBEAgACgClAEgBygCAGtBAmoQKCEDIAEoAgAhAiABIAM2AgAgAgRAIAIgASgCBBEBAAsgASgCAEUNASABKAIAIQILIAAtAI8BBEAgAkEtOgAAIAJBAWohAgsgBygCACEEA0AgACgClAEgBE0EQAJAIAJBADoAACAAIAY2AgAgAEEQaiAAENACQQFHDQAgASgCACECIAFBADYCACACBEAgAiABKAIEEQEACwwECwUgAiAAQfYAaiIDIANBCmogBBC+ASAAayAAai0ACjoAACACQQFqIQIgBEEBaiEEDAELCxA3AAsQNwALIABBmAJqIABBkAJqEDQEQCAFIAUoAgBBAnI2AgALIAAoApgCIQIgACgCkAEiASABKAIEQQFrIgM2AgQg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AgQgAkF/RgRAIAEgASgCACgCCBEBAAsgAEHAAWoQICICIAItAAtBB3YEfyACKAIIQf////8HcUEBawVBCgsQHyAAAn8gAi0AC0EHdgRAIAIoAgAMAQsgAgsiATYCvAEgACAGNgIMIABBADYCCANAAkAgAEGIAmogAEGAAmoQPkUNACAAKAK8AQJ/IAItAAtBB3YEQCACKAIEDAELIAItAAsLIAFqRgRAAn8gAiIBLQALQQd2BEAgASgCBAwBCyABLQALCyEDIAECfyABLQALQQd2BEAgASgCBAwBCyABLQALC0EBdBAfIAEgAS0AC0EHdgR/IAEoAghB/////wdxQQFrBUEKCxAfIAAgAwJ/IAEtAAtBB3YEQCACKAIADAELIAILIgFqNgK8AQsgAEGIAmoQLUEQIAEgAEG8AWogAEEIakEAIAcgAEEQaiAAQQxqIABB4AFqEHsNACAAQYgCahAxGgwBCwsgAiAAKAK8ASABaxAfAn8gAi0AC0EHdgRAIAIoAgAMAQsgAgshARAmIQMgACAFNgIAIAEgAyAAEL0CQQFHBEAgBEEENgIACyAAQYgCaiAAQYACahA0BEAgBCAEKAIAQQJyNgIACyAAKAKIAiEBIAIQHBogBxAcGiAAQZACaiQAIAEL/wQBAX4jAEGgAmsiACQAIAAgAjYCkAIgACABNgKYAiAAQeABaiADIABB8AFqIABB7wFqIABB7gFqEMABIABB0AFqECAiASABLQALQQd2BH8gASgCCEH/////B3FBAWsFQQoLEB8gAAJ/IAEtAAtBB3YEQCABKAIADAELIAELIgI2AswBIAAgAEEgajYCHCAAQQA2AhggAEEBOgAXIABBxQA6ABYDQAJAIABBmAJqIABBkAJqED5FDQAgACgCzAECfyABLQALQQd2BEAgASgCBAwBCyABLQALCyACakYEQAJ/IAEiAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwshAyACAn8gAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwtBAXQQHyACIAItAAtBB3YEfyACKAIIQf////8HcUEBawVBCgsQHyAAIAMCfyACLQALQQd2BEAgASgCAAwBCyABCyICajYCzAELIABBmAJqEC0gAEEXaiAAQRZqIAIgAEHMAWogACwA7wEgACwA7gEgAEHgAWogAEEgaiAAQRxqIABBGGogAEHwAWoQvwENACAAQZgCahAxGgwBCwsCQAJ/IAAtAOsBQQd2BEAgACgC5AEMAQsgAC0A6wELRQ0AIAAtABdFDQAgACgCHCIDIABBIGprQZ8BSg0AIAAgA0EEajYCHCADIAAoAhg2AgALIAAgAiAAKALMASAEEL4CIAApAwAhBiAFIAApAwg3AwggBSAGNwMAIABB4AFqIABBIGogACgCHCAEEDsgAEGYAmogAEGQAmoQNARAIAQgBCgCAEECcjYCAAsgACgCmAIhAiABEBwaIABB4AFqEBwaIABBoAJqJAAgAgvFAQEGfyMAQUBqIgIkACABKAIAIAEgAS0ACyIDQRh0QRh1QQBIIgQbIQVByJMBKAIAIQYgASgCBCADIAQbQQJ2IgNBgP0AIANBgP0ASRsiBARAA0AgAiAFIAdBAnRqKgIAuzkDACMAQRBrIgEkACABIAI2AgwgBkHgGiACQQAQzwEaIAFBEGokACAHQQFqIgcgBEcNAAsLIAYQ0AEaIAJBEGoQwAIiARC4AiABIAUgAxCzAiAAIAEQsQIgARC6AiACQUBrJAAL6AQAIwBBkAJrIgAkACAAIAI2AoACIAAgATYCiAIgAEHQAWogAyAAQeABaiAAQd8BaiAAQd4BahDAASAAQcABahAgIgEgAS0AC0EHdgR/IAEoAghB/////wdxQQFrBUEKCxAfIAACfyABLQALQQd2BEAgASgCAAwBCyABCyICNgK8ASAAIABBEGo2AgwgAEEANgIIIABBAToAByAAQcUAOgAGA0ACQCAAQYgCaiAAQYACahA+RQ0AIAAoArwBAn8gAS0AC0EHdgRAIAEoAgQMAQsgAS0ACwsgAmpGBEACfyABIgItAAtBB3YEQCACKAIEDAELIAItAAsLIQMgAgJ/IAItAAtBB3YEQCACKAIEDAELIAItAAsLQQF0EB8gAiACLQALQQd2BH8gAigCCEH/////B3FBAWsFQQoLEB8gACADAn8gAi0AC0EHdgRAIAEoAgAMAQsgAQsiAmo2ArwBCyAAQYgCahAtIABBB2ogAEEGaiACIABBvAFqIAAsAN8BIAAsAN4BIABB0AFqIABBEGogAEEMaiAAQQhqIABB4AFqEL8BDQAgAEGIAmoQMRoMAQsLAkACfyAALQDbAUEHdgRAIAAoAtQBDAELIAAtANsBC0UNACAALQAHRQ0AIAAoAgwiAyAAQRBqa0GfAUoNACAAIANBBGo2AgwgAyAAKAIINgIACyAFIAIgACgCvAEgBBC/AjkDACAAQdABaiAAQRBqIAAoAgwgBBA7IABBiAJqIABBgAJqEDQEQCAEIAQoAgBBAnI2AgALIAAoAogCIQIgARAcGiAAQdABahAcGiAAQZACaiQAIAIL6AQAIwBBkAJrIgAkACAAIAI2AoACIAAgATYCiAIgAEHQAWogAyAAQeABaiAAQd8BaiAAQd4BahDAASAAQcABahAgIgEgAS0AC0EHdgR/IAEoAghB/////wdxQQFrBUEKCxAfIAACfyABLQALQQd2BEAgASgCAAwBCyABCyICNgK8ASAAIABBEGo2AgwgAEEANgIIIABBAToAByAAQcUAOgAGA0ACQCAAQYgCaiAAQYACahA+RQ0AIAAoArwBAn8gAS0AC0EHdgRAIAEoAgQMAQsgAS0ACwsgAmpGBEACfyABIgItAAtBB3YEQCACKAIEDAELIAItAAsLIQMgAgJ/IAItAAtBB3YEQCACKAIEDAELIAItAAsLQQF0EB8gAiACLQALQQd2BH8gAigCCEH/////B3FBAWsFQQoLEB8gACADAn8gAi0AC0EHdgRAIAEoAgAMAQsgAQsiAmo2ArwBCyAAQYgCahAtIABBB2ogAEEGaiACIABBvAFqIAAsAN8BIAAsAN4BIABB0AFqIABBEGogAEEMaiAAQQhqIABB4AFqEL8BDQAgAEGIAmoQMRoMAQsLAkACfyAALQDbAUEHdgRAIAAoAtQBDAELIAAtANsBC0UNACAALQAHRQ0AIAAoAgwiAyAAQRBqa0GfAUoNACAAIANBBGo2AgwgAyAAKAIINgIACyAFIAIgACgCvAEgBBDBAjgCACAAQdABaiAAQRBqIAAoAgwgBBA7IABBiAJqIABBgAJqEDQEQCAEIAQoAgBBAnI2AgALIAAoAogCIQIgARAcGiAAQdABahAcGiAAQZACaiQAIAILvgQBAX8jAEGQAmsiACQAIAAgAjYCgAIgACABNgKIAiADEFchBiAAQdABaiADIABB/wFqEIYBIABBwAFqECAiASABLQALQQd2BH8gASgCCEH/////B3FBAWsFQQoLEB8gAAJ/IAEtAAtBB3YEQCABKAIADAELIAELIgI2ArwBIAAgAEEQajYCDCAAQQA2AggDQAJAIABBiAJqIABBgAJqED5FDQAgACgCvAECfyABLQALQQd2BEAgASgCBAwBCyABLQALCyACakYEQAJ/IAEiAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwshAyACAn8gAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwtBAXQQHyACIAItAAtBB3YEfyACKAIIQf////8HcUEBawVBCgsQHyAAIAMCfyACLQALQQd2BEAgASgCAAwBCyABCyICajYCvAELIABBiAJqEC0gBiACIABBvAFqIABBCGogACwA/wEgAEHQAWogAEEQaiAAQQxqQeDTARB7DQAgAEGIAmoQMRoMAQsLAkACfyAALQDbAUEHdgRAIAAoAtQBDAELIAAtANsBC0UNACAAKAIMIgMgAEEQamtBnwFKDQAgACADQQRqNgIMIAMgACgCCDYCAAsgBSACIAAoArwBIAQgBhDCAjcDACAAQdABaiAAQRBqIAAoAgwgBBA7IABBiAJqIABBgAJqEDQEQCAEIAQoAgBBAnI2AgALIAAoAogCIQIgARAcGiAAQdABahAcGiAAQZACaiQAIAILvgQBAX8jAEGQAmsiACQAIAAgAjYCgAIgACABNgKIAiADEFchBiAAQdABaiADIABB/wFqEIYBIABBwAFqECAiASABLQALQQd2BH8gASgCCEH/////B3FBAWsFQQoLEB8gAAJ/IAEtAAtBB3YEQCABKAIADAELIAELIgI2ArwBIAAgAEEQajYCDCAAQQA2AggDQAJAIABBiAJqIABBgAJqED5FDQAgACgCvAECfyABLQALQQd2BEAgASgCBAwBCyABLQALCyACakYEQAJ/IAEiAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwshAyACAn8gAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwtBAXQQHyACIAItAAtBB3YEfyACKAIIQf////8HcUEBawVBCgsQHyAAIAMCfyACLQALQQd2BEAgASgCAAwBCyABCyICajYCvAELIABBiAJqEC0gBiACIABBvAFqIABBCGogACwA/wEgAEHQAWogAEEQaiAAQQxqQeDTARB7DQAgAEGIAmoQMRoMAQsLAkACfyAALQDbAUEHdgRAIAAoAtQBDAELIAAtANsBC0UNACAAKAIMIgMgAEEQamtBnwFKDQAgACADQQRqNgIMIAMgACgCCDYCAAsgBSACIAAoArwBIAQgBhDFAjsBACAAQdABaiAAQRBqIAAoAgwgBBA7IABBiAJqIABBgAJqEDQEQCAEIAQoAgBBAnI2AgALIAAoAogCIQIgARAcGiAAQdABahAcGiAAQZACaiQAIAILvgQBAX8jAEGQAmsiACQAIAAgAjYCgAIgACABNgKIAiADEFchBiAAQdABaiADIABB/wFqEIYBIABBwAFqECAiASABLQALQQd2BH8gASgCCEH/////B3FBAWsFQQoLEB8gAAJ/IAEtAAtBB3YEQCABKAIADAELIAELIgI2ArwBIAAgAEEQajYCDCAAQQA2AggDQAJAIABBiAJqIABBgAJqED5FDQAgACgCvAECfyABLQALQQd2BEAgASgCBAwBCyABLQALCyACakYEQAJ/IAEiAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwshAyACAn8gAi0AC0EHdgRAIAIoAgQMAQsgAi0ACwtBAXQQHyACIAItAAtBB3YEfyACKAIIQf////8HcUEBawVBCgsQHyAAIAMCfyACLQALQQd2BEAgASgCAAwBCyABCyICajYCvAELIABBiAJqEC0gBiACIABBvAFqIABBCGogACwA/wEgAEHQAWogAEEQaiAAQQxqQeDTARB7DQAgAEGIAmoQMRoMAQsLAkACfyAALQDbAUEHdgRAIAAoAtQBDAELIAAtANsBC0UNACAAKAIMIgMgAEEQamtBnwFKDQAgACADQQRqNgIMIAMgACgCCDYCAAsgBSACIAAoArwBIAQgBhDGAjcDACAAQdABaiAAQRBqIAAoAgwgBBA7IABBiAJqIABBgAJqEDQEQCAEIAQoAgBBAnI2AgALIAAoAogCIQIgARAcGiAAQdABahAcGiAAQZACaiQAIAILGABBq5ICLAAAQQBIBEBBoJICKAIAEBsLC74EAQF/IwBBkAJrIgAkACAAIAI2AoACIAAgATYCiAIgAxBXIQYgAEHQAWogAyAAQf8BahCGASAAQcABahAgIgEgAS0AC0EHdgR/IAEoAghB/////wdxQQFrBUEKCxAfIAACfyABLQALQQd2BEAgASgCAAwBCyABCyICNgK8ASAAIABBEGo2AgwgAEEANgIIA0ACQCAAQYgCaiAAQYACahA+RQ0AIAAoArwBAn8gAS0AC0EHdgRAIAEoAgQMAQsgAS0ACwsgAmpGBEACfyABIgItAAtBB3YEQCACKAIEDAELIAItAAsLIQMgAgJ/IAItAAtBB3YEQCACKAIEDAELIAItAAsLQQF0EB8gAiACLQALQQd2BH8gAigCCEH/////B3FBAWsFQQoLEB8gACADAn8gAi0AC0EHdgRAIAEoAgAMAQsgAQsiAmo2ArwBCyAAQYgCahAtIAYgAiAAQbwBaiAAQQhqIAAsAP8BIABB0AFqIABBEGogAEEMakHg0wEQew0AIABBiAJqEDEaDAELCwJAAn8gAC0A2wFBB3YEQCAAKALUAQwBCyAALQDbAQtFDQAgACgCDCIDIABBEGprQZ8BSg0AIAAgA0EEajYCDCADIAAoAgg2AgALIAUgAiAAKAK8ASAEIAYQxwI2AgAgAEHQAWogAEEQaiAAKAIMIAQQOyAAQYgCaiAAQYACahA0BEAgBCAEKAIAQQJyNgIACyAAKAKIAiECIAEQHBogAEHQAWoQHBogAEGQAmokACACCzQBAX8jAEEQayIEJAAgACgCACEAIAQgAzoADyABIAIgBEEPaiAAEQQAIQAgBEEQaiQAIAAL7QIBAn8jAEEgayIGJAAgBiABNgIYAkAgAygCBEEBcUUEQCAGQX82AgAgBiAAIAEgAiADIAQgBiAAKAIAKAIQEQUAIgE2AhgCQAJAAkAgBigCAA4CAAECCyAFQQA6AAAMAwsgBUEBOgAADAILIAVBAToAACAEQQQ2AgAMAQsgBiADKAIcIgA2AgAgACAAKAIEQQFqNgIEIAYQQyEHIAYoAgAiACAAKAIEQQFrIgE2AgQgAUF/RgRAIAAgACgCACgCCBEBAAsgBiADKAIcIgA2AgAgACAAKAIEQQFqNgIEIAYQfCEAIAYoAgAiASABKAIEQQFrIgM2AgQgA0F/RgRAIAEgASgCACgCCBEBAAsgBiAAIAAoAgAoAhgRAwAgBkEMciAAIAAoAgAoAhwRAwAgBSAGQRhqIgMgAiAGIAMgByAEQQEQngEgBkY6AAAgBigCGCEBA0AgA0EMaxAcIgMgBkcNAAsLIAZBIGokACABC0ABAX9BACEAA38gASACRgR/IAAFIAEoAgAgAEEEdGoiAEGAgICAf3EiA0EYdiADciAAcyEAIAFBBGohAQwBCwsLGwAjAEEQayIBJAAgACACIAMQyAIgAUEQaiQAC1QBAn8CQANAIAMgBEcEQEF/IQAgASACRg0CIAEoAgAiBSADKAIAIgZIDQIgBSAGSgRAQQEPBSADQQRqIQMgAUEEaiEBDAILAAsLIAEgAkchAAsgAAtAAQF/QQAhAAN/IAEgAkYEfyAABSABLAAAIABBBHRqIgBBgICAgH9xIgNBGHYgA3IgAHMhACABQQFqIQEMAQsLCwsAIAAgAiADEMoCC14BA38gASAEIANraiEFAkADQCADIARHBEBBfyEAIAEgAkYNAiABLAAAIgYgAywAACIHSA0CIAYgB0oEQEEBDwUgA0EBaiEDIAFBAWohAQwCCwALCyACIAVHIQALIAALFAAgACgCACABaiACLQAAOgAAQQELAwAACzcBAX8jAEEQayIDJAAgA0EIaiABIAIgACgCABEGACADKAIIEA0gAygCCCIAEAwgA0EQaiQAIAALUgECfyABIAAoAlQiASABIAJBgAJqIgMQ2wIiBCABayADIAQbIgMgAiACIANLGyICEB4aIAAgASADaiIDNgJUIAAgAzYCCCAAIAEgAmo2AgQgAgszAQF/IAAoAhQiAyABIAIgACgCECADayIBIAEgAksbIgEQHhogACAAKAIUIAFqNgIUIAILSwECfyMAQRBrIgMkAEEBIQQgACACIAEoAgQgASgCACIBa0kEfyADIAEgAmosAAA2AghB7I0CIANBCGoQDgVBAQs2AgAgA0EQaiQACwkAIAAQxwEQGws1AQF/IAEgACgCBCICQQF1aiEBIAAoAgAhACABIAJBAXEEfyABKAIAIABqKAIABSAACxEAAAsNACAAKAIEIAAoAgBrC1QBAn8jAEEQayIEJAAgASAAKAIEIgVBAXVqIQEgACgCACEAIAVBAXEEQCABKAIAIABqKAIAIQALIAQgAzoADyABIAIgBEEPaiAAEQYAIARBEGokAAurBAEGfyABIAAoAgQgACgCACIFayIDSwRAAkAgASADayIGIAAiBSgCCCIBIAAoAgQiA2tNBEACQCAGRQ0AIAMhACAGQQdxIgQEQANAIAAgAi0AADoAACAAQQFqIQAgBEEBayIEDQALCyADIAZqIQMgBkEBa0EHSQ0AA0AgACACLQAAOgAAIAAgAi0AADoAASAAIAItAAA6AAIgACACLQAAOgADIAAgAi0AADoABCAAIAItAAA6AAUgACACLQAAOgAGIAAgAi0AADoAByAAQQhqIgAgA0cNAAsLIAUgAzYCBAwBCyADIAUoAgAiAGsiBCAGaiIHQQBOBEAgBCAHIAEgAGsiAUEBdCIAIAAgB0kbQf////8HIAFB/////wNJGyIIBH8gCBAdBUEACyIHaiIBIQAgBkEHcSIEBEAgASEAA0AgACACLQAAOgAAIABBAWohACAEQQFrIgQNAAsLIAEgBmohBCAGQQFrQQdPBEADQCAAIAItAAA6AAAgACACLQAAOgABIAAgAi0AADoAAiAAIAItAAA6AAMgACACLQAAOgAEIAAgAi0AADoABSAAIAItAAA6AAYgACACLQAAOgAHIABBCGoiACAERw0ACwsgASADIAUoAgAiAmsiAWshACABQQBKBEAgACACIAEQHhoLIAUgByAIajYCCCAFIAQ2AgQgBSAANgIAIAIEQCACEBsLDAELEEEACw8LIAEgA0kEQCAAIAEgBWo2AgQLC2ABBX8jAEEwayIDJAAgASgCBCEFIAEoAgAhBiABLQALIQQgAxDAAiICELgCIAIgBiABIARBGHRBGHVBAEgiARsgBSAEIAEbQQJ2ELMCIAAgAhCxAiACELoCIANBMGokAAsLufEBNgBBgAgLtBZpbmZpbml0eQBpbnN1ZmZpY2llbnQgbWVtb3J5AEZlYnJ1YXJ5AEphbnVhcnkAbmVlZCBkaWN0aW9uYXJ5AEp1bHkAVGh1cnNkYXkAVHVlc2RheQBXZWRuZXNkYXkAU2F0dXJkYXkAU3VuZGF5AE1vbmRheQBGcmlkYXkATWF5ACVtLyVkLyV5AC0rICAgMFgweAAtMFgrMFggMFgtMHgrMHggMHgATm92AFRodQBBdWd1c3QAdW5zaWduZWQgc2hvcnQAdW5zaWduZWQgaW50AEFGUENsaWVudABpbnZhbGlkIGxpdGVyYWwvbGVuZ3RocyBzZXQAaW52YWxpZCBjb2RlIGxlbmd0aHMgc2V0AHVua25vd24gaGVhZGVyIGZsYWdzIHNldABpbnZhbGlkIGRpc3RhbmNlcyBzZXQAZ2V0AE9jdABmbG9hdABpbnZhbGlkIGJpdCBsZW5ndGggcmVwZWF0AFNhdAB1aW50NjRfdAB0b28gbWFueSBsZW5ndGggb3IgZGlzdGFuY2Ugc3ltYm9scwBpbnZhbGlkIHN0b3JlZCBibG9jayBsZW5ndGhzAGtleS5zaXplKCkgPT0gUGFyYW06OmtCbG9ja0J5dGVzAEFwcgB2ZWN0b3IAc2lnbkZhY3RvcgBTcGVjQ2FsY3VsYXRvcgBidWZmZXIgZXJyb3IAc3RyZWFtIGVycm9yAGZpbGUgZXJyb3IAZGF0YSBlcnJvcgBPY3RvYmVyAE5vdmVtYmVyAFNlcHRlbWJlcgBEZWNlbWJlcgB1bnNpZ25lZCBjaGFyAGlvc19iYXNlOjpjbGVhcgBNYXIAY2xpZW50L0FGUENsaWVudC5jcHAAZmZ0L1NwZWNDYWxjdWxhdG9yLmNwcAB1dGlsL0VuY3J5cHRpb24uY3BwAFNlcAAlSTolTTolUyAlcABTdW4ASnVuAGZyZXFSZXNvbHV0aW9uAEFFU19FbmNyeXB0aW9uAHN0ZDo6ZXhjZXB0aW9uAF9fY3hhX2d1YXJkX2FjcXVpcmUgZGV0ZWN0ZWQgcmVjdXJzaXZlIGluaXRpYWxpemF0aW9uAGluY29tcGF0aWJsZSB2ZXJzaW9uAE1vbgBuYW4ASmFuAG1heFBrc1BlckZybQBKdWwAYm9vbABsbABBcHJpbAByZW1vdmVTaWwAZW1zY3JpcHRlbjo6dmFsAGludmFsaWQgY29kZSAtLSBtaXNzaW5nIGVuZC1vZi1ibG9jawBpbmNvcnJlY3QgaGVhZGVyIGNoZWNrAGluY29ycmVjdCBsZW5ndGggY2hlY2sAaW5jb3JyZWN0IGRhdGEgY2hlY2sAcHVzaF9iYWNrAGludmFsaWQgZGlzdGFuY2UgdG9vIGZhciBiYWNrAEZyaQBoZWFkZXIgY3JjIG1pc21hdGNoAE1hcmNoAEV4dHJhY3RRdWVyeUZQRGVidWcAQXVnAHVuc2lnbmVkIGxvbmcAc3RkOjp3c3RyaW5nAGJhc2ljX3N0cmluZwBzdGQ6OnN0cmluZwBzdGQ6OnUxNnN0cmluZwBzdGQ6OnUzMnN0cmluZwBpbmYAJS4wTGYAJUxmACVmAHJlc2l6ZQBpbnZhbGlkIHdpbmRvdyBzaXplAGFsbG9jYXRvcjxUPjo6YWxsb2NhdGUoc2l6ZV90IG4pICduJyBleGNlZWRzIG1heGltdW0gc3VwcG9ydGVkIHNpemUAdHJ1ZQBUdWUAZmFsc2UAaW52YWxpZCBibG9jayB0eXBlAEp1bmUAZG91YmxlAGludmFsaWQgbGl0ZXJhbC9sZW5ndGggY29kZQBpbnZhbGlkIGRpc3RhbmNlIGNvZGUAbWFza0RlY2F5VF9zdGQAbWFza0RlY2F5Rl9zdGQAdW5rbm93biBjb21wcmVzc2lvbiBtZXRob2QAbWFwOjphdDogIGtleSBub3QgZm91bmQAc3RyZWFtIGVuZAB2b2lkAFdlZABEZWMARmViACVhICViICVkICVIOiVNOiVTICVZAFBPU0lYAGludGVydmFsVAAlSDolTTolUwBGUFZFUgBFeHRyYWN0UXVlcnlGUABBUlJMRU4ATkFOAFBNAEFNAExDX0FMTABMQU5HAGludGVydmFsRgBJTkYAQwBmcmVxVUIAZnJlcUxCAGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PHNob3J0PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1bnNpZ25lZCBzaG9ydD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8aW50PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1bnNpZ25lZCBpbnQ+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PGZsb2F0PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1aW50OF90PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxpbnQ4X3Q+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PHVpbnQxNl90PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxpbnQxNl90PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1aW50MzJfdD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8aW50MzJfdD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8Y2hhcj4AdmVjdG9yPGNoYXI+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PHVuc2lnbmVkIGNoYXI+AHN0ZDo6YmFzaWNfc3RyaW5nPHVuc2lnbmVkIGNoYXI+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PHNpZ25lZCBjaGFyPgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxsb25nPgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1bnNpZ25lZCBsb25nPgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxkb3VibGU+ADAxMjM0NTY3ODkAMDc0Qjk3MjIxRjI3RjAyOQBDLlVURi04ADEuMi4xMQBoeWFpXzEuMi4wX2NsaWVudF8xLjAuMAAuAChudWxsKQBHZXRGRlRMZW4oKSA+PSBHZXRGcm1MZW4oKQBfcGtFeHRFbmctPkluaXRTdWMoKQBQdXJlIHZpcnR1YWwgZnVuY3Rpb24gY2FsbGVkIQAlZgoABA4AAIwOAABOU3QzX18yNnZlY3RvckljTlNfOWFsbG9jYXRvckljRUVFRQBOU3QzX18yMTNfX3ZlY3Rvcl9iYXNlSWNOU185YWxsb2NhdG9ySWNFRUVFAE5TdDNfXzIyMF9fdmVjdG9yX2Jhc2VfY29tbW9uSUxiMUVFRQAAAACMhwAAvA0AABCIAACQDQAAAAAAAAEAAADkDQAAAAAAABCIAABsDQAAAAAAAAEAAADsDQAAAAAAAE5TdDNfXzIxMmJhc2ljX3N0cmluZ0ljTlNfMTFjaGFyX3RyYWl0c0ljRUVOU185YWxsb2NhdG9ySWNFRUVFAE5TdDNfXzIyMV9fYmFzaWNfc3RyaW5nX2NvbW1vbklMYjFFRUUAAAAAjIcAAFsOAAAQiAAAHA4AAAAAAAABAAAAhA4AAAAAAABpaWkAUE5TdDNfXzI2dmVjdG9ySWNOU185YWxsb2NhdG9ySWNFRUVFAAAAAGyIAACoDgAAAAAAAAQOAABQS05TdDNfXzI2dmVjdG9ySWNOU185YWxsb2NhdG9ySWNFRUVFAAAAbIgAAOAOAAABAAAABA4AAGlpAHYAdmkA0A4AAMiGAADQDgAA7IYAAHZpaWkAQcAeC9sEyIYAANAOAABMhwAA7IYAAHZpaWlpAAAATIcAAAgPAACADwAABA4AAEyHAABOMTBlbXNjcmlwdGVuM3ZhbEUAAIyHAABsDwAAaWlpaQAAAADghgAABA4AAEyHAADshgAAaWlpaWkAAAAAAAAA3A8AABQAAAAVAAAAFgAAADExRkZUX0tJU1NGRlQAOEZGVF9CYXNlAIyHAADKDwAAtIcAALwPAADUDwAAAAAAAEAQAAAYAAAAGQAAABoAAAAbAAAAHAAAADI1UGVha0V4dHJhY3RvckNvcmVCYXNlbGluZQAyMFBlYWtFeHRyYWN0b3JD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ByMAAAhyAAAIMgAACcQAEQcLAAAIYgAACCIAAAmkAAAIAgAACIIAAAhCAAAJ5AAQBwcAAAhaAAAIGgAACZQAFAdDAAAIegAACDoAAAnUABIHEwAACGoAAAgqAAAJtAAACAoAAAiKAAAISgAACfQAEAcFAAAIVgAACBYAQAgAABMHMwAACHYAAAg2AAAJzAARBw8AAAhmAAAIJgAACawAAAgGAAAIhgAACEYAAAnsABAHCQAACF4AAAgeAAAJnAAUB2MAAAh+AAAIPgAACdwAEgcbAAAIbgAACC4AAAm8AAAIDgAACI4AAAhOAAAJ/ABgBwAAAAhRAAAIEQAVCIMAEgcfAAAIcQAACDEAAAnCABAHCgAACGEAAAghAAAJogAACAEAAAiBAAAIQQAACeIAEAcGAAAIWQAACBkAAAmSABMHOwAACHkAAAg5AAAJ0gARBxEAAAhpAAAIKQAACbIAAAgJAAAIiQAACEkAAAnyABAHBAAACFUAAAgVABAIAgETBysAAAh1AAAINQAACcoAEQcNAAAIZQAACCUAAAmqAAAIBQAACIUAAAhFAAAJ6gAQBwgAAAhdAAAIHQAACZoAFAdTAAAIfQAACD0AAAnaABIHFwAACG0AAAgtAAAJugAACA0AAAiNAAAITQAACfoAEAcDAAAIUwAACBMAFQjDABMHIwAACHMAAAgzAAAJxgARBwsAAAhjAAAIIwAACaYAAAgDAAAIgwAACEMAAAnmABAHBwAACFsAAAgbAAAJlgAUB0MAAAh7AAAIOwAACdYAEgcTAAAIawAACCsAAAm2AAAICwAACIsAAAhLAAAJ9gAQBwUAAAhXAAAIFwBACAAAEwczAAAIdwAACDcAAAnOABEHDwAACGcAAAgnAAAJrgAACAcAAAiHAAAIRwAACe4AEAcJAAAIXwAACB8AAAmeABQHYwAACH8AAAg/AAAJ3gASBxsAAAhvAAAILwAACb4AAAgPAAAIjwAACE8AAAn+AGAHAAAACFAAAAgQABQIcwASBx8AAAhwAAAIMAAACcEAEAcKAAAIYAAACCAAAAmhAAAIAAAACIAAAAhAAAAJ4QAQBwYAAAhYAAAIGAAACZEAEwc7AAAIeAAACDgAAAnRABEHEQAACGgAAAgoAAAJsQAACAgAAAiIAAAISAAACfEAEAcEAAAIVAAACBQAFQjjABMHKwAACHQAAAg0AAAJyQARBw0AAAhkAAAIJAAACakAAAgEAAAIhAAACEQAAAnpABAHCAAACFwAAAgcAAAJmQAUB1MAAAh8AAAIPAAACdkAEgcXAAAIbAAACCwAAAm5AAAIDAAACIwAAAhMAAAJ+QAQBwMAAAhSAAAIEgAVCKMAEwcjAAAIcgAACDIAAAnFABEHCwAACGIAAAgiAAAJpQAACAIAAAiCAAAIQgAACeUAEAcHAAAIWgAACBoAAAmVABQHQwAACHoAAAg6AAAJ1QASBxMAAAhqAAAIKgAACbUAAAgKAAAIigAACEoAAAn1ABAHBQAACFYAAAgWAEAIAAATBzMAAAh2AAAINgAACc0AEQcPAAAIZgAACCYAAAmtAAAIBgAACIYAAAhGAAAJ7QAQBwkAAAheAAAIHgAACZ0AFAdjAAAIfgAACD4AAAndABIHGwAACG4AAAguAAAJvQAACA4AAAiOAAAITgAACf0AYAcAAAAIUQAACBEAFQiDABIHHwAACHEAAAgxAAAJwwAQBwoAAAhhAAAIIQAACaMAAAgBAAAIgQAACEEAAAnjABAHBgAACFkAAAgZAAAJkwATBzsAAAh5AAAIOQAACdMAEQcRAAAIaQAACCkAAAmzAAAICQAACIkAAAhJAAAJ8wAQBwQAAAhVAAAIFQAQCAIBEwcrAAAIdQAACDUAAAnLABEHDQAACGUAAAglAAAJqwAACAUAAAiFAAAIRQAACesAEAcIAAAIXQAACB0AAAmbABQHUwAACH0AAAg9AAAJ2wASBxcAAAhtAAAILQAACbsAAAgNAAAIjQAACE0AAAn7ABAHAwAACFMAAAgTABUIwwATByMAAAhzAAAIMwAACccAEQcLAAAIYwAACCMAAAmnAAAIAwAACIMAAAhDAAAJ5wAQBwcAAAhbAAAIGwAACZcAFAdDAAAIewAACDsAAAnXABIHEwAACGsAAAgrAAAJtwAACAsAAAiLAAAISwAACfcAEAcFAAAIVwAACBcAQAgAABMHMwAACHcAAAg3AAAJzwARBw8AAAhnAAAIJwAACa8AAAgHAAAIhwAACEcAAAnvABAHCQAACF8AAAgfAAAJnwAUB2MAAAh/AAAIPwAACd8AEgcbAAAIbwAACC8AAAm/AAAIDwAACI8AAAhPAAAJ/wAQBQEAFwUBARMFEQAbBQEQEQUFABkFAQQVBUEAHQUBQBAFAwAYBQECFAUhABwFASASBQkAGgUBCBYFgQBABQAAEAUCABcFgQETBRkAGwUBGBEFBwAZBQEGFQVhAB0FAWAQBQQAGAUBAxQFMQAcBQEwEgUNABoFAQwWBcEAQAUAAAMABAAFAAYABwAIAAkACgALAA0ADwARABMAFwAbAB8AIwArADMAOwBDAFMAYwBzAIMAowDDAOMAAgEAAAAAAAAQABAAEAAQABAAEAAQABAAEQARABEAEQASABIAEgASABMAEwATABMAFAAUABQAFAAVABUAFQAVABAATQDKAAAAAQACAAMABAAFAAcACQANABEAGQAhADEAQQBhAIEAwQABAYEBAQIBAwEEAQYBCAEMARABGAEgATABQAFgAAAAABAAEAAQABAAEQARABIAEgATABMAFAAUABUAFQAWABYAFwAXABgAGAAZABkAGgAaABsAGwAcABwAHQAdAEAAQAAAAQIDBAQFBQYGBgYHBwcHCAgICAgICAgJCQkJCQkJCQoKCgoKCgoKCgoKCgoKCgoLCwsLCwsLCwsLCwsLCwsLDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwNDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PAAAQERISExMUFBQUFRUVFRYWFhYWFhYWFxcXFxcXFxcYGBgYGBgYGBgYGBgYGBgYGRkZGRkZGRkZGRkZGRkZGRoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxscHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHQABAgMEBQYHCAgJCQoKCwsMDAwMDQ0NDQ4ODg4PDw8PEBAQEBAQEBARERERERERERISEhISEhISExMTExMTExMUFBQUFBQUFBQUFBQUFBQUFRUVFRUVFRUVFRUVFRUVFRYWFhYWFhYWFhYWFhYWFhYXFxcXFxcXFxcXFxcXFxcXGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxwQPwAAEEQAAAEBAAAeAQAADwAAAJBDAACQRAAAAAAAAB4AAAAPAAAAAAAAABBFAAAAAAAAEwAAAAcAAAAAAAAADAAIAIwACABMAAgAzAAIACwACACsAAgAbAAIAOwACAAcAAgAnAAIAFwACADcAAgAPAAIALwACAB8AAgA/AAIAAIACACCAAgAQgAIAMIACAAiAAgAogAIAGIACADiAAgAEgAIAJIACABSAAgA0gAIADIACACyAAgAcgAIAPIACAAKAAgAigAIAEoACADKAAgAKgAIAKoACABqAAgA6gAIABoACACaAAgAWgAIANoACAA6AAgAugAIAHoACAD6AAgABgAIAIYACABGAAgAxgAIACYACACmAAgAZgAIAOYACAAWAAgAlgAIAFYACADWAAgANgAIALYACAB2AAgA9gAIAA4ACACOAAgATgAIAM4ACAAuAAgArgAIAG4ACADuAAgAHgAIAJ4ACABeAAgA3gAIAD4ACAC+AAgAfgAIAP4ACAABAAgAgQAIAEEACADBAAgAIQAIAKEACABhAAgA4QAIABEACACRAAgAUQAIANEACAAxAAgAsQAIAHEACADxAAgACQAIAIkACABJAAgAyQAIACkACACpAAgAaQAIAOkACAAZAAgAmQAIAFkACADZAAgAOQAIALkACAB5AAgA+QAIAAUACACFAAgARQAIAMUACAAlAAgApQAIAGUACADlAAgAFQAIAJUACABVAAgA1QAIADUACAC1AAgAdQAIAPUACAANAAgAjQAIAE0ACADNAAgALQAIAK0ACABtAAgA7QAIAB0ACACdAAgAXQAIAN0ACAA9AAgAvQAIAH0ACAD9AAgAEwAJABMBCQCTAAkAkwEJAFMACQBTAQkA0wAJANMBCQAzAAkAMwEJALMACQCzAQkAcwAJAHMBCQDzAAkA8wEJAAsACQALAQkAiwAJAIsBCQBLAAkASwEJAMsACQDLAQkAKwAJACsBCQCrAAkAqwEJAGsACQBrAQkA6wAJAOsBCQAbAAkAGwEJAJsACQCbAQkAWwAJAFsBCQDbAAkA2wEJADsACQA7AQkAuwAJALsBCQB7AAkAewEJAPsACQD7AQkABwAJAAcBCQCHAAkAhwEJAEcACQBHAQkAxwAJAMcBCQAnAAkAJwEJAKcACQCnAQkAZwAJAGcBCQDnAAkA5wEJABcACQAXAQkAlwAJAJcBCQBXAAkAVwEJANcACQDXAQkANwAJADcBCQC3AAkAtwEJAHcACQB3AQkA9wAJAPcBCQAPAAkADwEJAI8ACQCPAQkATwAJAE8BCQDPAAkAzwEJAC8ACQAvAQkArwAJAK8BCQBvAAkAbwEJAO8ACQDvAQkAHwAJAB8BCQCfAAkAnwEJAF8ACQBfAQkA3wAJAN8BCQA/AAkAPwEJAL8ACQC/AQkAfwAJAH8BCQD/AAkA/wEJAAAABwBAAAcAIAAHAGAABwAQAAcAUAAHADAABwBwAAcACAAHAEgABwAoAAcAaAAHABgABwBYAAcAOAAHAHgABwAEAAcARAAHACQABwBkAAcAFAAHAFQABwA0AAcAdAAHAAMACACDAAgAQwAIAMMACAAjAAgAowAIAGMACADjAAgAAAAFABAABQAIAAUAGAAFAAQABQAUAAUADAAFABwABQACAAUAEgAFAAoABQAaAAUABgAFABYABQAOAAUAHgAFAAEABQARAAUACQAFABkABQAFAAUAFQAFAA0ABQAdAAUAAwAFABMABQALAAUAGwAFAAcABQAXAAUAQbCIAQtNAQAAAAEAAAABAAAAAQAAAAIAAAACAAAAAgAAAAIAAAADAAAAAwAAAAMAAAADAAAABAAAAAQAAAAEAAAABAAAAAUAAAAFAAAABQAAAAUAQaCJAQtlAQAAAAEAAAACAAAAAgAAAAMAAAADAAAABAAAAAQAAAAFAAAABQAAAAYAAAAGAAAABwAAAAcAAAAIAAAACAAAAAkAAAAJAAAACgAAAAoAAAALAAAACwAAAAwAAAAMAAAADQAAAA0AQdCKAQsjAgAAAAMAAAAHAAAAAAAAABAREgAIBwkGCgULBAwDDQIOAQ8AQYSLAQtpAQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAKAAAADAAAAA4AAAAQAAAAFAAAABgAAAAcAAAAIAAAACgAAAAwAAAAOAAAAEAAAABQAAAAYAAAAHAAAACAAAAAoAAAAMAAAADgAEGEjAELcgEAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAAABAACAAQAAAAIAAAADAAAABAAAAAYAAAAIAAAADAAAABAAAAAYAAAAIAAAADAAAABAAAAAYABBgI0BC5EHLgQAAL8JAABjDQAAFQYAAAgGAAAgBgAACQQAAPsFAAAsBwAAYw0AAE5TdDNfXzIxMmJhc2ljX3N0cmluZ0loTlNfMTFjaGFyX3RyYWl0c0loRUVOU185YWxsb2NhdG9ySWhFRUVFAAAQiAAAqEYAAAAAAAABAAAAhA4AAAAAAABOU3QzX18yMTJiYXNpY19zdHJpbmdJd05TXzExY2hhcl90cmFpdHNJd0VFTlNfOWFsbG9jYXRvckl3RUVFRQAAEIgAAABHAAAAAAAAAQAAAIQOAAAAAAAATlN0M19fMjEyYmFzaWNfc3RyaW5nSURzTlNfMTFjaGFyX3RyYWl0c0lEc0VFTlNfOWFsbG9jYXRvcklEc0VFRUUAAAAQiAAAWEcAAAAAAAABAAAAhA4AAAAAAABOU3QzX18yMTJiYXNpY19zdHJpbmdJRGlOU18xMWNoYXJfdHJhaXRzSURpRUVOU185YWxsb2NhdG9ySURpRUVFRQAAABCIAAC0RwAAAAAAAAEAAACEDgAAAAAAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92aWV3SWNFRQAAjIcAABBIAABOMTBlbXNjcmlwdGVuMTFtZW1vcnlfdmlld0lhRUUAAIyHAAA4SAAATjEwZW1zY3JpcHRlbjExbWVtb3J5X3ZpZXdJaEVFAACMhwAAYEgAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92aWV3SXNFRQAAjIcAAIhIAABOMTBlbXNjcmlwdGVuMTFtZW1vcnlfdmlld0l0RUUAAIyHAACwSAAATjEwZW1zY3JpcHRlbjExbWVtb3J5X3ZpZXdJaUVFAACMhwAA2EgAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92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qFYAtKi1AM/MDgASiS0Ab1c0ACxWiQCZzuMA1iC5AGteqgA+KpwAEV/MAP0LSgDh9PsAjjttAOKGLADp1IQA/LSpAO/u0QAuNckALzlhADghRAAb2cgAgfwKAPtKagAvHNgAU7SEAE6ZjABUIswAKlXcAMDG1gALGZYAGnC4AGmVZAAmWmAAP1LuAH8RDwD0tREA/Mv1ADS8LQA0vO4A6F3MAN1eYABnjpsAkjPvAMkXuABhWJsA4Ve8AFGDxgDYPhAA3XFIAC0c3QCvGKEAISxGAFnz1wDZepgAnlTAAE+G+gBWBvwA5XmuAIkiNgA4rSIAZ5PcAFXoqgCCJjgAyuebAFENpACZM7EAqdcOAGkFSABlsvAAf4inAIhMlwD50TYAIZKzAHuCSgCYzyEAQJ/cANxHVQDhdDoAZ+tCAP6d3wBe1F8Ae2ekALqsegBV9qIAK4gjAEG6VQBZbggAISqGADlHgwCJ4+YA5Z7UAEn7QAD/VukAHA/KAMVZigCU+isA08HFAA/FzwDbWq4AR8WGAIVDYgAhhjsALHmUABBhhwAqTHsAgCwaAEO/EgCIJpAAeDyJAKjE5ADl23sAxDrCACb06gD3Z4oADZK/AGWjKwA9k7EAvXwLAKRR3AAn3WMAaeHdAJqUGQCoKZUAaM4oAAnttABEnyAATpjKAHCCYwB+fCMAD7kyAKf1jgAUVucAIfEIALWdKgBvfk0ApRlRALX5qwCC39YAlt1hABY2AgDEOp8Ag6KhAHLtbQA5jXoAgripAGsyXABGJ1sAADTtANIAdwD89FUAAVlNAOBxgABBo60BC68BQPsh+T8AAAAALUR0PgAAAICYRvg8AAAAYFHMeDsAAACAgxvwOQAAAEAgJXo4AAAAgCKC4zYAAAAAHfNpNdF0ngBXnb0qgHBSD///PicKAAAAZAAAAOgDAAAQJwAAoIYBAEBCDwCAlpgAAOH1BRgAAAA1AAAAcQAAAGv////O+///kr///wAAAABMVwAAMAAAADEAAABOU3QzX18yOGlvc19iYXNlRQAAAIyHAAA4VwBB/K4BCwEyAEGjrwELBf//////AEHwrwELgwT/////////////////////////////////////////////////////////////////AAECAwQFBgcICf////////8KCwwNDg8QERITFBUWFxgZGhscHR4fICEiI////////woLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIj/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wABAgQHAwYFAAAAAAAAAAIAAMADAADABAAAwAUAAMAGAADABwAAwAgAAMAJAADACgAAwAsAAMAMAADADQAAwA4AAMAPAADAEAAAwBEAAMASAADAEwAAwBQAAMAVAADAFgAAwBcAAMAYAADAGQAAwBoAAMAbAADAHAAAwB0AAMAeAADAHwAAwAAAALMBAADDAgAAwwMAAMMEAADDBQAAwwYAAMMHAADDCAAAwwkAAMMKAADDCwAAwwwAAMMNAADTDgAAww8AAMMAAAy7AQAMwwIADMMDAAzDBAAM0wAAAADeEgSVAAAAAP///////////////9BZAAAUAAAAQy5VVEYtOABBmLQBCwLkWQBBsLQBC0pMQ19DVFlQRQAAAABMQ19OVU1FUklDAABMQ19USU1FAAAAAABMQ19DT0xMQVRFAABMQ19NT05FVEFSWQBMQ19NRVNTQUdFUwCAWwBBgLcBC/8BAgACAAIAAgACAAIAAgACAAIAAyACIAIgAiACIAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAFgBMAEwATABMAEwATABMAEwATABMAEwATABMAEwATACNgI2AjYCNgI2AjYCNgI2AjYCNgEwATABMAEwATABMAEwAjVCNUI1QjVCNUI1QjFCMUIxQjFCMUIxQjFCMUIxQjFCMUIxQjFCMUIxQjFCMUIxQjFCMUEwATABMAEwATABMAI1gjWCNYI1gjWCNYIxgjGCMYIxgjGCMYIxgjGCMYIxgjGCMYIxgjGCMYIxgjGCMYIxgjGBMAEwATABMACAEGAuwELApBfAEGUvwEL+QMBAAAAAgAAAAMAAAAEAAAABQAAAAYAAAAHAAAACAAAAAkAAAAKAAAACwAAAAwAAAANAAAADgAAAA8AAAAQAAAAEQAAABIAAAATAAAAFAAAABUAAAAWAAAAFwAAABgA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" - -if (typeof exports != 'undefined') - exports.WASM_BINARY = WASM_BINARY; diff --git a/src/router/xtc_recognize/afp/afp_runner.js b/src/router/xtc_recognize/afp/afp_runner.js deleted file mode 100644 index 25aa6fda484e444f10cf15b141a4a383c3fbdaeb..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/afp/afp_runner.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 指纹生成子进程脚本(被 Python 通过 subprocess 调用) - * - * 协议: - * stdin: base64 编码的 16-bit PCM s16le 数据(8kHz 单声道,3秒) - * stdout: base64 编码的指纹 FP 字符串 - * stderr: 日志/错误 - * - * 退出码: - * 0 成功;1 失败 - */ - -const path = require('path') -const { GenerateFP } = require(path.join(__dirname, 'afp.js')) - -async function main() { - const chunks = [] - process.stdin.on('data', (c) => chunks.push(c)) - process.stdin.on('end', async () => { - try { - const b64 = Buffer.concat(chunks).toString('utf8').trim() - const pcmBytes = Buffer.from(b64, 'base64') - // s16le -> Float32Array - const samples = pcmBytes.length / 2 - const pcm = new Float32Array(samples) - const dv = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength) - for (let i = 0; i < samples; i++) { - pcm[i] = dv.getInt16(i * 2, true) / 32768 - } - const t0 = Date.now() - const fp = await GenerateFP(pcm) - const t1 = Date.now() - process.stderr.write(`[afp-runner] samples=${samples} fp_len=${fp.length} took=${t1 - t0}ms\n`) - process.stdout.write(fp) - process.exit(0) - } catch (e) { - process.stderr.write('[afp-runner] ERROR: ' + (e.stack || e.message) + '\n') - process.exit(1) - } - }) - process.stdin.on('error', (e) => { - process.stderr.write('[afp-runner] stdin error: ' + e.message + '\n') - process.exit(1) - }) -} - -main() diff --git a/src/router/xtc_recognize/amr_decoder.py b/src/router/xtc_recognize/amr_decoder.py deleted file mode 100644 index afa35c76f644136db1a39340e7cb6f390973ce19..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/amr_decoder.py +++ /dev/null @@ -1,65 +0,0 @@ -"""AMR/任意音频 -> 8kHz 单声道 PCM s16le 转码。 - -使用系统 ffmpeg(Dockerfile 已 apt-get install ffmpeg)。 -""" -from __future__ import annotations - -import asyncio -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -async def decode_to_pcm_s16le( - audio_bytes: bytes, - *, - duration_sec: int = 3, - sample_rate: int = 8000, - channels: int = 1, - mime: str = "", -) -> bytes: - """把任意音频(amr/mp3/wav/m4a 等)转成 8kHz 单声道 PCM s16le。 - - Args: - audio_bytes: 输入音频字节 - duration_sec: 截取时长(秒),默认 3 - sample_rate: 采样率,默认 8000(网易云 shazam_v2 要求) - channels: 声道数,默认 1 - mime: MIME 类型。若为 audio/pcm 则视为已是 PCM s16le 直接截断返回 - - Returns: - PCM s16le 字节流 - - Raises: - RuntimeError: ffmpeg 失败 - """ - # 裸 PCM 直通(便于测试 / 手表若直接录 PCM 也支持) - if mime in ("audio/pcm", "audio/x-pcm", "application/pcm"): - needed = sample_rate * channels * 2 * duration_sec - return audio_bytes[:needed] - - args = [ - "ffmpeg", - "-i", "pipe:0", - "-f", "s16le", - "-acodec", "pcm_s16le", - "-ar", str(sample_rate), - "-ac", str(channels), - "-t", str(duration_sec), - "pipe:1", - ] - logger.info("[amr-decoder] ffmpeg start, input=%d bytes mime=%s", len(audio_bytes), mime) - - proc = await asyncio.create_subprocess_exec( - *args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate(input=audio_bytes) - if proc.returncode != 0: - err = stderr.decode("utf-8", errors="replace")[-500:] - raise RuntimeError(f"ffmpeg exit {proc.returncode}: {err}") - logger.info("[amr-decoder] ffmpeg ok, pcm=%d bytes", len(stdout)) - return stdout diff --git a/src/router/xtc_recognize/fingerprint.py b/src/router/xtc_recognize/fingerprint.py deleted file mode 100644 index 7d398ed8a75923c96def388f4491911d35202429..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/fingerprint.py +++ /dev/null @@ -1,48 +0,0 @@ -"""调用 Node.js 子进程运行 afp.js 生成音频指纹。 - -通过 stdin/stdout 通信,避免 Node 进程频繁启动开销(每次仍会重启 wasm, -但实测 93ms 足够快)。 -""" -from __future__ import annotations - -import asyncio -import base64 -import logging -import os -from typing import Optional - -logger = logging.getLogger(__name__) - -# afp_runner.js 路径(相对于本文件) -_RUNNER_PATH = os.path.join(os.path.dirname(__file__), "afp", "afp_runner.js") - - -async def generate_fp(pcm_s16le: bytes) -> str: - """生成音频指纹。 - - Args: - pcm_s16le: 8kHz 单声道 PCM s16le 字节 - - Returns: - base64 编码的指纹字符串 - """ - b64_input = base64.b64encode(pcm_s16le).decode("ascii") - - logger.info("[fp] spawn node, input b64=%d chars", len(b64_input)) - proc = await asyncio.create_subprocess_exec( - "node", _RUNNER_PATH, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate(input=b64_input.encode("ascii")) - if proc.returncode != 0: - err = stderr.decode("utf-8", errors="replace")[-500:] - raise RuntimeError(f"afp_runner exit {proc.returncode}: {err}") - # 把 stderr 日志输出到 logger - if stderr: - for line in stderr.decode("utf-8", errors="replace").splitlines(): - logger.info("[fp] %s", line) - fp = stdout.decode("ascii").strip() - logger.info("[fp] generated, len=%d", len(fp)) - return fp diff --git a/src/router/xtc_recognize/ncm_proxy.py b/src/router/xtc_recognize/ncm_proxy.py deleted file mode 100644 index ee27aaae5d448805c81ce50ec849b6c7061d69d6..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/ncm_proxy.py +++ /dev/null @@ -1,117 +0,0 @@ -"""网易云 API 代理:识曲 / 歌曲 URL / 歌曲详情。 - -所有调用直连网易云公开接口,不依赖 api-enhanced 子服务。 -""" -from __future__ import annotations - -import logging -from typing import Any, Optional - -import httpx - -logger = logging.getLogger(__name__) - -_NCM_HEADERS = { - "User-Agent": "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36", - "Referer": "https://music.163.com", -} -_TIMEOUT = 10.0 - - -async def audio_match(audio_fp: str, duration: int = 3) -> dict: - """调用网易云听歌识曲接口。 - - Args: - audio_fp: base64 编码的音频指纹 - duration: 录音时长(秒) - - Returns: - 网易云原始响应 dict,结构 {data: {result: [{song, startTime, score}]}} - """ - url = "https://interface.music.163.com/api/music/audio/match" - params = { - "sessionId": "0123456789abcdef", - "algorithmCode": "shazam_v2", - "duration": str(duration), - "rawdata": audio_fp, - "times": "1", - "decrypt": "1", - } - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params, headers=_NCM_HEADERS) - resp.raise_for_status() - return resp.json() - - -async def get_song_url(song_id: str, *, unblock: bool = False, level: str = "standard") -> dict: - """获取试听 URL。 - - 优先调用网易云公开接口(无 cookie,仅试听片段)。 - 若需要完整播放,由前端开关 unblock=true 触发,但本代理不实现 unblock - (需要 api-enhanced 的 unblockmusic-utils,这里仅返回公开接口结果)。 - - Returns: - {id, url, type, freeTrialInfo, fee} - """ - url = "https://music.163.com/api/song/enhance/player/url" - params = {"ids": f"[{song_id}]", "br": "128000"} - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params, headers=_NCM_HEADERS) - resp.raise_for_status() - data = resp.json() - item = (data.get("data") or [{}])[0] - return { - "id": item.get("id"), - "url": item.get("url"), - "type": item.get("type"), - "freeTrialInfo": None, - "fee": item.get("fee"), - "unblock_supported": False, # 标记给前端:本后端不支持 unblock - } - - -async def get_song_detail(song_id: str) -> Optional[dict]: - """获取歌曲详情(封面、歌手等)。""" - url = "https://music.163.com/api/v1/song/detail" - params = {"ids": f"[{song_id}]"} - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params, headers=_NCM_HEADERS) - resp.raise_for_status() - data = resp.json() - songs = data.get("songs") or [] - if not songs: - return None - s = songs[0] - return { - "id": s.get("id"), - "name": s.get("name"), - "artists": "/".join(a.get("name", "") for a in s.get("artists", [])), - "album": (s.get("album") or {}).get("name"), - "cover": (s.get("album") or {}).get("picUrl"), - "duration": s.get("duration"), - } - - -async def get_song_detail_batch(song_ids: list[str]) -> list[Optional[dict]]: - """批量获取歌曲详情。公开接口不支持批量,逐个调用。""" - # 网易云公开接口 ids 参数实际是 JSON 数组字符串,可以传多个 - # 但为稳定性考虑,单个查询更可靠 - url = "https://music.163.com/api/v1/song/detail" - ids_json = "[" + ",".join(str(i) for i in song_ids) + "]" - params = {"ids": ids_json} - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params, headers=_NCM_HEADERS) - resp.raise_for_status() - data = resp.json() - songs = data.get("songs") or [] - by_id = {} - for s in songs: - by_id[str(s.get("id"))] = { - "id": s.get("id"), - "name": s.get("name"), - "artists": "/".join(a.get("name", "") for a in s.get("artists", [])), - "album": (s.get("album") or {}).get("name"), - "cover": (s.get("album") or {}).get("picUrl"), - "duration": s.get("duration"), - } - return [by_id.get(str(sid)) for sid in song_ids] diff --git a/src/router/xtc_recognize/recognize.py b/src/router/xtc_recognize/recognize.py deleted file mode 100644 index 601eec983bb77643f7316917ef41b7ee00c5f0a5..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/recognize.py +++ /dev/null @@ -1,74 +0,0 @@ -"""识曲主流程:buffer -> PCM -> FP -> 网易云识曲 -> 批量补详情。""" -from __future__ import annotations - -import logging -from typing import Any - -from .amr_decoder import decode_to_pcm_s16le -from .fingerprint import generate_fp -from .ncm_proxy import audio_match, get_song_detail_batch - -logger = logging.getLogger(__name__) - - -async def recognize_from_audio( - audio_bytes: bytes, - *, - duration: int = 3, - mime: str = "", -) -> dict: - """主入口。 - - Args: - audio_bytes: 原始音频字节(amr/wav/mp3/m4a 等 ffmpeg 支持的格式) - duration: 采样时长(秒) - mime: MIME 类型(仅用于日志) - - Returns: - {fp: str, list: [...], raw_match: dict} - """ - logger.info( - "[recognize] start, input=%d bytes mime=%s duration=%d", - len(audio_bytes), mime, duration, - ) - - # 1) 转码 - pcm = await decode_to_pcm_s16le(audio_bytes, duration_sec=duration, mime=mime) - - # 2) 指纹 - fp = await generate_fp(pcm) - - # 3) 识曲 - match_res = await audio_match(fp, duration) - candidates = ((match_res.get("data") or {}).get("result")) or [] - logger.info("[recognize] match candidates=%d", len(candidates)) - - if not candidates: - return {"fp": fp, "list": [], "raw_match": match_res} - - # 4) 批量补全详情 - ids = [str(c.get("song", {}).get("id")) for c in candidates if c.get("song", {}).get("id")] - details = await get_song_detail_batch(ids) if ids else [] - detail_map = {str(d["id"]): d for d in details if d} - - # 5) 组装结果,按 score 排序 - out_list = [] - for c in candidates: - song = c.get("song") or {} - sid = str(song.get("id")) if song.get("id") else "" - d = detail_map.get(sid, {}) - out_list.append({ - "id": song.get("id"), - "name": d.get("name") or song.get("name"), - "artists": d.get("artists") or "/".join( - a.get("name", "") for a in song.get("artists", []) - ), - "album": d.get("album") or (song.get("album") or {}).get("name"), - "cover": d.get("cover") or (song.get("album") or {}).get("picUrl"), - "duration": d.get("duration"), - "matchScore": c.get("score"), - "startTime": c.get("startTime"), - }) - out_list.sort(key=lambda x: (x.get("matchScore") or 0), reverse=True) - - return {"fp": fp, "list": out_list, "raw_match": match_res} diff --git a/src/router/xtc_recognize/router.py b/src/router/xtc_recognize/router.py deleted file mode 100644 index d0cfa31dfe7d04a38d3fa4aaf656955f13ffee3a..0000000000000000000000000000000000000000 --- a/src/router/xtc_recognize/router.py +++ /dev/null @@ -1,181 +0,0 @@ -"""XTC 听歌识曲 FastAPI 路由。 - -无侵入集成到 gcli2api:所有路径前缀 /xtc,不与现有路由冲突。 -""" -from __future__ import annotations - -import base64 -import logging -from typing import Any, Optional - -from fastapi import APIRouter, Query, Request -from fastapi.responses import JSONResponse - -from .ncm_proxy import get_song_detail, get_song_url -from .recognize import recognize_from_audio - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/xtc", tags=["XTC Recognize"]) - -# 可选鉴权:环境变量 XTC_API_KEY 设置后,请求头 X-API-Key 需匹配 -import os -_API_KEY = os.environ.get("XTC_API_KEY", "") - - -def _check_key(request: Request) -> Optional[JSONResponse]: - """校验 X-API-Key。未配置则放行。""" - if not _API_KEY: - return None - provided = request.headers.get("X-API-Key", "") - if provided == _API_KEY: - return None - return JSONResponse( - status_code=401, - content={"code": 401, "msg": "unauthorized"}, - ) - - -def _ok(data: Any) -> JSONResponse: - return JSONResponse( - status_code=200, - content={"code": 200, "data": data}, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-API-Key", - }, - ) - - -def _err(msg: str, status: int = 500) -> JSONResponse: - return JSONResponse( - status_code=status, - content={"code": status, "msg": msg}, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-API-Key", - }, - ) - - -@router.options("/{path:path}") -async def cors_preflight(path: str) -> JSONResponse: - return JSONResponse( - status_code=204, - content=None, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-API-Key", - "Access-Control-Max-Age": "86400", - }, - ) - - -@router.get("/health") -async def health() -> JSONResponse: - """健康检查。""" - import shutil - node_ok = shutil.which("node") is not None - ffmpeg_ok = shutil.which("ffmpeg") is not None - return _ok({ - "service": "xtc-recognize", - "node": node_ok, - "ffmpeg": ffmpeg_ok, - "auth": bool(_API_KEY), - }) - - -@router.post("/recognize") -async def recognize(request: Request) -> JSONResponse: - """识曲接口。 - - Body: JSON - { - "audio": "", - "mime": "audio/amr", # 可选 - "name": "record.amr", # 可选 - "duration": 3 # 可选,默认 3 - } - - Returns: - {code:200, data:{fp, list:[{id,name,artists,album,cover,duration,matchScore,startTime}], raw_match}} - """ - auth_err = _check_key(request) - if auth_err: - return auth_err - - try: - body = await request.json() - if not isinstance(body, dict): - return _err("invalid body", 400) - audio_b64 = body.get("audio") - if not audio_b64: - return _err("audio required", 400) - duration = int(body.get("duration") or 3) - if duration < 1 or duration > 10: - duration = 3 - mime = str(body.get("mime") or "") - name = str(body.get("name") or "") - - audio_bytes = base64.b64decode(audio_b64) - if len(audio_bytes) > 2 * 1024 * 1024: - return _err("audio too large (max 2MB)", 400) - - result = await recognize_from_audio( - audio_bytes, - duration=duration, - mime=mime, - ) - # raw_match 体积大且含敏感字段,对外只返回精简后的 list - return _ok({ - "fp": result["fp"], - "list": result["list"], - "count": len(result["list"]), - }) - except Exception as e: - logger.exception("[recognize] error") - return _err(str(e) or "recognize failed") - - -@router.get("/song/url") -async def song_url( - request: Request, - id: str = Query(...), - unblock: str = Query("false"), - level: str = Query("standard"), -) -> JSONResponse: - """获取试听 URL。 - - 公开接口仅返回 30s 试听片段(VIP 歌曲)。unblock 暂不支持。 - """ - auth_err = _check_key(request) - if auth_err: - return auth_err - try: - data = await get_song_url(id, unblock=(unblock == "true"), level=level) - return _ok(data) - except Exception as e: - logger.exception("[song/url] error") - return _err(str(e) or "get url failed") - - -@router.get("/song/detail") -async def song_detail( - request: Request, - id: str = Query(...), -) -> JSONResponse: - """获取歌曲详情。""" - auth_err = _check_key(request) - if auth_err: - return auth_err - try: - data = await get_song_detail(id) - if not data: - return _err("song not found", 404) - return _ok(data) - except Exception as e: - logger.exception("[song/detail] error") - return _err(str(e) or "get detail failed") diff --git a/src/storage/mongodb_manager.py b/src/storage/mongodb_manager.py deleted file mode 100644 index 3291c76d70413204b289c779c874521e4c9e5bfe..0000000000000000000000000000000000000000 --- a/src/storage/mongodb_manager.py +++ /dev/null @@ -1,1539 +0,0 @@ -""" -MongoDB 存储管理器 -""" - -import json -import os -import random -import time -from typing import Any, Dict, List, Optional - -from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase - -from log import log - - -class MongoDBManager: - """MongoDB 数据库管理器""" - - # 状态字段常量 - STATE_FIELDS = { - "error_codes", - "error_messages", - "disabled", - "last_success", - "user_email", - "model_cooldowns", - "preview", - "tier", - "enable_credit", - } - - @staticmethod - def _escape_model_name(model_name: str) -> str: - """ - 转义模型名中的点号,避免 MongoDB 将其解释为嵌套结构 - - Args: - model_name: 原始模型名 (如 "gemini-2.5-flash") - - Returns: - 转义后的模型名 (如 "gemini-2-5-flash") - """ - return model_name.replace(".", "-") - - def __init__(self): - self._client: Optional[AsyncIOMotorClient] = None - self._db: Optional[AsyncIOMotorDatabase] = None - self._initialized = False - - # 内存配置缓存 - 初始化时加载一次 - self._config_cache: Dict[str, Any] = {} - self._config_loaded = False - - # Redis 缓存(仅当 REDIS_URL 环境变量存在时启用) - self._redis = None - self._redis_enabled: bool = False - - async def initialize(self) -> None: - """初始化 MongoDB 连接""" - if self._initialized: - return - - try: - mongodb_uri = os.getenv("MONGODB_URI") - if not mongodb_uri: - raise ValueError("MONGODB_URI environment variable not set") - - database_name = os.getenv("MONGODB_DATABASE", "gcli2api") - - self._client = AsyncIOMotorClient(mongodb_uri) - self._db = self._client[database_name] - - # 测试连接 - await self._db.command("ping") - - # 创建索引 - await self._create_indexes() - - # 加载配置到内存 - await self._load_config_cache() - - self._initialized = True - log.info(f"MongoDB storage initialized (database: {database_name})") - - # 尝试初始化 Redis(可选) - await self._init_redis() - - except Exception as e: - log.error(f"Error initializing MongoDB: {e}") - raise - - async def _create_indexes(self): - """ - 创建索引 - """ - from pymongo import IndexModel, ASCENDING - - credentials_collection = self._db["credentials"] - antigravity_credentials_collection = self._db["antigravity_credentials"] - - # ===== Geminicli 凭证索引 ===== - geminicli_indexes = [ - # 唯一索引 - 用于所有按文件名的精确查询 - IndexModel([("filename", ASCENDING)], unique=True, name="idx_filename_unique"), - - # 复合索引 - 用于 get_next_available_credential 和 get_available_credentials_list - # 查询模式: {disabled: False} + sort by rotation_order - IndexModel( - [("disabled", ASCENDING), ("rotation_order", ASCENDING)], - name="idx_disabled_rotation" - ), - - # 单字段索引 - 用于 get_credentials_summary 的错误筛选 - IndexModel([("error_codes", ASCENDING)], name="idx_error_codes"), - - # 单字段索引 - 用于 get_duplicate_credentials_by_email 的去重查询 - IndexModel([("user_email", ASCENDING)], name="idx_user_email"), - ] - - # ===== Antigravity 凭证索引 ===== - antigravity_indexes = [ - # 唯一索引 - IndexModel([("filename", ASCENDING)], unique=True, name="idx_filename_unique"), - - # 复合索引 - 查询模式: {disabled: False} + sort by rotation_order - # 查询模式: {disabled: False} + 可选 sort by rotation_order - IndexModel( - [("disabled", ASCENDING), ("rotation_order", ASCENDING)], - name="idx_disabled_rotation" - ), - - # 单字段索引 - 错误筛选 - IndexModel([("error_codes", ASCENDING)], name="idx_error_codes"), - - # 单字段索引 - 去重查询 - IndexModel([("user_email", ASCENDING)], name="idx_user_email"), - ] - - # 并行创建新索引 - try: - await credentials_collection.create_indexes(geminicli_indexes) - await antigravity_credentials_collection.create_indexes(antigravity_indexes) - log.debug("MongoDB indexes created successfully") - except Exception as e: - # 如果索引已存在,忽略错误 - if "already exists" not in str(e).lower(): - log.warning(f"Index creation warning: {e}") - - async def _load_config_cache(self): - """加载配置到内存缓存(仅在初始化时调用一次)""" - if self._config_loaded: - return - - try: - config_collection = self._db["config"] - cursor = config_collection.find({}) - - async for doc in cursor: - self._config_cache[doc["key"]] = doc.get("value") - - self._config_loaded = True - log.debug(f"Loaded {len(self._config_cache)} config items into cache") - - except Exception as e: - log.error(f"Error loading config cache: {e}") - self._config_cache = {} - - # ============ Redis 缓存(可选,仅当 REDIS_URL 存在时启用)============ - - async def _init_redis(self) -> None: - """初始化 Redis 连接并重建凭证池缓存(若 REDIS_URL 存在)""" - redis_url = os.getenv("REDIS_URL") - if not redis_url: - return - - try: - import redis.asyncio as aioredis # type: ignore - except ImportError: - log.warning("redis package not installed, Redis cache disabled. Run: pip install redis") - return - - try: - self._redis = aioredis.from_url(redis_url, decode_responses=True) - await self._redis.ping() - self._redis_enabled = True - log.info("Redis connected, rebuilding credential pool cache...") - - # 并行重建两个 mode 的缓存及配置缓存 - import asyncio - await asyncio.gather( - self._rebuild_redis_cache("geminicli"), - self._rebuild_redis_cache("antigravity"), - self._load_config_to_redis(), - ) - log.info("Redis credential pool cache ready") - except Exception as e: - log.warning(f"Redis init failed, falling back to MongoDB-only mode: {e}") - self._redis = None - self._redis_enabled = False - - # ---- Redis key 工具 ---- - - def _rk_avail(self, mode: str) -> str: - """所有未禁用凭证的 Redis Set key""" - return f"gcli:avail:{mode}" - - def _rk_tier(self, mode: str, tier: str) -> str: - """按 tier 分桶的未禁用凭证 Redis Set key""" - return f"gcli:tier:{mode}:{tier}" - - def _rk_preview(self, mode: str) -> str: - """preview=True 凭证的 Redis Set key""" - return f"gcli:preview:{mode}" - - def _rk_cd(self, mode: str, filename: str, escaped_model: str) -> str: - """模型冷却 Redis key(带 TTL)""" - return f"gcli:cd:{mode}:{filename}:{escaped_model}" - - # ---- Redis 缓存维护 ---- - - async def _rebuild_redis_cache(self, mode: str) -> None: - """ - 从 MongoDB 重建指定 mode 的 Redis 凭证池缓存。 - - 使用临时 key + RENAME 原子替换 - """ - if not self._redis: - return - try: - collection = self._db[self._get_collection_name(mode)] - # 同时投影 model_cooldowns、tier、preview,以便重建缓存 - projection: Dict[str, Any] = {"filename": 1, "disabled": 1, "model_cooldowns": 1, "tier": 1, "preview": 1, "_id": 0} - - avail: List[str] = [] - tier_buckets: Dict[str, List[str]] = {} # tier -> [filename, ...] - preview_members: List[str] = [] - cooldown_entries: List[tuple] = [] # (cd_key, ttl_seconds, value) - current_time = time.time() - - async for doc in collection.find({}, projection=projection): - if not doc.get("disabled", False): - filename = doc["filename"] - avail.append(filename) - - # 按 tier 分桶 - tier = doc.get("tier") or "pro" - tier_buckets.setdefault(tier, []).append(filename) - - # preview 分桶(仅 geminicli) - if mode == "geminicli" and doc.get("preview", True): - preview_members.append(filename) - - # 收集未过期的模型冷却,重建 Redis TTL Key - model_cooldowns = doc.get("model_cooldowns") or {} - for escaped_model, cooldown_until in model_cooldowns.items(): - if isinstance(cooldown_until, (int, float)) and cooldown_until > current_time: - ttl = int(cooldown_until - current_time) - if ttl > 0: - cd_key = self._rk_cd(mode, filename, escaped_model) - cooldown_entries.append((cd_key, ttl, str(cooldown_until))) - - tmp_avail = self._rk_avail(mode) + ":tmp" - - pipe = self._redis.pipeline() - # 先写临时 key(此时正式 key 仍完整可用) - pipe.delete(tmp_avail) - if avail: - pipe.sadd(tmp_avail, *avail) - await pipe.execute() - - # RENAME 是原子操作:瞬间切换,不存在空窗 - pipe2 = self._redis.pipeline() - if avail: - pipe2.rename(tmp_avail, self._rk_avail(mode)) - else: - pipe2.delete(self._rk_avail(mode)) - pipe2.delete(tmp_avail) - await pipe2.execute() - - # 重建 tier 分桶 Set(原子替换) - all_tiers = ("free", "pro", "ultra") - pipe3 = self._redis.pipeline() - for tier in all_tiers: - tier_key = self._rk_tier(mode, tier) - tmp_tier_key = tier_key + ":tmp" - pipe3.delete(tmp_tier_key) - members = tier_buckets.get(tier, []) - if members: - pipe3.sadd(tmp_tier_key, *members) - await pipe3.execute() - - pipe4 = self._redis.pipeline() - for tier in all_tiers: - tier_key = self._rk_tier(mode, tier) - tmp_tier_key = tier_key + ":tmp" - members = tier_buckets.get(tier, []) - if members: - pipe4.rename(tmp_tier_key, tier_key) - else: - pipe4.delete(tier_key) - pipe4.delete(tmp_tier_key) - await pipe4.execute() - - # 重建 preview 分桶(仅 geminicli) - preview_key = self._rk_preview(mode) - tmp_preview_key = preview_key + ":tmp" - pipe5 = self._redis.pipeline() - pipe5.delete(tmp_preview_key) - if preview_members: - pipe5.sadd(tmp_preview_key, *preview_members) - await pipe5.execute() - pipe6 = self._redis.pipeline() - if preview_members: - pipe6.rename(tmp_preview_key, preview_key) - else: - pipe6.delete(preview_key) - pipe6.delete(tmp_preview_key) - await pipe6.execute() - - # 批量恢复未过期的模型冷却 TTL Key - if cooldown_entries: - pipe7 = self._redis.pipeline() - for cd_key, ttl, value in cooldown_entries: - pipe7.setex(cd_key, ttl, value) - await pipe7.execute() - - log.debug( - f"Redis cache rebuilt [{mode}]: {len(avail)} avail, " - f"tiers={{{', '.join(f'{t}:{len(tier_buckets.get(t, []))}' for t in all_tiers)}}}, " - f"preview={len(preview_members)}, " - f"{len(cooldown_entries)} cooldown key(s) restored" - ) - except Exception as e: - log.warning(f"Redis rebuild cache error [{mode}]: {e}") - - async def _redis_add_cred(self, mode: str, filename: str, tier: str = "pro", preview: bool = True) -> None: - """将凭证加入 Redis 可用池及对应 tier 分桶、preview 分桶""" - if not self._redis_enabled: - return - try: - pipe = self._redis.pipeline() - pipe.sadd(self._rk_avail(mode), filename) - pipe.sadd(self._rk_tier(mode, tier), filename) - if mode == "geminicli" and preview: - pipe.sadd(self._rk_preview(mode), filename) - await pipe.execute() - except Exception as e: - log.warning(f"Redis add_cred error: {e}") - - async def _redis_remove_cred(self, mode: str, filename: str, tier: Optional[str] = None) -> None: - """从 Redis 所有池中移除凭证""" - if not self._redis_enabled: - return - try: - pipe = self._redis.pipeline() - pipe.srem(self._rk_avail(mode), filename) - if tier: - pipe.srem(self._rk_tier(mode, tier), filename) - else: - # tier 未知时从所有分桶中移除 - for t in ("free", "pro", "ultra"): - pipe.srem(self._rk_tier(mode, t), filename) - pipe.srem(self._rk_preview(mode), filename) - await pipe.execute() - except Exception as e: - log.warning(f"Redis remove_cred error: {e}") - - async def _redis_sync_cred(self, mode: str, filename: str, disabled: bool, tier: str = "pro", preview: bool = True) -> None: - """根据最新状态同步单个凭证在 Redis 中的集合成员""" - if not self._redis_enabled: - return - try: - pipe = self._redis.pipeline() - if disabled: - pipe.srem(self._rk_avail(mode), filename) - for t in ("free", "pro", "ultra"): - pipe.srem(self._rk_tier(mode, t), filename) - pipe.srem(self._rk_preview(mode), filename) - else: - pipe.sadd(self._rk_avail(mode), filename) - pipe.sadd(self._rk_tier(mode, tier), filename) - if mode == "geminicli" and preview: - pipe.sadd(self._rk_preview(mode), filename) - else: - pipe.srem(self._rk_preview(mode), filename) - await pipe.execute() - except Exception as e: - log.warning(f"Redis sync_cred error: {e}") - - async def _get_next_available_from_redis( - self, mode: str, model_name: Optional[str], exclude_free_tier: bool = False, preview_only: bool = False - ) -> Optional[tuple]: - """ - Redis 快速路径:随机取候选凭证,跳过冷却中的,返回 (filename, credential_data)。 - 失败或池为空时返回 None,由调用方降级到 MongoDB。 - """ - try: - # 选择候选池优先级:preview_only > exclude_free_tier > 全量池 - if preview_only and exclude_free_tier: - # preview 且非 free:preview ∩ (pro ∪ ultra) - preview_set = await self._redis.smembers(self._rk_preview(mode)) - pro_members = await self._redis.smembers(self._rk_tier(mode, "pro")) - ultra_members = await self._redis.smembers(self._rk_tier(mode, "ultra")) - non_free = pro_members | ultra_members - all_candidates = list(preview_set & non_free) - if not all_candidates: - log.debug(f"[Redis MISS] mode={mode} preview+non-free: no candidates, fallback to MongoDB") - return None - sample_size = min(len(all_candidates), 10) - candidates = random.sample(all_candidates, sample_size) - elif preview_only: - preview_key = self._rk_preview(mode) - preview_size = await self._redis.scard(preview_key) - if preview_size == 0: - log.debug(f"[Redis MISS] mode={mode} preview_only: pool empty, fallback to MongoDB") - return None - sample_size = min(preview_size, 10) - candidates = await self._redis.srandmember(preview_key, sample_size) - if not candidates: - return None - elif exclude_free_tier: - pro_members = await self._redis.smembers(self._rk_tier(mode, "pro")) - ultra_members = await self._redis.smembers(self._rk_tier(mode, "ultra")) - all_candidates = list(pro_members | ultra_members) - if not all_candidates: - log.debug(f"[Redis MISS] mode={mode} exclude_free: no non-free creds, fallback to MongoDB") - return None - sample_size = min(len(all_candidates), 10) - candidates = random.sample(all_candidates, sample_size) - else: - pool_key = self._rk_avail(mode) - pool_size = await self._redis.scard(pool_key) - if pool_size == 0: - log.debug(f"[Redis MISS] mode={mode} pool_key={pool_key}: pool empty, fallback to MongoDB") - return None - sample_size = min(pool_size, 10) - candidates = await self._redis.srandmember(pool_key, sample_size) - if not candidates: - return None - - # 过滤冷却中的凭证 - if model_name: - escaped = self._escape_model_name(model_name) - for filename in candidates: - cd_key = self._rk_cd(mode, filename, escaped) - if not await self._redis.exists(cd_key): - credential_data = await self.get_credential(filename, mode) - if mode == "antigravity": - state = await self.get_credential_state(filename, mode) - credential_data = credential_data or {} - credential_data["enable_credit"] = bool(state.get("enable_credit", False)) - log.debug(f"[Redis HIT] mode={mode} model={model_name} -> {filename}") - return filename, credential_data - # 所有候选都在冷却中,降级到 MongoDB - log.debug(f"[Redis MISS] mode={mode} model={model_name}: all {len(candidates)} candidates in cooldown, fallback to MongoDB") - return None - else: - filename = candidates[0] - credential_data = await self.get_credential(filename, mode) - if mode == "antigravity": - state = await self.get_credential_state(filename, mode) - credential_data = credential_data or {} - credential_data["enable_credit"] = bool(state.get("enable_credit", False)) - log.debug(f"[Redis HIT] mode={mode} -> {filename}") - return filename, credential_data - except Exception as e: - log.warning(f"Redis get_next_available error: {e}") - return None - - async def close(self) -> None: - """关闭 MongoDB 连接""" - if self._redis: - await self._redis.aclose() - self._redis = None - self._redis_enabled = False - if self._client: - self._client.close() - self._client = None - self._db = None - self._initialized = False - log.debug("MongoDB storage closed") - - def _ensure_initialized(self): - """确保已初始化""" - if not self._initialized: - raise RuntimeError("MongoDB manager not initialized") - - def _get_collection_name(self, mode: str) -> str: - """根据 mode 获取对应的集合名""" - if mode == "antigravity": - return "antigravity_credentials" - elif mode == "geminicli": - return "credentials" - else: - raise ValueError(f"Invalid mode: {mode}. Must be 'geminicli' or 'antigravity'") - - # ============ SQL 方法 ============ - - async def get_next_available_credential( - self, mode: str = "geminicli", model_name: Optional[str] = None - ) -> Optional[tuple[str, Dict[str, Any]]]: - """ - 随机获取一个可用凭证(负载均衡) - - 未禁用 - - 如果提供了 model_name,还会检查模型级冷却 - - 随机选择 - - Args: - mode: 凭证模式 ("geminicli" 或 "antigravity") - model_name: 完整模型名(如 "gemini-2.0-flash-exp") - - Note: - - 开启 Redis 时:利用 Redis Set 随机选凭证 + TTL key 判断冷却 - - 未开启 Redis 时:使用 count + random skip + limit(1) - """ - self._ensure_initialized() - - # Redis 快速路径:根据模型名派生过滤标志,直接在 Redis 分桶中筛选 - if self._redis_enabled: - model_lower = model_name.lower() if model_name else "" - exclude_free = mode == "geminicli" and "pro" in model_lower - preview_only = mode == "geminicli" and "preview" in model_lower - result = await self._get_next_available_from_redis( - mode, model_name, exclude_free_tier=exclude_free, preview_only=preview_only - ) - if result is not None: - return result - # result 为 None:池为空或所有候选都冷却中,降级到 MongoDB 以扩大样本空间 - log.debug(f"[MongoDB fallback] mode={mode} model={model_name}") - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - current_time = time.time() - - # 构建普通查询(避免 $sample 聚合导致全集合扫描) - match_query: Dict[str, Any] = {"disabled": False} - - # pro 模型只允许非 free tier 凭证 - if mode == "geminicli" and model_name and "pro" in model_name.lower(): - match_query["tier"] = {"$ne": "free"} - - # preview 模型只允许 preview=True 的凭证 - if mode == "geminicli" and model_name and "preview" in model_name.lower(): - match_query["preview"] = True - - # 冷却检查:直接用 MongoDB 查询表达,无需 $addFields - if model_name: - escaped_model_name = self._escape_model_name(model_name) - field = f"model_cooldowns.{escaped_model_name}" - match_query["$or"] = [ - {field: {"$exists": False}}, - {field: {"$lte": current_time}}, - ] - - # 统计符合条件的凭证总数(走索引,极快) - count = await collection.count_documents(match_query) - if count == 0: - return None - - # 随机偏移 + limit(1),替代 $sample,避免全集合随机排序 - skip_n = random.randint(0, count - 1) - projection = {"filename": 1, "credential_data": 1, "enable_credit": 1, "_id": 0} - docs = await collection.find(match_query, projection).skip(skip_n).limit(1).to_list(1) - - if docs: - doc = docs[0] - credential_data = doc.get("credential_data") or {} - if mode == "antigravity": - credential_data["enable_credit"] = bool(doc.get("enable_credit", False)) - return doc["filename"], credential_data - - return None - - except Exception as e: - log.error(f"Error getting next available credential (mode={mode}, model_name={model_name}): {e}") - return None - - async def get_available_credentials_list(self, mode: str = "geminicli") -> List[str]: - """ - 获取所有可用凭证列表 - - 未禁用 - - 按轮换顺序排序 - """ - self._ensure_initialized() - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - pipeline = [ - {"$match": {"disabled": False}}, - {"$sort": {"rotation_order": 1}}, - {"$project": {"filename": 1, "_id": 0}} - ] - - docs = await collection.aggregate(pipeline).to_list(length=None) - return [doc["filename"] for doc in docs] - - except Exception as e: - log.error(f"Error getting available credentials list (mode={mode}): {e}") - return [] - - # ============ StorageBackend 协议方法 ============ - - async def store_credential(self, filename: str, credential_data: Dict[str, Any], mode: str = "geminicli") -> bool: - """存储或更新凭证""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - current_ts = time.time() - - # 使用 upsert + $setOnInsert - # 如果文档存在,只更新 credential_data 和 updated_at - # 如果文档不存在,设置所有默认字段 - - # 先尝试更新现有文档 - result = await collection.update_one( - {"filename": filename}, - { - "$set": { - "credential_data": credential_data, - "updated_at": current_ts, - } - } - ) - - # 如果没有匹配到(新凭证),需要插入 - if result.matched_count == 0: - # 获取下一个 rotation_order - pipeline = [ - {"$group": {"_id": None, "max_order": {"$max": "$rotation_order"}}}, - {"$project": {"_id": 0, "next_order": {"$add": ["$max_order", 1]}}} - ] - - result_list = await collection.aggregate(pipeline).to_list(length=1) - next_order = result_list[0]["next_order"] if result_list else 0 - - # 插入新凭证(使用 insert_one,因为我们已经确认不存在) - try: - new_credential = { - "filename": filename, - "credential_data": credential_data, - "disabled": False, - "error_codes": [], - "error_messages": [], - "last_success": current_ts, - "user_email": None, - "model_cooldowns": {}, - "preview": True, - "tier": "pro", - "rotation_order": next_order, - "call_count": 0, - "created_at": current_ts, - "updated_at": current_ts, - } - - if mode == "antigravity": - new_credential["enable_credit"] = False - - await collection.insert_one(new_credential) - # 新凭证插入成功,添加到 Redis 可用池 - await self._redis_add_cred(mode, filename) - except Exception as insert_error: - # 处理并发插入导致的重复键错误 - if "duplicate key" in str(insert_error).lower(): - # 重试更新(已存在的凭证,无需更新 Redis) - await collection.update_one( - {"filename": filename}, - {"$set": {"credential_data": credential_data, "updated_at": current_ts}} - ) - else: - raise - - log.debug(f"Stored credential: {filename} (mode={mode})") - return True - - except Exception as e: - log.error(f"Error storing credential {filename}: {e}") - return False - - async def get_credential(self, filename: str, mode: str = "geminicli") -> Optional[Dict[str, Any]]: - """获取凭证数据""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 精确匹配,只投影需要的字段 - doc = await collection.find_one( - {"filename": filename}, - {"credential_data": 1, "_id": 0} - ) - if doc: - return doc.get("credential_data") - - return None - - except Exception as e: - log.error(f"Error getting credential {filename}: {e}") - return None - - async def list_credentials(self, mode: str = "geminicli") -> List[str]: - """列出所有凭证文件名""" - self._ensure_initialized() - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 使用聚合管道 - pipeline = [ - {"$sort": {"rotation_order": 1}}, - {"$project": {"filename": 1, "_id": 0}} - ] - - docs = await collection.aggregate(pipeline).to_list(length=None) - return [doc["filename"] for doc in docs] - - except Exception as e: - log.error(f"Error listing credentials: {e}") - return [] - - async def delete_credential(self, filename: str, mode: str = "geminicli") -> bool: - """删除凭证""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 精确匹配删除 - result = await collection.delete_one({"filename": filename}) - deleted_count = result.deleted_count - - if deleted_count > 0: - # 从 Redis 池中移除 - await self._redis_remove_cred(mode, filename) - log.debug(f"Deleted {deleted_count} credential(s): {filename} (mode={mode})") - return True - else: - log.warning(f"No credential found to delete: {filename} (mode={mode})") - return False - - except Exception as e: - log.error(f"Error deleting credential {filename}: {e}") - return False - - async def get_duplicate_credentials_by_email(self, mode: str = "geminicli") -> Dict[str, Any]: - """ - 获取按邮箱分组的重复凭证信息(只查询邮箱和文件名,不加载完整凭证数据) - 用于去重操作 - - Args: - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 包含 email_groups(邮箱分组)、duplicate_count(重复数量)、no_email_count(无邮箱数量)的字典 - """ - self._ensure_initialized() - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 使用聚合管道,只查询 filename 和 user_email 字段 - pipeline = [ - { - "$project": { - "filename": 1, - "user_email": 1, - "_id": 0 - } - }, - { - "$sort": {"filename": 1} - } - ] - - docs = await collection.aggregate(pipeline).to_list(length=None) - - # 按邮箱分组 - email_to_files = {} - no_email_files = [] - - for doc in docs: - filename = doc.get("filename") - user_email = doc.get("user_email") - - if user_email: - if user_email not in email_to_files: - email_to_files[user_email] = [] - email_to_files[user_email].append(filename) - else: - no_email_files.append(filename) - - # 找出重复的邮箱组 - duplicate_groups = [] - total_duplicate_count = 0 - - for email, files in email_to_files.items(): - if len(files) > 1: - # 保留第一个文件,其他为重复 - duplicate_groups.append({ - "email": email, - "kept_file": files[0], - "duplicate_files": files[1:], - "duplicate_count": len(files) - 1, - }) - total_duplicate_count += len(files) - 1 - - return { - "email_groups": email_to_files, - "duplicate_groups": duplicate_groups, - "duplicate_count": total_duplicate_count, - "no_email_files": no_email_files, - "no_email_count": len(no_email_files), - "unique_email_count": len(email_to_files), - "total_count": len(docs), - } - - except Exception as e: - log.error(f"Error getting duplicate credentials by email: {e}") - return { - "email_groups": {}, - "duplicate_groups": [], - "duplicate_count": 0, - "no_email_files": [], - "no_email_count": 0, - "unique_email_count": 0, - "total_count": 0, - } - - async def update_credential_state( - self, filename: str, state_updates: Dict[str, Any], mode: str = "geminicli" - ) -> bool: - """更新凭证状态""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 过滤只更新状态字段 - valid_updates = { - k: v for k, v in state_updates.items() if k in self.STATE_FIELDS - } - - if mode != "antigravity": - valid_updates.pop("enable_credit", None) - - if not valid_updates: - return True - - valid_updates["updated_at"] = time.time() - - # 精确匹配更新 - result = await collection.update_one( - {"filename": filename}, {"$set": valid_updates} - ) - updated_count = result.modified_count + result.matched_count - - # 如果 disabled 发生变化,同步 Redis 池成员关系 - if self._redis_enabled and "disabled" in valid_updates: - if valid_updates["disabled"]: - # 直接禁用:从集合中移除 - await self._redis_remove_cred(mode, filename) - else: - # 重新启用:需要读取当前 tier/preview 以正确放入分桶 - doc = await collection.find_one( - {"filename": filename}, - projection={"tier": 1, "preview": 1, "_id": 0}, - ) - tier_val = (doc or {}).get("tier", "pro") or "pro" - preview_val = (doc or {}).get("preview", True) - await self._redis_sync_cred(mode, filename, disabled=False, tier=tier_val, preview=preview_val) - elif self._redis_enabled and ("tier" in valid_updates or "preview" in valid_updates): - # tier 或 preview 更新:重新同步分桶(只在凭证未禁用时) - doc = await collection.find_one( - {"filename": filename}, - projection={"disabled": 1, "tier": 1, "preview": 1, "_id": 0}, - ) - if doc and not doc.get("disabled", False): - tier_val = doc.get("tier", "pro") or "pro" - preview_val = doc.get("preview", True) - await self._redis_sync_cred(mode, filename, disabled=False, tier=tier_val, preview=preview_val) - - return updated_count > 0 - - except Exception as e: - log.error(f"Error updating credential state {filename}: {e}") - return False - - async def get_credential_state(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """获取凭证状态(不包含error_messages)""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - current_time = time.time() - - # 精确匹配 - doc = await collection.find_one({"filename": filename}) - - if doc: - model_cooldowns = doc.get("model_cooldowns", {}) - # 过滤掉损坏的数据(dict类型)和过期的冷却 - if model_cooldowns: - model_cooldowns = { - k: v for k, v in model_cooldowns.items() - if isinstance(v, (int, float)) and v > current_time - } - - state = { - "disabled": doc.get("disabled", False), - "error_codes": doc.get("error_codes", []), - "last_success": doc.get("last_success", current_time), - "user_email": doc.get("user_email"), - "model_cooldowns": model_cooldowns, - "preview": doc.get("preview", True), - "tier": doc.get("tier", "pro"), - } - if mode == "antigravity": - state["enable_credit"] = doc.get("enable_credit", False) - return state - - # 返回默认状态 - default_state = { - "disabled": False, - "error_codes": [], - "last_success": current_time, - "user_email": None, - "model_cooldowns": {}, - "preview": True, - "tier": "pro", - } - if mode == "antigravity": - default_state["enable_credit"] = False - return default_state - - except Exception as e: - log.error(f"Error getting credential state {filename}: {e}") - return {} - - async def get_all_credential_states(self, mode: str = "geminicli") -> Dict[str, Dict[str, Any]]: - """获取所有凭证状态(不包含error_messages)""" - self._ensure_initialized() - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 使用投影只获取需要的字段(不包含error_messages) - projection = { - "filename": 1, - "disabled": 1, - "error_codes": 1, - "last_success": 1, - "user_email": 1, - "model_cooldowns": 1, - "preview": 1, - "tier": 1, - "enable_credit": 1, - "_id": 0 - } - - cursor = collection.find({}, projection=projection) - - states = {} - current_time = time.time() - - async for doc in cursor: - filename = doc["filename"] - model_cooldowns = doc.get("model_cooldowns", {}) - - # 自动过滤掉已过期的模型CD - if model_cooldowns: - model_cooldowns = { - k: v for k, v in model_cooldowns.items() - if isinstance(v, (int, float)) and v > current_time - } - - state = { - "disabled": doc.get("disabled", False), - "error_codes": doc.get("error_codes", []), - "last_success": doc.get("last_success", time.time()), - "user_email": doc.get("user_email"), - "model_cooldowns": model_cooldowns, - "preview": doc.get("preview", True), - "tier": doc.get("tier", "pro"), - } - if mode == "antigravity": - state["enable_credit"] = doc.get("enable_credit", False) - states[filename] = state - - return states - - except Exception as e: - log.error(f"Error getting all credential states: {e}") - return {} - - async def get_credentials_summary( - self, - offset: int = 0, - limit: Optional[int] = None, - status_filter: str = "all", - mode: str = "geminicli", - error_code_filter: Optional[str] = None, - cooldown_filter: Optional[str] = None, - preview_filter: Optional[str] = None, - tier_filter: Optional[str] = None - ) -> Dict[str, Any]: - """ - 获取凭证的摘要信息(不包含完整凭证数据)- 支持分页和状态筛选 - - Args: - offset: 跳过的记录数(默认0) - limit: 返回的最大记录数(None表示返回所有) - status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用) - mode: 凭证模式 ("geminicli" 或 "antigravity") - error_code_filter: 错误码筛选(格式如"400"或"403",筛选包含该错误码的凭证) - cooldown_filter: 冷却状态筛选("in_cooldown"=冷却中, "no_cooldown"=未冷却) - preview_filter: Preview筛选("preview"=支持preview, "no_preview"=不支持preview,仅geminicli模式有效) - tier_filter: tier筛选("free", "pro", "ultra") - - Returns: - 包含 items(凭证列表)、total(总数)、offset、limit 的字典 - """ - self._ensure_initialized() - - try: - # 根据 mode 选择集合名 - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 构建查询条件 - query = {} - if status_filter == "enabled": - query["disabled"] = False - elif status_filter == "disabled": - query["disabled"] = True - - # 错误码筛选 - 兼容存储为数字或字符串的情况 - if error_code_filter and str(error_code_filter).strip().lower() != "all": - if str(error_code_filter).strip().lower() == "none": - # 筛选无错误的凭证:error_codes 为空数组、不存在、或为 null - query["$or"] = [ - {"error_codes": {"$exists": False}}, - {"error_codes": None}, - {"error_codes": []}, - {"error_codes": "[]"}, - ] - else: - filter_value = str(error_code_filter).strip() - query_values = [filter_value] - try: - query_values.append(int(filter_value)) - except ValueError: - pass - query["error_codes"] = {"$in": query_values} - - # 计算全局统计数据(不受筛选条件影响) - global_stats = {"total": 0, "normal": 0, "disabled": 0} - stats_pipeline = [ - { - "$group": { - "_id": "$disabled", - "count": {"$sum": 1} - } - } - ] - - stats_result = await collection.aggregate(stats_pipeline).to_list(length=10) - for item in stats_result: - count = item["count"] - global_stats["total"] += count - if item["_id"]: - global_stats["disabled"] = count - else: - global_stats["normal"] = count - - # 获取所有匹配的文档(用于冷却筛选,因为需要在Python中判断) - projection = { - "filename": 1, - "disabled": 1, - "error_codes": 1, - "last_success": 1, - "user_email": 1, - "rotation_order": 1, - "model_cooldowns": 1, - "preview": 1, - "tier": 1, - "enable_credit": 1, - "_id": 0 - } - - cursor = collection.find(query, projection=projection).sort("rotation_order", 1) - - all_summaries = [] - current_time = time.time() - - async for doc in cursor: - model_cooldowns = doc.get("model_cooldowns", {}) - - # 自动过滤掉已过期的模型CD - active_cooldowns = {} - if model_cooldowns: - active_cooldowns = { - k: v for k, v in model_cooldowns.items() - if isinstance(v, (int, float)) and v > current_time - } - - summary = { - "filename": doc["filename"], - "disabled": doc.get("disabled", False), - "error_codes": doc.get("error_codes", []), - "last_success": doc.get("last_success", current_time), - "user_email": doc.get("user_email"), - "rotation_order": doc.get("rotation_order", 0), - "model_cooldowns": active_cooldowns, - "preview": doc.get("preview", True), - "tier": doc.get("tier", "pro"), - } - - if mode == "antigravity": - summary["enable_credit"] = bool(doc.get("enable_credit", False)) - - if mode == "geminicli" and preview_filter: - preview_value = summary.get("preview", True) - if preview_filter == "preview" and not preview_value: - continue - if preview_filter == "no_preview" and preview_value: - continue - - # 应用tier筛选 - if tier_filter and tier_filter in ("free", "pro", "ultra"): - if summary["tier"] != tier_filter: - continue - - # 应用冷却筛选 - if cooldown_filter == "in_cooldown": - # 只保留有冷却的凭证 - if active_cooldowns: - all_summaries.append(summary) - elif cooldown_filter == "no_cooldown": - # 只保留没有冷却的凭证 - if not active_cooldowns: - all_summaries.append(summary) - else: - # 不筛选冷却状态 - all_summaries.append(summary) - - # 应用分页 - total_count = len(all_summaries) - if limit is not None: - summaries = all_summaries[offset:offset + limit] - else: - summaries = all_summaries[offset:] - - return { - "items": summaries, - "total": total_count, - "offset": offset, - "limit": limit, - "stats": global_stats, - } - - except Exception as e: - log.error(f"Error getting credentials summary: {e}") - return { - "items": [], - "total": 0, - "offset": offset, - "limit": limit, - "stats": {"total": 0, "normal": 0, "disabled": 0}, - } - - # ============ 配置管理(内存缓存 + 可选 Redis)============ - - def _rk_config(self, key: str) -> str: - """配置项的 Redis key""" - return f"gcli:config:{key}" - - def _rk_config_all(self) -> str: - """所有配置的 Redis Hash key""" - return "gcli:config" - - async def _load_config_to_redis(self) -> None: - """将所有配置从 MongoDB 同步到 Redis Hash""" - if not self._redis_enabled: - return - try: - config_collection = self._db["config"] - cursor = config_collection.find({}) - mapping = {} - async for doc in cursor: - mapping[doc["key"]] = json.dumps(doc.get("value")) - pipe = self._redis.pipeline() - pipe.delete(self._rk_config_all()) - if mapping: - pipe.hset(self._rk_config_all(), mapping=mapping) - await pipe.execute() - log.debug(f"Synced {len(mapping)} config items to Redis") - except Exception as e: - log.warning(f"Failed to sync config to Redis: {e}") - - async def set_config(self, key: str, value: Any) -> bool: - """设置配置(写入数据库;Redis 启用时写 Redis,否则更新内存缓存)""" - self._ensure_initialized() - - try: - config_collection = self._db["config"] - await config_collection.update_one( - {"key": key}, - {"$set": {"value": value, "updated_at": time.time()}}, - upsert=True, - ) - - if self._redis_enabled: - try: - await self._redis.hset(self._rk_config_all(), key, json.dumps(value)) - except Exception as e: - log.warning(f"Redis config set error for key={key}: {e}") - else: - self._config_cache[key] = value - - return True - - except Exception as e: - log.error(f"Error setting config {key}: {e}") - return False - - async def reload_config_cache(self): - """重新加载配置缓存(在批量修改配置后调用)""" - self._ensure_initialized() - if self._redis_enabled: - await self._load_config_to_redis() - else: - self._config_loaded = False - await self._load_config_cache() - log.info("Config cache reloaded from database") - - async def get_config(self, key: str, default: Any = None) -> Any: - """获取配置(Redis 启用时从 Redis 读取,否则从内存缓存)""" - self._ensure_initialized() - - if self._redis_enabled: - try: - raw = await self._redis.hget(self._rk_config_all(), key) - if raw is not None: - return json.loads(raw) - return default - except Exception as e: - log.warning(f"Redis config get error for key={key}: {e}") - return default - - return self._config_cache.get(key, default) - - async def get_all_config(self) -> Dict[str, Any]: - """获取所有配置(Redis 启用时从 Redis 读取,否则从内存缓存)""" - self._ensure_initialized() - - if self._redis_enabled: - try: - raw_map = await self._redis.hgetall(self._rk_config_all()) - return {k: json.loads(v) for k, v in raw_map.items()} - except Exception as e: - log.warning(f"Redis config getall error: {e}") - return {} - - return self._config_cache.copy() - - async def delete_config(self, key: str) -> bool: - """删除配置""" - self._ensure_initialized() - - try: - config_collection = self._db["config"] - result = await config_collection.delete_one({"key": key}) - - if self._redis_enabled: - try: - await self._redis.hdel(self._rk_config_all(), key) - except Exception as e: - log.warning(f"Redis config delete error for key={key}: {e}") - else: - self._config_cache.pop(key, None) - - return result.deleted_count > 0 - - except Exception as e: - log.error(f"Error deleting config {key}: {e}") - return False - - async def get_credential_errors(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """ - 专门获取凭证的错误信息(包含 error_codes 和 error_messages) - - Args: - filename: 凭证文件名 - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 包含 error_codes 和 error_messages 的字典 - """ - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 精确匹配 - doc = await collection.find_one( - {"filename": filename}, - {"error_codes": 1, "error_messages": 1, "_id": 0} - ) - - if doc: - return { - "filename": filename, - "error_codes": doc.get("error_codes", []), - "error_messages": doc.get("error_messages", []), - } - - # 凭证不存在,返回空错误信息 - return { - "filename": filename, - "error_codes": [], - "error_messages": [], - } - - except Exception as e: - log.error(f"Error getting credential errors {filename}: {e}") - return { - "filename": filename, - "error_codes": [], - "error_messages": [], - "error": str(e) - } - - # ============ 模型级冷却管理 ============ - - async def set_model_cooldown( - self, - filename: str, - model_name: str, - cooldown_until: Optional[float], - mode: str = "geminicli" - ) -> bool: - """ - 设置特定模型的冷却时间 - - Args: - filename: 凭证文件名 - model_name: 模型名(完整模型名,如 "gemini-2.0-flash-exp") - cooldown_until: 冷却截止时间戳(None 表示清除冷却) - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 是否成功 - """ - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - # 转义模型名中的点号 - escaped_model_name = self._escape_model_name(model_name) - - # 使用原子操作直接更新,避免竞态条件 - if cooldown_until is None: - # 删除指定模型的冷却 - result = await collection.update_one( - {"filename": filename}, - { - "$unset": {f"model_cooldowns.{escaped_model_name}": ""}, - "$set": {"updated_at": time.time()} - } - ) - else: - # 设置冷却时间 - result = await collection.update_one( - {"filename": filename}, - { - "$set": { - f"model_cooldowns.{escaped_model_name}": cooldown_until, - "updated_at": time.time() - } - } - ) - - if result.matched_count == 0: - log.warning(f"Credential {filename} not found") - return False - - # 同步写入 Redis TTL key - if self._redis_enabled: - cd_key = self._rk_cd(mode, filename, escaped_model_name) - if cooldown_until is None: - await self._redis.delete(cd_key) - else: - ttl = int(cooldown_until - time.time()) - if ttl > 0: - await self._redis.setex(cd_key, ttl, str(cooldown_until)) - else: - # 冷却已经过期,确保清除 - await self._redis.delete(cd_key) - - log.debug(f"Set model cooldown: {filename}, model_name={model_name}, cooldown_until={cooldown_until}") - return True - - except Exception as e: - log.error(f"Error setting model cooldown for {filename}: {e}") - return False - - async def clear_all_model_cooldowns( - self, - filename: str, - mode: str = "geminicli" - ) -> bool: - """ - 清除某个凭证的所有模型冷却时间 - - Args: - filename: 凭证文件名 - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 是否成功 - """ - self._ensure_initialized() - - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - - doc = await collection.find_one( - {"filename": filename}, - {"model_cooldowns": 1, "_id": 0} - ) - if not doc: - log.warning(f"Credential {filename} not found") - return False - - model_cooldowns = doc.get("model_cooldowns") or {} - - await collection.update_one( - {"filename": filename}, - { - "$set": { - "model_cooldowns": {}, - "updated_at": time.time(), - } - } - ) - - if self._redis_enabled and isinstance(model_cooldowns, dict) and model_cooldowns: - redis_keys = [self._rk_cd(mode, filename, escaped_model) for escaped_model in model_cooldowns.keys()] - await self._redis.delete(*redis_keys) - - log.debug(f"Cleared all model cooldowns: {filename} (mode={mode})") - return True - - except Exception as e: - log.error(f"Error clearing all model cooldowns for {filename}: {e}") - return False - - async def record_success( - self, - filename: str, - model_name: Optional[str] = None, - mode: str = "geminicli" - ) -> None: - """ - 成功调用后的条件写入: - - 只有当前 error_codes 非空时才清除错误并写 last_success - - 只有当前存在该模型的冷却键时才清除 - 通过 MongoDB 服务端条件匹配实现 - """ - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - collection_name = self._get_collection_name(mode) - collection = self._db[collection_name] - now = time.time() - - # 条件写入:只有 error_codes 非空时才触发,避免无意义的写 IO - await collection.update_one( - {"filename": filename, "error_codes": {"$ne": []}}, - {"$set": { - "last_success": now, - "error_codes": [], - "error_messages": {}, - "updated_at": now, - }} - ) - - # 条件删除模型冷却:只有该键存在时才写入 - if model_name: - escaped = self._escape_model_name(model_name) - await collection.update_one( - {"filename": filename, f"model_cooldowns.{escaped}": {"$exists": True}}, - {"$unset": {f"model_cooldowns.{escaped}": ""}, "$set": {"updated_at": now}} - ) - # 同步删除 Redis 冷却 key - if self._redis_enabled: - await self._redis.delete(self._rk_cd(mode, filename, escaped)) - - except Exception as e: - log.error(f"Error recording success for {filename}: {e}") \ No newline at end of file diff --git a/src/storage/psql_manager.py b/src/storage/psql_manager.py deleted file mode 100644 index 0afc04ac4ad025410cdd497d37c033c3d7e6c27b..0000000000000000000000000000000000000000 --- a/src/storage/psql_manager.py +++ /dev/null @@ -1,1034 +0,0 @@ -""" -PostgreSQL 存储管理器 -""" - -import asyncio -import json -import os -import time -from typing import Any, Dict, List, Optional, Tuple - -import asyncpg - -from log import log - - -class PSQLManager: - """PostgreSQL 数据库管理器""" - - # 状态字段常量 - STATE_FIELDS = { - "error_codes", - "error_messages", - "disabled", - "last_success", - "user_email", - "model_cooldowns", - "preview", - "tier", - "enable_credit", - } - - def __init__(self): - self._dsn: Optional[str] = None - self._pool: Optional[asyncpg.Pool] = None - self._initialized = False - self._lock = asyncio.Lock() - - # 内存配置缓存 - self._config_cache: Dict[str, Any] = {} - self._config_loaded = False - - async def initialize(self) -> None: - """初始化 PostgreSQL 数据库""" - if self._initialized: - return - - async with self._lock: - if self._initialized: - return - - try: - self._dsn = os.getenv("POSTGRESQL_URI", "") - if not self._dsn: - raise RuntimeError("POSTGRESQL_URI environment variable is not set") - - self._pool = await asyncpg.create_pool(self._dsn, min_size=2, max_size=10) - - async with self._pool.acquire() as conn: - await self._create_tables(conn) - await self._ensure_schema_compatibility(conn) - - await self._load_config_cache() - - self._initialized = True - log.info("PostgreSQL storage initialized") - - except Exception as e: - log.error(f"Error initializing PostgreSQL: {e}") - if self._pool: - await self._pool.close() - self._pool = None - raise - - async def _create_tables(self, conn: asyncpg.Connection) -> None: - """创建数据库表和索引""" - await conn.execute(""" - CREATE TABLE IF NOT EXISTS credentials ( - id SERIAL PRIMARY KEY, - filename TEXT UNIQUE NOT NULL, - credential_data TEXT NOT NULL, - - disabled INTEGER DEFAULT 0, - error_codes TEXT DEFAULT '[]', - error_messages TEXT DEFAULT '[]', - last_success DOUBLE PRECISION, - user_email TEXT, - - model_cooldowns TEXT DEFAULT '{}', - preview INTEGER DEFAULT 1, - tier TEXT DEFAULT 'pro', - - rotation_order INTEGER DEFAULT 0, - call_count INTEGER DEFAULT 0, - - created_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()), - updated_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()) - ) - """) - - await conn.execute(""" - CREATE TABLE IF NOT EXISTS antigravity_credentials ( - id SERIAL PRIMARY KEY, - filename TEXT UNIQUE NOT NULL, - credential_data TEXT NOT NULL, - - disabled INTEGER DEFAULT 0, - error_codes TEXT DEFAULT '[]', - error_messages TEXT DEFAULT '[]', - last_success DOUBLE PRECISION, - user_email TEXT, - - model_cooldowns TEXT DEFAULT '{}', - tier TEXT DEFAULT 'pro', - enable_credit INTEGER DEFAULT 0, - - rotation_order INTEGER DEFAULT 0, - call_count INTEGER DEFAULT 0, - - created_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()), - updated_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()) - ) - """) - - await conn.execute(""" - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW()) - ) - """) - - # 索引 - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_disabled ON credentials(disabled) - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_rotation_order ON credentials(rotation_order) - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_ag_disabled ON antigravity_credentials(disabled) - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_ag_rotation_order ON antigravity_credentials(rotation_order) - """) - - log.debug("PostgreSQL tables and indexes created") - - async def _ensure_schema_compatibility(self, conn: asyncpg.Connection) -> None: - """确保数据库结构兼容,自动修复缺失的列""" - required_columns = { - "credentials": [ - ("disabled", "INTEGER DEFAULT 0"), - ("error_codes", "TEXT DEFAULT '[]'"), - ("error_messages", "TEXT DEFAULT '[]'"), - ("last_success", "DOUBLE PRECISION"), - ("user_email", "TEXT"), - ("model_cooldowns", "TEXT DEFAULT '{}'"), - ("preview", "INTEGER DEFAULT 1"), - ("tier", "TEXT DEFAULT 'pro'"), - ("rotation_order", "INTEGER DEFAULT 0"), - ("call_count", "INTEGER DEFAULT 0"), - ("created_at", "DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW())"), - ("updated_at", "DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW())"), - ], - "antigravity_credentials": [ - ("disabled", "INTEGER DEFAULT 0"), - ("error_codes", "TEXT DEFAULT '[]'"), - ("error_messages", "TEXT DEFAULT '[]'"), - ("last_success", "DOUBLE PRECISION"), - ("user_email", "TEXT"), - ("model_cooldowns", "TEXT DEFAULT '{}'"), - ("tier", "TEXT DEFAULT 'pro'"), - ("enable_credit", "INTEGER DEFAULT 0"), - ("rotation_order", "INTEGER DEFAULT 0"), - ("call_count", "INTEGER DEFAULT 0"), - ("created_at", "DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW())"), - ("updated_at", "DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW())"), - ], - } - - try: - for table_name, columns in required_columns.items(): - rows = await conn.fetch(""" - SELECT column_name FROM information_schema.columns - WHERE table_name = $1 - """, table_name) - existing = {r["column_name"] for r in rows} - - for col_name, col_def in columns: - if col_name not in existing: - try: - await conn.execute( - f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_def}" - ) - log.info(f"Added missing column {table_name}.{col_name}") - except Exception as e: - log.error(f"Failed to add column {table_name}.{col_name}: {e}") - except Exception as e: - log.error(f"Error ensuring schema compatibility: {e}") - - async def _load_config_cache(self) -> None: - """加载配置到内存缓存""" - if self._config_loaded: - return - - try: - async with self._pool.acquire() as conn: - rows = await conn.fetch("SELECT key, value FROM config") - - for row in rows: - try: - self._config_cache[row["key"]] = json.loads(row["value"]) - except json.JSONDecodeError: - self._config_cache[row["key"]] = row["value"] - - self._config_loaded = True - log.debug(f"Loaded {len(self._config_cache)} config items into cache") - - except Exception as e: - log.error(f"Error loading config cache: {e}") - self._config_cache = {} - - async def close(self) -> None: - """关闭数据库连接池""" - if self._pool: - await self._pool.close() - self._pool = None - self._initialized = False - log.debug("PostgreSQL storage closed") - - def _ensure_initialized(self) -> None: - if not self._initialized or not self._pool: - raise RuntimeError("PostgreSQL manager not initialized") - - def _get_table_name(self, mode: str) -> str: - if mode == "antigravity": - return "antigravity_credentials" - elif mode == "geminicli": - return "credentials" - else: - raise ValueError(f"Invalid mode: {mode}. Must be 'geminicli' or 'antigravity'") - - # ============ 凭证查询方法 ============ - - async def get_next_available_credential( - self, mode: str = "geminicli", model_name: Optional[str] = None - ) -> Optional[Tuple[str, Dict[str, Any]]]: - """随机获取一个可用凭证(负载均衡)""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - current_time = time.time() - - async with self._pool.acquire() as conn: - if mode == "geminicli": - tier_clause = "" - if model_name and "pro" in model_name.lower(): - tier_clause = "AND (tier IS NULL OR tier != 'free')" - - rows = await conn.fetch(f""" - SELECT filename, credential_data, model_cooldowns, preview - FROM {table_name} - WHERE disabled = 0 {tier_clause} - ORDER BY RANDOM() - """) - - if not model_name: - if rows: - return rows[0]["filename"], json.loads(rows[0]["credential_data"]) - return None - - is_preview_model = "preview" in model_name.lower() - non_preview_creds = [] - preview_creds = [] - - for row in rows: - model_cooldowns = json.loads(row["model_cooldowns"] or "{}") - cd = model_cooldowns.get(model_name) - if cd is None or current_time >= cd: - if row["preview"]: - preview_creds.append((row["filename"], row["credential_data"])) - else: - non_preview_creds.append((row["filename"], row["credential_data"])) - - if is_preview_model: - if preview_creds: - return preview_creds[0][0], json.loads(preview_creds[0][1]) - else: - if non_preview_creds: - return non_preview_creds[0][0], json.loads(non_preview_creds[0][1]) - elif preview_creds: - return preview_creds[0][0], json.loads(preview_creds[0][1]) - - return None - else: - rows = await conn.fetch(f""" - SELECT filename, credential_data, model_cooldowns, enable_credit - FROM {table_name} - WHERE disabled = 0 - ORDER BY RANDOM() - """) - - if not model_name: - if rows: - credential_data = json.loads(rows[0]["credential_data"]) - credential_data["enable_credit"] = bool(rows[0]["enable_credit"]) - return rows[0]["filename"], credential_data - return None - - for row in rows: - model_cooldowns = json.loads(row["model_cooldowns"] or "{}") - cd = model_cooldowns.get(model_name) - if cd is None or current_time >= cd: - credential_data = json.loads(row["credential_data"]) - credential_data["enable_credit"] = bool(row["enable_credit"]) - return row["filename"], credential_data - - return None - - except Exception as e: - log.error(f"Error getting next available credential (mode={mode}, model_name={model_name}): {e}") - return None - - async def get_available_credentials_list(self) -> List[str]: - """获取所有可用凭证列表""" - self._ensure_initialized() - - try: - async with self._pool.acquire() as conn: - rows = await conn.fetch(""" - SELECT filename FROM credentials - WHERE disabled = 0 - ORDER BY rotation_order ASC - """) - return [r["filename"] for r in rows] - except Exception as e: - log.error(f"Error getting available credentials list: {e}") - return [] - - # ============ StorageBackend 协议方法 ============ - - async def store_credential(self, filename: str, credential_data: Dict[str, Any], mode: str = "geminicli") -> bool: - """存储或更新凭证""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - existing = await conn.fetchrow( - f"SELECT rotation_order FROM {table_name} WHERE filename = $1", filename - ) - - if existing: - await conn.execute( - f""" - UPDATE {table_name} - SET credential_data = $1, - updated_at = EXTRACT(EPOCH FROM NOW()) - WHERE filename = $2 - """, - json.dumps(credential_data), filename - ) - else: - row = await conn.fetchrow( - f"SELECT COALESCE(MAX(rotation_order), -1) + 1 AS next_order FROM {table_name}" - ) - next_order = row["next_order"] - await conn.execute( - f""" - INSERT INTO {table_name} - (filename, credential_data, rotation_order, last_success) - VALUES ($1, $2, $3, $4) - """, - filename, json.dumps(credential_data), next_order, time.time() - ) - - log.debug(f"Stored credential: {filename} (mode={mode})") - return True - - except Exception as e: - log.error(f"Error storing credential {filename}: {e}") - return False - - async def get_credential(self, filename: str, mode: str = "geminicli") -> Optional[Dict[str, Any]]: - """获取凭证数据""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - row = await conn.fetchrow( - f"SELECT credential_data FROM {table_name} WHERE filename = $1", filename - ) - if row: - return json.loads(row["credential_data"]) - return None - except Exception as e: - log.error(f"Error getting credential {filename}: {e}") - return None - - async def list_credentials(self, mode: str = "geminicli") -> List[str]: - """列出所有凭证文件名(包括禁用的)""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - rows = await conn.fetch( - f"SELECT filename FROM {table_name} ORDER BY rotation_order" - ) - return [r["filename"] for r in rows] - except Exception as e: - log.error(f"Error listing credentials: {e}") - return [] - - async def delete_credential(self, filename: str, mode: str = "geminicli") -> bool: - """删除凭证""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - result = await conn.execute( - f"DELETE FROM {table_name} WHERE filename = $1", filename - ) - # asyncpg returns "DELETE N" - deleted_count = int(result.split()[-1]) - - if deleted_count > 0: - log.debug(f"Deleted credential: {filename} (mode={mode})") - return True - else: - log.warning(f"No credential found to delete: {filename} (mode={mode})") - return False - - except Exception as e: - log.error(f"Error deleting credential {filename}: {e}") - return False - - async def update_credential_state(self, filename: str, state_updates: Dict[str, Any], mode: str = "geminicli") -> bool: - """更新凭证状态""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - log.debug(f"[DB] update_credential_state: filename={filename}, updates={state_updates}, mode={mode}") - - set_clauses = [] - values = [] - idx = 1 - - for key, value in state_updates.items(): - if key in self.STATE_FIELDS: - if key == "enable_credit" and mode != "antigravity": - continue - if key in ("error_codes", "error_messages", "model_cooldowns"): - set_clauses.append(f"{key} = ${idx}") - values.append(json.dumps(value)) - else: - set_clauses.append(f"{key} = ${idx}") - values.append(value) - idx += 1 - - if not set_clauses: - return True - - set_clauses.append(f"updated_at = EXTRACT(EPOCH FROM NOW())") - values.append(filename) - - sql = f""" - UPDATE {table_name} - SET {', '.join(set_clauses)} - WHERE filename = ${idx} - """ - - async with self._pool.acquire() as conn: - result = await conn.execute(sql, *values) - updated_count = int(result.split()[-1]) - - return updated_count > 0 - - except Exception as e: - log.error(f"[DB] Error updating credential state {filename}: {e}") - return False - - async def get_credential_state(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """获取凭证状态""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - if mode == "geminicli": - row = await conn.fetchrow(f""" - SELECT disabled, error_codes, last_success, user_email, model_cooldowns, preview, tier - FROM {table_name} WHERE filename = $1 - """, filename) - - if row: - return { - "disabled": bool(row["disabled"]), - "error_codes": json.loads(row["error_codes"] or "[]"), - "last_success": row["last_success"] or time.time(), - "user_email": row["user_email"], - "model_cooldowns": json.loads(row["model_cooldowns"] or "{}"), - "preview": bool(row["preview"]) if row["preview"] is not None else True, - "tier": row["tier"] if row["tier"] is not None else "pro", - } - - return { - "disabled": False, - "error_codes": [], - "last_success": time.time(), - "user_email": None, - "model_cooldowns": {}, - "preview": True, - "tier": "pro", - } - else: - row = await conn.fetchrow(f""" - SELECT disabled, error_codes, last_success, user_email, model_cooldowns, tier, enable_credit - FROM {table_name} WHERE filename = $1 - """, filename) - - if row: - return { - "disabled": bool(row["disabled"]), - "error_codes": json.loads(row["error_codes"] or "[]"), - "last_success": row["last_success"] or time.time(), - "user_email": row["user_email"], - "model_cooldowns": json.loads(row["model_cooldowns"] or "{}"), - "tier": row["tier"] if row["tier"] is not None else "pro", - "enable_credit": bool(row["enable_credit"]) if row["enable_credit"] is not None else False, - } - - return { - "disabled": False, - "error_codes": [], - "last_success": time.time(), - "user_email": None, - "model_cooldowns": {}, - "tier": "pro", - "enable_credit": False, - } - - except Exception as e: - log.error(f"Error getting credential state {filename}: {e}") - return {} - - async def get_all_credential_states(self, mode: str = "geminicli") -> Dict[str, Dict[str, Any]]: - """获取所有凭证状态""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - current_time = time.time() - - async with self._pool.acquire() as conn: - if mode == "geminicli": - rows = await conn.fetch(f""" - SELECT filename, disabled, error_codes, last_success, - user_email, model_cooldowns, preview, tier - FROM {table_name} - """) - - states = {} - for row in rows: - model_cooldowns = json.loads(row["model_cooldowns"] or "{}") - if model_cooldowns: - model_cooldowns = {k: v for k, v in model_cooldowns.items() if v > current_time} - - states[row["filename"]] = { - "disabled": bool(row["disabled"]), - "error_codes": json.loads(row["error_codes"] or "[]"), - "last_success": row["last_success"] or current_time, - "user_email": row["user_email"], - "model_cooldowns": model_cooldowns, - "preview": bool(row["preview"]) if row["preview"] is not None else True, - "tier": row["tier"] if row["tier"] is not None else "pro", - } - return states - else: - rows = await conn.fetch(f""" - SELECT filename, disabled, error_codes, last_success, - user_email, model_cooldowns, tier, enable_credit - FROM {table_name} - """) - - states = {} - for row in rows: - model_cooldowns = json.loads(row["model_cooldowns"] or "{}") - if model_cooldowns: - model_cooldowns = {k: v for k, v in model_cooldowns.items() if v > current_time} - - states[row["filename"]] = { - "disabled": bool(row["disabled"]), - "error_codes": json.loads(row["error_codes"] or "[]"), - "last_success": row["last_success"] or current_time, - "user_email": row["user_email"], - "model_cooldowns": model_cooldowns, - "tier": row["tier"] if row["tier"] is not None else "pro", - "enable_credit": bool(row["enable_credit"]) if row["enable_credit"] is not None else False, - } - return states - - except Exception as e: - log.error(f"Error getting all credential states: {e}") - return {} - - async def get_credentials_summary( - self, - offset: int = 0, - limit: Optional[int] = None, - status_filter: str = "all", - mode: str = "geminicli", - error_code_filter: Optional[str] = None, - cooldown_filter: Optional[str] = None, - preview_filter: Optional[str] = None, - tier_filter: Optional[str] = None - ) -> Dict[str, Any]: - """获取凭证的摘要信息,支持分页和状态筛选""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - current_time = time.time() - - async with self._pool.acquire() as conn: - # 全局统计 - stats_rows = await conn.fetch( - f"SELECT disabled, COUNT(*) AS cnt FROM {table_name} GROUP BY disabled" - ) - global_stats = {"total": 0, "normal": 0, "disabled": 0} - for r in stats_rows: - global_stats["total"] += r["cnt"] - if r["disabled"]: - global_stats["disabled"] = r["cnt"] - else: - global_stats["normal"] = r["cnt"] - - # WHERE 子句 - where_clauses = [] - if status_filter == "enabled": - where_clauses.append("disabled = 0") - elif status_filter == "disabled": - where_clauses.append("disabled = 1") - - where_clause = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" - - # 查询 - if mode == "geminicli": - all_rows = await conn.fetch(f""" - SELECT filename, disabled, error_codes, last_success, - user_email, rotation_order, model_cooldowns, preview, tier - FROM {table_name} - {where_clause} - ORDER BY rotation_order - """) - else: - all_rows = await conn.fetch(f""" - SELECT filename, disabled, error_codes, last_success, - user_email, rotation_order, model_cooldowns, tier, enable_credit - FROM {table_name} - {where_clause} - ORDER BY rotation_order - """) - - # 错误码筛选 - filter_value = None - filter_int = None - filter_none = False - if error_code_filter and str(error_code_filter).strip().lower() != "all": - if str(error_code_filter).strip().lower() == "none": - filter_none = True - else: - filter_value = str(error_code_filter).strip() - try: - filter_int = int(filter_value) - except ValueError: - filter_int = None - - all_summaries = [] - for row in all_rows: - error_codes_json = row["error_codes"] or "[]" - model_cooldowns = json.loads(row["model_cooldowns"] or "{}") - active_cooldowns = {k: v for k, v in model_cooldowns.items() if v > current_time} - error_codes = json.loads(error_codes_json) - - # 筛选无错误的凭证 - if filter_none: - if error_codes: - continue - - if filter_value: - match = False - for code in error_codes: - if code == filter_value or code == filter_int: - match = True - break - if isinstance(code, str) and filter_int is not None: - try: - if int(code) == filter_int: - match = True - break - except ValueError: - pass - if not match: - continue - - summary = { - "filename": row["filename"], - "disabled": bool(row["disabled"]), - "error_codes": error_codes, - "last_success": row["last_success"] or current_time, - "user_email": row["user_email"], - "rotation_order": row["rotation_order"], - "model_cooldowns": active_cooldowns, - "tier": row["tier"] if row["tier"] is not None else "pro", - } - - if mode == "geminicli": - summary["preview"] = bool(row["preview"]) if row["preview"] is not None else True - - if preview_filter: - preview_value = summary.get("preview", True) - if preview_filter == "preview" and not preview_value: - continue - elif preview_filter == "no_preview" and preview_value: - continue - else: - summary["enable_credit"] = bool(row["enable_credit"]) if row["enable_credit"] is not None else False - - if tier_filter and tier_filter in ("free", "pro", "ultra"): - if summary["tier"] != tier_filter: - continue - - if cooldown_filter == "in_cooldown": - if active_cooldowns: - all_summaries.append(summary) - elif cooldown_filter == "no_cooldown": - if not active_cooldowns: - all_summaries.append(summary) - else: - all_summaries.append(summary) - - total_count = len(all_summaries) - if limit is not None: - summaries = all_summaries[offset:offset + limit] - else: - summaries = all_summaries[offset:] - - return { - "items": summaries, - "total": total_count, - "offset": offset, - "limit": limit, - "stats": global_stats, - } - - except Exception as e: - log.error(f"Error getting credentials summary: {e}") - return { - "items": [], - "total": 0, - "offset": offset, - "limit": limit, - "stats": {"total": 0, "normal": 0, "disabled": 0}, - } - - async def get_duplicate_credentials_by_email(self, mode: str = "geminicli") -> Dict[str, Any]: - """获取按邮箱分组的重复凭证信息""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - rows = await conn.fetch( - f"SELECT filename, user_email FROM {table_name} ORDER BY filename" - ) - - email_to_files: Dict[str, List[str]] = {} - no_email_files: List[str] = [] - - for row in rows: - if row["user_email"]: - email_to_files.setdefault(row["user_email"], []).append(row["filename"]) - else: - no_email_files.append(row["filename"]) - - duplicate_groups = [] - total_duplicate_count = 0 - for email, files in email_to_files.items(): - if len(files) > 1: - duplicate_groups.append({ - "email": email, - "kept_file": files[0], - "duplicate_files": files[1:], - "duplicate_count": len(files) - 1, - }) - total_duplicate_count += len(files) - 1 - - return { - "email_groups": email_to_files, - "duplicate_groups": duplicate_groups, - "duplicate_count": total_duplicate_count, - "no_email_files": no_email_files, - "no_email_count": len(no_email_files), - "unique_email_count": len(email_to_files), - "total_count": len(rows), - } - - except Exception as e: - log.error(f"Error getting duplicate credentials by email: {e}") - return { - "email_groups": {}, - "duplicate_groups": [], - "duplicate_count": 0, - "no_email_files": [], - "no_email_count": 0, - "unique_email_count": 0, - "total_count": 0, - } - - # ============ 配置管理(内存缓存)============ - - async def set_config(self, key: str, value: Any) -> bool: - """设置配置(写入数据库 + 更新内存缓存)""" - self._ensure_initialized() - - try: - async with self._pool.acquire() as conn: - await conn.execute(""" - INSERT INTO config (key, value, updated_at) - VALUES ($1, $2, EXTRACT(EPOCH FROM NOW())) - ON CONFLICT (key) DO UPDATE - SET value = EXCLUDED.value, - updated_at = EXCLUDED.updated_at - """, key, json.dumps(value)) - - self._config_cache[key] = value - return True - - except Exception as e: - log.error(f"Error setting config {key}: {e}") - return False - - async def reload_config_cache(self) -> None: - """重新加载配置缓存""" - self._ensure_initialized() - self._config_loaded = False - await self._load_config_cache() - log.info("Config cache reloaded from database") - - async def get_config(self, key: str, default: Any = None) -> Any: - """获取配置(从内存缓存)""" - self._ensure_initialized() - return self._config_cache.get(key, default) - - async def get_all_config(self) -> Dict[str, Any]: - """获取所有配置(从内存缓存)""" - self._ensure_initialized() - return self._config_cache.copy() - - async def delete_config(self, key: str) -> bool: - """删除配置""" - self._ensure_initialized() - - try: - async with self._pool.acquire() as conn: - await conn.execute("DELETE FROM config WHERE key = $1", key) - - self._config_cache.pop(key, None) - return True - - except Exception as e: - log.error(f"Error deleting config {key}: {e}") - return False - - async def get_credential_errors(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """获取凭证的错误信息""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - row = await conn.fetchrow( - f"SELECT error_codes, error_messages FROM {table_name} WHERE filename = $1", - filename - ) - - if row: - return { - "filename": filename, - "error_codes": json.loads(row["error_codes"] or "[]"), - "error_messages": json.loads(row["error_messages"] or "[]"), - } - - return {"filename": filename, "error_codes": [], "error_messages": []} - - except Exception as e: - log.error(f"Error getting credential errors {filename}: {e}") - return {"filename": filename, "error_codes": [], "error_messages": [], "error": str(e)} - - # ============ 模型级冷却管理 ============ - - async def set_model_cooldown( - self, - filename: str, - model_name: str, - cooldown_until: Optional[float], - mode: str = "geminicli" - ) -> bool: - """设置特定模型的冷却时间""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - row = await conn.fetchrow( - f"SELECT model_cooldowns FROM {table_name} WHERE filename = $1", filename - ) - - if not row: - log.warning(f"Credential {filename} not found") - return False - - model_cooldowns = json.loads(row["model_cooldowns"] or "{}") - - if cooldown_until is None: - model_cooldowns.pop(model_name, None) - else: - model_cooldowns[model_name] = cooldown_until - - await conn.execute( - f""" - UPDATE {table_name} - SET model_cooldowns = $1, - updated_at = EXTRACT(EPOCH FROM NOW()) - WHERE filename = $2 - """, - json.dumps(model_cooldowns), filename - ) - - log.debug(f"Set model cooldown: {filename}, model_name={model_name}, cooldown_until={cooldown_until}") - return True - - except Exception as e: - log.error(f"Error setting model cooldown for {filename}: {e}") - return False - - async def clear_all_model_cooldowns( - self, - filename: str, - mode: str = "geminicli" - ) -> bool: - """清除某个凭证的所有模型冷却时间""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - result = await conn.execute( - f""" - UPDATE {table_name} - SET model_cooldowns = '{{}}', - updated_at = EXTRACT(EPOCH FROM NOW()) - WHERE filename = $1 - """, - filename, - ) - updated_count = int(result.split()[-1]) - - if updated_count == 0: - log.warning(f"Credential {filename} not found") - return False - - log.debug(f"Cleared all model cooldowns: {filename} (mode={mode})") - return True - - except Exception as e: - log.error(f"Error clearing all model cooldowns for {filename}: {e}") - return False - - async def record_success( - self, - filename: str, - model_name: Optional[str] = None, - mode: str = "geminicli" - ) -> None: - """成功调用后的条件写入""" - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with self._pool.acquire() as conn: - await conn.execute(f""" - UPDATE {table_name} - SET last_success = EXTRACT(EPOCH FROM NOW()), - error_codes = '[]', - error_messages = '{{}}', - updated_at = EXTRACT(EPOCH FROM NOW()) - WHERE filename = $1 - AND (error_codes IS NOT NULL AND error_codes != '[]' AND error_codes != '') - """, filename) - - if model_name: - row = await conn.fetchrow( - f"SELECT model_cooldowns FROM {table_name} WHERE filename = $1", filename - ) - if row: - cooldowns = json.loads(row["model_cooldowns"] or "{}") - if model_name in cooldowns: - cooldowns.pop(model_name) - await conn.execute( - f""" - UPDATE {table_name} - SET model_cooldowns = $1, updated_at = EXTRACT(EPOCH FROM NOW()) - WHERE filename = $2 - """, - json.dumps(cooldowns), filename - ) - - except Exception as e: - log.error(f"Error recording success for {filename}: {e}") diff --git a/src/storage/sqlite_manager.py b/src/storage/sqlite_manager.py deleted file mode 100644 index 95ed637557af67212cbaf1ddda9d32b5bf34a567..0000000000000000000000000000000000000000 --- a/src/storage/sqlite_manager.py +++ /dev/null @@ -1,1375 +0,0 @@ -""" -SQLite 存储管理器 -""" - -import asyncio -import json -import os -import time -from typing import Any, Dict, List, Optional, Tuple - -import aiosqlite - -from log import log - - -class SQLiteManager: - """SQLite 数据库管理器""" - - # 状态字段常量 - STATE_FIELDS = { - "error_codes", - "error_messages", - "disabled", - "last_success", - "user_email", - "model_cooldowns", - "preview", - "tier", - "enable_credit", - } - - # 所有必需的列定义(用于自动校验和修复) - REQUIRED_COLUMNS = { - "credentials": [ - ("disabled", "INTEGER DEFAULT 0"), - ("error_codes", "TEXT DEFAULT '[]'"), - ("error_messages", "TEXT DEFAULT '[]'"), - ("last_success", "REAL"), - ("user_email", "TEXT"), - ("model_cooldowns", "TEXT DEFAULT '{}'"), - ("preview", "INTEGER DEFAULT 1"), - ("tier", "TEXT DEFAULT 'pro'"), - ("rotation_order", "INTEGER DEFAULT 0"), - ("call_count", "INTEGER DEFAULT 0"), - ("created_at", "REAL DEFAULT (unixepoch())"), - ("updated_at", "REAL DEFAULT (unixepoch())") - ], - "antigravity_credentials": [ - ("disabled", "INTEGER DEFAULT 0"), - ("error_codes", "TEXT DEFAULT '[]'"), - ("error_messages", "TEXT DEFAULT '[]'"), - ("last_success", "REAL"), - ("user_email", "TEXT"), - ("model_cooldowns", "TEXT DEFAULT '{}'"), - ("tier", "TEXT DEFAULT 'pro'"), - ("enable_credit", "INTEGER DEFAULT 0"), - ("rotation_order", "INTEGER DEFAULT 0"), - ("call_count", "INTEGER DEFAULT 0"), - ("created_at", "REAL DEFAULT (unixepoch())"), - ("updated_at", "REAL DEFAULT (unixepoch())") - ] - } - - def __init__(self): - self._db_path = None - self._credentials_dir = None - self._initialized = False - self._lock = asyncio.Lock() - - # 内存配置缓存 - 初始化时加载一次 - self._config_cache: Dict[str, Any] = {} - self._config_loaded = False - - async def initialize(self) -> None: - """初始化 SQLite 数据库""" - if self._initialized: - return - - async with self._lock: - if self._initialized: - return - - try: - # 获取凭证目录 - self._credentials_dir = os.getenv("CREDENTIALS_DIR", "./creds") - self._db_path = os.path.join(self._credentials_dir, "credentials.db") - - # 确保目录存在 - os.makedirs(self._credentials_dir, exist_ok=True) - - # 创建数据库和表 - async with aiosqlite.connect(self._db_path) as db: - # 启用 WAL 模式(提升并发性能) - await db.execute("PRAGMA journal_mode=WAL") - await db.execute("PRAGMA foreign_keys=ON") - - # 检查并自动修复数据库结构 - await self._ensure_schema_compatibility(db) - - # 创建表 - await self._create_tables(db) - - # 修复可能包含路径的凭证文件名 - await self._repair_credential_filenames(db) - - await db.commit() - - # 加载配置到内存 - await self._load_config_cache() - - self._initialized = True - log.info(f"SQLite storage initialized at {self._db_path}") - - except Exception as e: - log.error(f"Error initializing SQLite: {e}") - raise - - async def _ensure_schema_compatibility(self, db: aiosqlite.Connection) -> None: - """ - 确保数据库结构兼容,自动修复缺失的列 - """ - try: - # 检查每个表 - for table_name, columns in self.REQUIRED_COLUMNS.items(): - # 检查表是否存在 - async with db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", - (table_name,) - ) as cursor: - if not await cursor.fetchone(): - log.debug(f"Table {table_name} does not exist, will be created") - continue - - # 获取现有列 - async with db.execute(f"PRAGMA table_info({table_name})") as cursor: - existing_columns = {row[1] for row in await cursor.fetchall()} - - # 添加缺失的列 - added_count = 0 - for col_name, col_def in columns: - if col_name not in existing_columns: - try: - await db.execute(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_def}") - log.info(f"Added missing column {table_name}.{col_name}") - added_count += 1 - except Exception as e: - log.error(f"Failed to add column {table_name}.{col_name}: {e}") - - if added_count > 0: - log.info(f"Table {table_name}: added {added_count} missing column(s)") - - except Exception as e: - log.error(f"Error ensuring schema compatibility: {e}") - # 不抛出异常,允许继续初始化 - - async def _create_tables(self, db: aiosqlite.Connection): - """创建数据库表和索引""" - # 凭证表 - await db.execute(""" - CREATE TABLE IF NOT EXISTS credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT UNIQUE NOT NULL, - credential_data TEXT NOT NULL, - - -- 状态字段 - disabled INTEGER DEFAULT 0, - error_codes TEXT DEFAULT '[]', - error_messages TEXT DEFAULT '[]', - last_success REAL, - user_email TEXT, - - -- 模型级 CD 支持 (JSON: {model_name: cooldown_timestamp}) - model_cooldowns TEXT DEFAULT '{}', - - -- preview 状态 (只对 geminicli 有效,默认为 true) - preview INTEGER DEFAULT 1, - - -- tier 状态 (只对 geminicli 有效,默认为 pro) - tier TEXT DEFAULT 'pro', - - -- 轮换相关 - rotation_order INTEGER DEFAULT 0, - call_count INTEGER DEFAULT 0, - - -- 时间戳 - created_at REAL DEFAULT (unixepoch()), - updated_at REAL DEFAULT (unixepoch()) - ) - """) - - # Antigravity 凭证表(结构相同但独立存储) - await db.execute(""" - CREATE TABLE IF NOT EXISTS antigravity_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT UNIQUE NOT NULL, - credential_data TEXT NOT NULL, - - -- 状态字段 - disabled INTEGER DEFAULT 0, - error_codes TEXT DEFAULT '[]', - error_messages TEXT DEFAULT '[]', - last_success REAL, - user_email TEXT, - - -- 模型级 CD 支持 (JSON: {model_name: cooldown_timestamp}) - model_cooldowns TEXT DEFAULT '{}', - - -- tier 状态 (默认为 pro) - tier TEXT DEFAULT 'pro', - - -- 是否启用信用额度模式(仅 antigravity,有效值 0/1) - enable_credit INTEGER DEFAULT 0, - - -- 轮换相关 - rotation_order INTEGER DEFAULT 0, - call_count INTEGER DEFAULT 0, - - -- 时间戳 - created_at REAL DEFAULT (unixepoch()), - updated_at REAL DEFAULT (unixepoch()) - ) - """) - - # 创建索引 - 普通凭证表 - await db.execute(""" - CREATE INDEX IF NOT EXISTS idx_disabled - ON credentials(disabled) - """) - await db.execute(""" - CREATE INDEX IF NOT EXISTS idx_rotation_order - ON credentials(rotation_order) - """) - - # 创建索引 - Antigravity 凭证表 - await db.execute(""" - CREATE INDEX IF NOT EXISTS idx_ag_disabled - ON antigravity_credentials(disabled) - """) - await db.execute(""" - CREATE INDEX IF NOT EXISTS idx_ag_rotation_order - ON antigravity_credentials(rotation_order) - """) - - # 配置表 - await db.execute(""" - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at REAL DEFAULT (unixepoch()) - ) - """) - - log.debug("SQLite tables and indexes created") - - async def _repair_credential_filenames(self, db: aiosqlite.Connection): - """ - 修复凭证数据库中可能包含路径的文件名,确保所有文件名都是 basename - """ - try: - repaired_count = 0 - - # 修复 credentials 表 - async with db.execute("SELECT filename FROM credentials") as cursor: - rows = await cursor.fetchall() - for (filename,) in rows: - basename = os.path.basename(filename) - if basename != filename: - # 检查是否会产生冲突 - async with db.execute( - "SELECT COUNT(*) FROM credentials WHERE filename = ?", - (basename,) - ) as check_cursor: - count = (await check_cursor.fetchone())[0] - - if count == 0: - # 无冲突,直接更新 - await db.execute( - "UPDATE credentials SET filename = ? WHERE filename = ?", - (basename, filename) - ) - repaired_count += 1 - log.info(f"Repaired credential filename: {filename} -> {basename}") - else: - # 有冲突,删除带路径的旧记录(保留 basename 的记录) - await db.execute( - "DELETE FROM credentials WHERE filename = ?", - (filename,) - ) - repaired_count += 1 - log.warning(f"Removed duplicate credential with path: {filename} (kept {basename})") - - # 修复 antigravity_credentials 表 - async with db.execute("SELECT filename FROM antigravity_credentials") as cursor: - rows = await cursor.fetchall() - for (filename,) in rows: - basename = os.path.basename(filename) - if basename != filename: - # 检查是否会产生冲突 - async with db.execute( - "SELECT COUNT(*) FROM antigravity_credentials WHERE filename = ?", - (basename,) - ) as check_cursor: - count = (await check_cursor.fetchone())[0] - - if count == 0: - # 无冲突,直接更新 - await db.execute( - "UPDATE antigravity_credentials SET filename = ? WHERE filename = ?", - (basename, filename) - ) - repaired_count += 1 - log.info(f"Repaired antigravity credential filename: {filename} -> {basename}") - else: - # 有冲突,删除带路径的旧记录(保留 basename 的记录) - await db.execute( - "DELETE FROM antigravity_credentials WHERE filename = ?", - (filename,) - ) - repaired_count += 1 - log.warning(f"Removed duplicate antigravity credential with path: {filename} (kept {basename})") - - if repaired_count > 0: - log.info(f"Repaired {repaired_count} credential filename(s)") - else: - log.debug("No credential filenames need repair") - - except Exception as e: - log.error(f"Error repairing credential filenames: {e}") - # 不抛出异常,允许继续初始化 - - async def _load_config_cache(self): - """加载配置到内存缓存(仅在初始化时调用一次)""" - if self._config_loaded: - return - - try: - async with aiosqlite.connect(self._db_path) as db: - async with db.execute("SELECT key, value FROM config") as cursor: - rows = await cursor.fetchall() - - for key, value in rows: - try: - self._config_cache[key] = json.loads(value) - except json.JSONDecodeError: - self._config_cache[key] = value - - self._config_loaded = True - log.debug(f"Loaded {len(self._config_cache)} config items into cache") - - except Exception as e: - log.error(f"Error loading config cache: {e}") - self._config_cache = {} - - async def close(self) -> None: - """关闭数据库连接""" - self._initialized = False - log.debug("SQLite storage closed") - - def _ensure_initialized(self): - """确保已初始化""" - if not self._initialized: - raise RuntimeError("SQLite manager not initialized") - - def _get_table_name(self, mode: str) -> str: - """根据 mode 获取对应的表名""" - if mode == "antigravity": - return "antigravity_credentials" - elif mode == "geminicli": - return "credentials" - else: - raise ValueError(f"Invalid mode: {mode}. Must be 'geminicli' or 'antigravity'") - - # ============ SQL 方法 ============ - - async def get_next_available_credential( - self, mode: str = "geminicli", model_name: Optional[str] = None - ) -> Optional[Tuple[str, Dict[str, Any]]]: - """ - 随机获取一个可用凭证(负载均衡) - - 未禁用 - - 如果提供了 model_name,还会检查模型级冷却和preview状态 - - 随机选择 - - Args: - mode: 凭证模式 ("geminicli" 或 "antigravity") - model_name: 完整模型名(如 "gemini-2.0-flash-exp", "gemini-3-flash-preview") - """ - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - current_time = time.time() - - if mode == "geminicli": - tier_clause = "" - if model_name and "pro" in model_name.lower(): - tier_clause = "AND (tier IS NULL OR tier != 'free')" - - async with db.execute(f""" - SELECT filename, credential_data, model_cooldowns, preview - FROM {table_name} - WHERE disabled = 0 {tier_clause} - ORDER BY RANDOM() - """) as cursor: - rows = await cursor.fetchall() - - if not model_name: - if rows: - filename, credential_json, _, _ = rows[0] - credential_data = json.loads(credential_json) - return filename, credential_data - return None - - is_preview_model = "preview" in model_name.lower() - non_preview_creds = [] - preview_creds = [] - - for filename, credential_json, model_cooldowns_json, preview in rows: - model_cooldowns = json.loads(model_cooldowns_json or '{}') - model_cooldown = model_cooldowns.get(model_name) - if model_cooldown is None or current_time >= model_cooldown: - if preview: - preview_creds.append((filename, credential_json)) - else: - non_preview_creds.append((filename, credential_json)) - - if is_preview_model: - if preview_creds: - filename, credential_json = preview_creds[0] - credential_data = json.loads(credential_json) - return filename, credential_data - else: - if non_preview_creds: - filename, credential_json = non_preview_creds[0] - credential_data = json.loads(credential_json) - return filename, credential_data - elif preview_creds: - filename, credential_json = preview_creds[0] - credential_data = json.loads(credential_json) - return filename, credential_data - - return None - else: - async with db.execute(f""" - SELECT filename, credential_data, model_cooldowns, enable_credit - FROM {table_name} - WHERE disabled = 0 - ORDER BY RANDOM() - """) as cursor: - rows = await cursor.fetchall() - - if not model_name: - if rows: - filename, credential_json, _, enable_credit = rows[0] - credential_data = json.loads(credential_json) - credential_data["enable_credit"] = bool(enable_credit) - return filename, credential_data - return None - - for filename, credential_json, model_cooldowns_json, enable_credit in rows: - model_cooldowns = json.loads(model_cooldowns_json or '{}') - model_cooldown = model_cooldowns.get(model_name) - if model_cooldown is None or current_time >= model_cooldown: - credential_data = json.loads(credential_json) - credential_data["enable_credit"] = bool(enable_credit) - return filename, credential_data - - return None - - except Exception as e: - log.error(f"Error getting next available credential (mode={mode}, model_name={model_name}): {e}") - return None - - async def get_available_credentials_list(self) -> List[str]: - """ - 获取所有可用凭证列表 - - 未禁用 - - 按轮换顺序排序 - """ - self._ensure_initialized() - - try: - async with aiosqlite.connect(self._db_path) as db: - async with db.execute(""" - SELECT filename - FROM credentials - WHERE disabled = 0 - ORDER BY rotation_order ASC - """) as cursor: - rows = await cursor.fetchall() - return [row[0] for row in rows] - - except Exception as e: - log.error(f"Error getting available credentials list: {e}") - return [] - - # ============ StorageBackend 协议方法 ============ - - async def store_credential(self, filename: str, credential_data: Dict[str, Any], mode: str = "geminicli") -> bool: - """存储或更新凭证""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 检查凭证是否存在 - async with db.execute(f""" - SELECT disabled, error_codes, last_success, user_email, - rotation_order, call_count - FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - existing = await cursor.fetchone() - - if existing: - # 更新现有凭证(保留状态) - await db.execute(f""" - UPDATE {table_name} - SET credential_data = ?, - updated_at = unixepoch() - WHERE filename = ? - """, (json.dumps(credential_data), filename)) - else: - # 插入新凭证 - async with db.execute(f""" - SELECT COALESCE(MAX(rotation_order), -1) + 1 FROM {table_name} - """) as cursor: - row = await cursor.fetchone() - next_order = row[0] - - await db.execute(f""" - INSERT INTO {table_name} - (filename, credential_data, rotation_order, last_success) - VALUES (?, ?, ?, ?) - """, (filename, json.dumps(credential_data), next_order, time.time())) - - await db.commit() - log.debug(f"Stored credential: {filename} (mode={mode})") - return True - - except Exception as e: - log.error(f"Error storing credential {filename}: {e}") - return False - - async def get_credential(self, filename: str, mode: str = "geminicli") -> Optional[Dict[str, Any]]: - """获取凭证数据""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 精确匹配 - async with db.execute(f""" - SELECT credential_data FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - row = await cursor.fetchone() - if row: - return json.loads(row[0]) - - return None - - except Exception as e: - log.error(f"Error getting credential {filename}: {e}") - return None - - async def list_credentials(self, mode: str = "geminicli") -> List[str]: - """列出所有凭证文件名(包括禁用的)""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - async with db.execute(f""" - SELECT filename FROM {table_name} ORDER BY rotation_order - """) as cursor: - rows = await cursor.fetchall() - return [row[0] for row in rows] - - except Exception as e: - log.error(f"Error listing credentials: {e}") - return [] - - async def delete_credential(self, filename: str, mode: str = "geminicli") -> bool: - """删除凭证""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 精确匹配删除 - result = await db.execute(f""" - DELETE FROM {table_name} WHERE filename = ? - """, (filename,)) - deleted_count = result.rowcount - - await db.commit() - - if deleted_count > 0: - log.debug(f"Deleted {deleted_count} credential(s): {filename} (mode={mode})") - return True - else: - log.warning(f"No credential found to delete: {filename} (mode={mode})") - return False - - except Exception as e: - log.error(f"Error deleting credential {filename}: {e}") - return False - - async def update_credential_state(self, filename: str, state_updates: Dict[str, Any], mode: str = "geminicli") -> bool: - """更新凭证状态""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - log.debug(f"[DB] update_credential_state 开始: filename={filename}, state_updates={state_updates}, mode={mode}, table={table_name}") - - # 构建动态 SQL - set_clauses = [] - values = [] - - for key, value in state_updates.items(): - if key in self.STATE_FIELDS: - if key == "enable_credit" and mode != "antigravity": - continue - if key in ("error_codes", "error_messages", "model_cooldowns"): - # JSON 字段需要序列化 - set_clauses.append(f"{key} = ?") - values.append(json.dumps(value)) - else: - set_clauses.append(f"{key} = ?") - values.append(value) - - if not set_clauses: - log.info(f"[DB] 没有需要更新的状态字段") - return True - - set_clauses.append("updated_at = unixepoch()") - values.append(filename) - - log.debug(f"[DB] SQL参数: set_clauses={set_clauses}, values={values}") - - async with aiosqlite.connect(self._db_path) as db: - # 精确匹配更新 - sql_exact = f""" - UPDATE {table_name} - SET {', '.join(set_clauses)} - WHERE filename = ? - """ - log.debug(f"[DB] 执行精确匹配SQL: {sql_exact}") - log.debug(f"[DB] SQL参数值: {values}") - - result = await db.execute(sql_exact, values) - updated_count = result.rowcount - log.debug(f"[DB] 精确匹配 rowcount={updated_count}") - - # 提交前检查 - log.debug(f"[DB] 准备commit,总更新行数={updated_count}") - await db.commit() - log.debug(f"[DB] commit完成") - - success = updated_count > 0 - log.debug(f"[DB] update_credential_state 结束: success={success}, updated_count={updated_count}") - return success - - except Exception as e: - log.error(f"[DB] Error updating credential state {filename}: {e}") - return False - - async def get_credential_state(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """获取凭证状态(不包含error_messages)""" - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 精确匹配 - if mode == "geminicli": - async with db.execute(f""" - SELECT disabled, error_codes, last_success, user_email, model_cooldowns, preview, tier - FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - row = await cursor.fetchone() - - if row: - error_codes_json = row[1] or '[]' - model_cooldowns_json = row[4] or '{}' - return { - "disabled": bool(row[0]), - "error_codes": json.loads(error_codes_json), - "last_success": row[2] or time.time(), - "user_email": row[3], - "model_cooldowns": json.loads(model_cooldowns_json), - "preview": bool(row[5]) if row[5] is not None else True, - "tier": row[6] if row[6] is not None else "pro", - } - - # 返回默认状态 - return { - "disabled": False, - "error_codes": [], - "last_success": time.time(), - "user_email": None, - "model_cooldowns": {}, - "preview": True, - "tier": "pro", - } - else: - # antigravity 模式 - async with db.execute(f""" - SELECT disabled, error_codes, last_success, user_email, model_cooldowns, tier, enable_credit - FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - row = await cursor.fetchone() - - if row: - error_codes_json = row[1] or '[]' - model_cooldowns_json = row[4] or '{}' - return { - "disabled": bool(row[0]), - "error_codes": json.loads(error_codes_json), - "last_success": row[2] or time.time(), - "user_email": row[3], - "model_cooldowns": json.loads(model_cooldowns_json), - "tier": row[5] if row[5] is not None else "pro", - "enable_credit": bool(row[6]) if row[6] is not None else False, - } - - # 返回默认状态 - return { - "disabled": False, - "error_codes": [], - "last_success": time.time(), - "user_email": None, - "model_cooldowns": {}, - "tier": "pro", - "enable_credit": False, - } - - except Exception as e: - log.error(f"Error getting credential state {filename}: {e}") - return {} - - async def get_all_credential_states(self, mode: str = "geminicli") -> Dict[str, Dict[str, Any]]: - """获取所有凭证状态(不包含error_messages)""" - self._ensure_initialized() - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - if mode == "geminicli": - async with db.execute(f""" - SELECT filename, disabled, error_codes, last_success, - user_email, model_cooldowns, preview, tier - FROM {table_name} - """) as cursor: - rows = await cursor.fetchall() - - states = {} - current_time = time.time() - - for row in rows: - filename = row[0] - error_codes_json = row[2] or '[]' - model_cooldowns_json = row[5] or '{}' - model_cooldowns = json.loads(model_cooldowns_json) - - # 自动过滤掉已过期的模型CD - if model_cooldowns: - model_cooldowns = { - k: v for k, v in model_cooldowns.items() - if v > current_time - } - - states[filename] = { - "disabled": bool(row[1]), - "error_codes": json.loads(error_codes_json), - "last_success": row[3] or time.time(), - "user_email": row[4], - "model_cooldowns": model_cooldowns, - "preview": bool(row[6]) if row[6] is not None else True, - "tier": row[7] if row[7] is not None else "pro", - } - - return states - else: - # antigravity 模式 - async with db.execute(f""" - SELECT filename, disabled, error_codes, last_success, - user_email, model_cooldowns, tier, enable_credit - FROM {table_name} - """) as cursor: - rows = await cursor.fetchall() - - states = {} - current_time = time.time() - - for row in rows: - filename = row[0] - error_codes_json = row[2] or '[]' - model_cooldowns_json = row[5] or '{}' - model_cooldowns = json.loads(model_cooldowns_json) - - # 自动过滤掉已过期的模型CD - if model_cooldowns: - model_cooldowns = { - k: v for k, v in model_cooldowns.items() - if v > current_time - } - - states[filename] = { - "disabled": bool(row[1]), - "error_codes": json.loads(error_codes_json), - "last_success": row[3] or time.time(), - "user_email": row[4], - "model_cooldowns": model_cooldowns, - "tier": row[6] if row[6] is not None else "pro", - "enable_credit": bool(row[7]) if row[7] is not None else False, - } - - return states - - except Exception as e: - log.error(f"Error getting all credential states: {e}") - return {} - - async def get_credentials_summary( - self, - offset: int = 0, - limit: Optional[int] = None, - status_filter: str = "all", - mode: str = "geminicli", - error_code_filter: Optional[str] = None, - cooldown_filter: Optional[str] = None, - preview_filter: Optional[str] = None, - tier_filter: Optional[str] = None - ) -> Dict[str, Any]: - """ - 获取凭证的摘要信息(不包含完整凭证数据)- 支持分页和状态筛选 - - Args: - offset: 跳过的记录数(默认0) - limit: 返回的最大记录数(None表示返回所有) - status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用) - mode: 凭证模式 ("geminicli" 或 "antigravity") - error_code_filter: 错误码筛选(格式如"400"或"403",筛选包含该错误码的凭证) - cooldown_filter: 冷却状态筛选("in_cooldown"=冷却中, "no_cooldown"=未冷却) - preview_filter: Preview筛选("preview"=支持preview, "no_preview"=不支持preview,仅geminicli模式有效) - tier_filter: tier筛选("free", "pro", "ultra") - - Returns: - 包含 items(凭证列表)、total(总数)、offset、limit 的字典 - """ - self._ensure_initialized() - - try: - # 根据 mode 选择表名 - table_name = self._get_table_name(mode) - - async with aiosqlite.connect(self._db_path) as db: - # 先计算全局统计数据(不受筛选条件影响) - global_stats = {"total": 0, "normal": 0, "disabled": 0} - async with db.execute(f""" - SELECT disabled, COUNT(*) FROM {table_name} GROUP BY disabled - """) as stats_cursor: - stats_rows = await stats_cursor.fetchall() - for disabled, count in stats_rows: - global_stats["total"] += count - if disabled: - global_stats["disabled"] = count - else: - global_stats["normal"] = count - - # 构建WHERE子句 - where_clauses = [] - count_params = [] - - if status_filter == "enabled": - where_clauses.append("disabled = 0") - elif status_filter == "disabled": - where_clauses.append("disabled = 1") - - filter_value = None - filter_int = None - filter_none = False - if error_code_filter and str(error_code_filter).strip().lower() != "all": - if str(error_code_filter).strip().lower() == "none": - filter_none = True - else: - filter_value = str(error_code_filter).strip() - try: - filter_int = int(filter_value) - except ValueError: - filter_int = None - - # 构建WHERE子句 - where_clause = "" - if where_clauses: - where_clause = "WHERE " + " AND ".join(where_clauses) - - # 先获取所有数据(用于冷却筛选,因为需要在Python中判断) - if mode == "geminicli": - all_query = f""" - SELECT filename, disabled, error_codes, last_success, - user_email, rotation_order, model_cooldowns, preview, tier - FROM {table_name} - {where_clause} - ORDER BY rotation_order - """ - else: - all_query = f""" - SELECT filename, disabled, error_codes, last_success, - user_email, rotation_order, model_cooldowns, tier, enable_credit - FROM {table_name} - {where_clause} - ORDER BY rotation_order - """ - - async with db.execute(all_query, count_params) as cursor: - all_rows = await cursor.fetchall() - - current_time = time.time() - all_summaries = [] - - for row in all_rows: - filename = row[0] - error_codes_json = row[2] or '[]' - model_cooldowns_json = row[6] or '{}' - model_cooldowns = json.loads(model_cooldowns_json) - - # 自动过滤掉已过期的模型CD - active_cooldowns = {} - if model_cooldowns: - active_cooldowns = { - k: v for k, v in model_cooldowns.items() - if v > current_time - } - - error_codes = json.loads(error_codes_json) - - # 筛选无错误的凭证 - if filter_none: - if error_codes: - continue - - if filter_value: - match = False - for code in error_codes: - if code == filter_value or code == filter_int: - match = True - break - if isinstance(code, str) and filter_int is not None: - try: - if int(code) == filter_int: - match = True - break - except ValueError: - pass - if not match: - continue - - summary = { - "filename": filename, - "disabled": bool(row[1]), - "error_codes": error_codes, - "last_success": row[3] or current_time, - "user_email": row[4], - "rotation_order": row[5], - "model_cooldowns": active_cooldowns, - "tier": row[8] if mode == "geminicli" and row[8] is not None else ( - row[7] if mode != "geminicli" and row[7] is not None else "pro" - ), - } - - if mode != "geminicli": - summary["enable_credit"] = bool(row[8]) if row[8] is not None else False - - if mode == "geminicli": - summary["preview"] = bool(row[7]) if row[7] is not None else True - - if preview_filter: - preview_value = summary.get("preview", True) - if preview_filter == "preview" and not preview_value: - continue - elif preview_filter == "no_preview" and preview_value: - continue - - # 应用tier筛选 - if tier_filter and tier_filter in ("free", "pro", "ultra"): - if summary["tier"] != tier_filter: - continue - - # 应用冷却筛选 - if cooldown_filter == "in_cooldown": - # 只保留有冷却的凭证 - if active_cooldowns: - all_summaries.append(summary) - elif cooldown_filter == "no_cooldown": - # 只保留没有冷却的凭证 - if not active_cooldowns: - all_summaries.append(summary) - else: - # 不筛选冷却状态 - all_summaries.append(summary) - - # 应用分页 - total_count = len(all_summaries) - if limit is not None: - summaries = all_summaries[offset:offset + limit] - else: - summaries = all_summaries[offset:] - - return { - "items": summaries, - "total": total_count, - "offset": offset, - "limit": limit, - "stats": global_stats, - } - - except Exception as e: - log.error(f"Error getting credentials summary: {e}") - return { - "items": [], - "total": 0, - "offset": offset, - "limit": limit, - "stats": {"total": 0, "normal": 0, "disabled": 0}, - } - - async def get_duplicate_credentials_by_email(self, mode: str = "geminicli") -> Dict[str, Any]: - """ - 获取按邮箱分组的重复凭证信息(只查询邮箱和文件名,不加载完整凭证数据) - 用于去重操作 - - Args: - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 包含 email_groups(邮箱分组)、duplicate_count(重复数量)、no_email_count(无邮箱数量)的字典 - """ - self._ensure_initialized() - - try: - # 根据 mode 选择表名 - table_name = self._get_table_name(mode) - - async with aiosqlite.connect(self._db_path) as db: - # 查询所有凭证的文件名和邮箱(不加载完整凭证数据) - query = f""" - SELECT filename, user_email - FROM {table_name} - ORDER BY filename - """ - - async with db.execute(query) as cursor: - rows = await cursor.fetchall() - - # 按邮箱分组 - email_to_files = {} - no_email_files = [] - - for filename, user_email in rows: - if user_email: - if user_email not in email_to_files: - email_to_files[user_email] = [] - email_to_files[user_email].append(filename) - else: - no_email_files.append(filename) - - # 找出重复的邮箱组 - duplicate_groups = [] - total_duplicate_count = 0 - - for email, files in email_to_files.items(): - if len(files) > 1: - # 保留第一个文件,其他为重复 - duplicate_groups.append({ - "email": email, - "kept_file": files[0], - "duplicate_files": files[1:], - "duplicate_count": len(files) - 1, - }) - total_duplicate_count += len(files) - 1 - - return { - "email_groups": email_to_files, - "duplicate_groups": duplicate_groups, - "duplicate_count": total_duplicate_count, - "no_email_files": no_email_files, - "no_email_count": len(no_email_files), - "unique_email_count": len(email_to_files), - "total_count": len(rows), - } - - except Exception as e: - log.error(f"Error getting duplicate credentials by email: {e}") - return { - "email_groups": {}, - "duplicate_groups": [], - "duplicate_count": 0, - "no_email_files": [], - "no_email_count": 0, - "unique_email_count": 0, - "total_count": 0, - } - - # ============ 配置管理(内存缓存)============ - - async def set_config(self, key: str, value: Any) -> bool: - """设置配置(写入数据库 + 更新内存缓存)""" - self._ensure_initialized() - - try: - async with aiosqlite.connect(self._db_path) as db: - await db.execute(""" - INSERT INTO config (key, value, updated_at) - VALUES (?, ?, unixepoch()) - ON CONFLICT(key) DO UPDATE SET - value = excluded.value, - updated_at = excluded.updated_at - """, (key, json.dumps(value))) - await db.commit() - - # 更新内存缓存 - self._config_cache[key] = value - return True - - except Exception as e: - log.error(f"Error setting config {key}: {e}") - return False - - async def reload_config_cache(self): - """重新加载配置缓存(在批量修改配置后调用)""" - self._ensure_initialized() - self._config_loaded = False - await self._load_config_cache() - log.info("Config cache reloaded from database") - - async def get_config(self, key: str, default: Any = None) -> Any: - """获取配置(从内存缓存)""" - self._ensure_initialized() - return self._config_cache.get(key, default) - - async def get_all_config(self) -> Dict[str, Any]: - """获取所有配置(从内存缓存)""" - self._ensure_initialized() - return self._config_cache.copy() - - async def delete_config(self, key: str) -> bool: - """删除配置""" - self._ensure_initialized() - - try: - async with aiosqlite.connect(self._db_path) as db: - await db.execute("DELETE FROM config WHERE key = ?", (key,)) - await db.commit() - - # 从内存缓存移除 - self._config_cache.pop(key, None) - return True - - except Exception as e: - log.error(f"Error deleting config {key}: {e}") - return False - - async def get_credential_errors(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """ - 专门获取凭证的错误信息(包含 error_codes 和 error_messages) - - Args: - filename: 凭证文件名 - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 包含 error_codes 和 error_messages 的字典 - """ - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 精确匹配 - async with db.execute(f""" - SELECT error_codes, error_messages FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - row = await cursor.fetchone() - - if row: - error_codes_json = row[0] or '[]' - error_messages_json = row[1] or '[]' - return { - "filename": filename, - "error_codes": json.loads(error_codes_json), - "error_messages": json.loads(error_messages_json), - } - - # 凭证不存在,返回空错误信息 - return { - "filename": filename, - "error_codes": [], - "error_messages": [], - } - - except Exception as e: - log.error(f"Error getting credential errors {filename}: {e}") - return { - "filename": filename, - "error_codes": [], - "error_messages": [], - "error": str(e) - } - - # ============ 模型级冷却管理 ============ - - async def set_model_cooldown( - self, - filename: str, - model_name: str, - cooldown_until: Optional[float], - mode: str = "geminicli" - ) -> bool: - """ - 设置特定模型的冷却时间 - - Args: - filename: 凭证文件名 - model_name: 模型名(完整模型名,如 "gemini-2.0-flash-exp") - cooldown_until: 冷却截止时间戳(None 表示清除冷却) - mode: 凭证模式 ("geminicli" 或 "antigravity") - - Returns: - 是否成功 - """ - self._ensure_initialized() - - # 统一使用 basename 处理文件名 - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 获取当前的 model_cooldowns - async with db.execute(f""" - SELECT model_cooldowns FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - row = await cursor.fetchone() - - if not row: - log.warning(f"Credential {filename} not found") - return False - - model_cooldowns = json.loads(row[0] or '{}') - - # 更新或删除指定模型的冷却时间 - if cooldown_until is None: - model_cooldowns.pop(model_name, None) - else: - model_cooldowns[model_name] = cooldown_until - - # 写回数据库 - await db.execute(f""" - UPDATE {table_name} - SET model_cooldowns = ?, - updated_at = unixepoch() - WHERE filename = ? - """, (json.dumps(model_cooldowns), filename)) - await db.commit() - - log.debug(f"Set model cooldown: {filename}, model_name={model_name}, cooldown_until={cooldown_until}") - return True - - except Exception as e: - log.error(f"Error setting model cooldown for {filename}: {e}") - return False - - async def clear_all_model_cooldowns( - self, - filename: str, - mode: str = "geminicli" - ) -> bool: - """清除某个凭证的所有模型冷却时间""" - self._ensure_initialized() - - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - result = await db.execute(f""" - UPDATE {table_name} - SET model_cooldowns = '{{}}', - updated_at = unixepoch() - WHERE filename = ? - """, (filename,)) - updated_count = result.rowcount - await db.commit() - - if updated_count == 0: - log.warning(f"Credential {filename} not found") - return False - - log.debug(f"Cleared all model cooldowns: {filename} (mode={mode})") - return True - - except Exception as e: - log.error(f"Error clearing all model cooldowns for {filename}: {e}") - return False - - async def record_success( - self, - filename: str, - model_name: Optional[str] = None, - mode: str = "geminicli" - ) -> None: - """ - 成功调用后的条件写入: - - 只有当前 error_codes 非空时才清除错误并写 last_success - - 只有当前存在该模型的冷却键时才清除 - 通过 SQL WHERE 条件匹配实现 - """ - self._ensure_initialized() - filename = os.path.basename(filename) - - try: - table_name = self._get_table_name(mode) - async with aiosqlite.connect(self._db_path) as db: - # 条件写入:只有 error_codes 非空时才触发 - await db.execute(f""" - UPDATE {table_name} - SET last_success = unixepoch(), - error_codes = '[]', - error_messages = '{{}}', - updated_at = unixepoch() - WHERE filename = ? - AND (error_codes IS NOT NULL AND error_codes != '[]' AND error_codes != '') - """, (filename,)) - - # 条件删除模型冷却:只有模型键存在时才写入 - if model_name: - async with db.execute(f""" - SELECT model_cooldowns FROM {table_name} WHERE filename = ? - """, (filename,)) as cursor: - row = await cursor.fetchone() - if row: - cooldowns = json.loads(row[0] or '{}') - if model_name in cooldowns: - cooldowns.pop(model_name) - await db.execute(f""" - UPDATE {table_name} - SET model_cooldowns = ?, updated_at = unixepoch() - WHERE filename = ? - """, (json.dumps(cooldowns), filename)) - - await db.commit() - - except Exception as e: - log.error(f"Error recording success for {filename}: {e}") \ No newline at end of file diff --git a/src/storage_adapter.py b/src/storage_adapter.py deleted file mode 100644 index 134a0807bda837e51dc395004d0a5354b52af66e..0000000000000000000000000000000000000000 --- a/src/storage_adapter.py +++ /dev/null @@ -1,343 +0,0 @@ -""" -存储适配器,提供统一的接口来处理 SQLite 和 MongoDB 存储。 -根据配置自动选择存储后端: -- 默认使用 SQLite(本地文件存储) -- 如果设置了 MONGODB_URI 环境变量,则使用 MongoDB -""" - -import asyncio -import json -import os -from typing import Any, Dict, List, Optional, Protocol - -from log import log - - -class StorageBackend(Protocol): - """存储后端协议""" - - async def initialize(self) -> None: - """初始化存储后端""" - ... - - async def close(self) -> None: - """关闭存储后端""" - ... - - # 凭证管理 - async def store_credential(self, filename: str, credential_data: Dict[str, Any], mode: str = "geminicli") -> bool: - """存储凭证数据""" - ... - - async def get_credential(self, filename: str, mode: str = "geminicli") -> Optional[Dict[str, Any]]: - """获取凭证数据""" - ... - - async def list_credentials(self, mode: str = "geminicli") -> List[str]: - """列出所有凭证文件名""" - ... - - async def delete_credential(self, filename: str, mode: str = "geminicli") -> bool: - """删除凭证""" - ... - - # 状态管理 - async def update_credential_state(self, filename: str, state_updates: Dict[str, Any], mode: str = "geminicli") -> bool: - """更新凭证状态""" - ... - - async def get_credential_state(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """获取凭证状态""" - ... - - async def get_all_credential_states(self, mode: str = "geminicli") -> Dict[str, Dict[str, Any]]: - """获取所有凭证状态""" - ... - - # 配置管理 - async def set_config(self, key: str, value: Any) -> bool: - """设置配置项""" - ... - - async def get_config(self, key: str, default: Any = None) -> Any: - """获取配置项""" - ... - - async def get_all_config(self) -> Dict[str, Any]: - """获取所有配置""" - ... - - async def delete_config(self, key: str) -> bool: - """删除配置项""" - ... - - -class StorageAdapter: - """存储适配器,根据配置选择存储后端""" - - def __init__(self): - self._backend: Optional["StorageBackend"] = None - self._initialized = False - self._lock = asyncio.Lock() - - async def initialize(self) -> None: - """初始化存储适配器""" - async with self._lock: - if self._initialized: - return - - # 按优先级检查存储后端:PostgreSQL > MongoDB > SQLite - postgresql_uri = os.getenv("POSTGRESQL_URI", "") - mongodb_uri = os.getenv("MONGODB_URI", "") - - if postgresql_uri: - # 使用 PostgreSQL - try: - from .storage.psql_manager import PSQLManager - - self._backend = PSQLManager() - await self._backend.initialize() - log.info("Using PostgreSQL storage backend") - except Exception as e: - log.error(f"Failed to initialize PostgreSQL backend: {e}") - # 尝试降级到 SQLite - log.info("Falling back to SQLite storage backend") - try: - from .storage.sqlite_manager import SQLiteManager - - self._backend = SQLiteManager() - await self._backend.initialize() - log.info("Using SQLite storage backend (fallback)") - except Exception as e2: - log.error(f"Failed to initialize SQLite backend: {e2}") - raise RuntimeError("No storage backend available") from e2 - elif not mongodb_uri: - # 优先使用 SQLite(默认启用,无需环境变量) - try: - from .storage.sqlite_manager import SQLiteManager - - self._backend = SQLiteManager() - await self._backend.initialize() - log.info("Using SQLite storage backend") - except Exception as e: - log.error(f"Failed to initialize SQLite backend: {e}") - raise RuntimeError("No storage backend available") from e - else: - # 使用 MongoDB - try: - from .storage.mongodb_manager import MongoDBManager - - self._backend = MongoDBManager() - await self._backend.initialize() - log.info("Using MongoDB storage backend") - except Exception as e: - log.error(f"Failed to initialize MongoDB backend: {e}") - # 尝试降级到 SQLite - log.info("Falling back to SQLite storage backend") - try: - from .storage.sqlite_manager import SQLiteManager - - self._backend = SQLiteManager() - await self._backend.initialize() - log.info("Using SQLite storage backend (fallback)") - except Exception as e2: - log.error(f"Failed to initialize SQLite backend: {e2}") - raise RuntimeError("No storage backend available") from e2 - - self._initialized = True - - async def close(self) -> None: - """关闭存储适配器""" - if self._backend: - await self._backend.close() - self._backend = None - self._initialized = False - - def _ensure_initialized(self): - """确保存储适配器已初始化""" - if not self._initialized or not self._backend: - raise RuntimeError("Storage adapter not initialized") - - # ============ 凭证管理 ============ - - async def store_credential(self, filename: str, credential_data: Dict[str, Any], mode: str = "geminicli") -> bool: - """存储凭证数据""" - self._ensure_initialized() - return await self._backend.store_credential(filename, credential_data, mode) - - async def get_credential(self, filename: str, mode: str = "geminicli") -> Optional[Dict[str, Any]]: - """获取凭证数据""" - self._ensure_initialized() - return await self._backend.get_credential(filename, mode) - - async def list_credentials(self, mode: str = "geminicli") -> List[str]: - """列出所有凭证文件名""" - self._ensure_initialized() - return await self._backend.list_credentials(mode) - - async def delete_credential(self, filename: str, mode: str = "geminicli") -> bool: - """删除凭证""" - self._ensure_initialized() - return await self._backend.delete_credential(filename, mode) - - # ============ 状态管理 ============ - - async def update_credential_state(self, filename: str, state_updates: Dict[str, Any], mode: str = "geminicli") -> bool: - """更新凭证状态""" - self._ensure_initialized() - return await self._backend.update_credential_state(filename, state_updates, mode) - - async def get_credential_state(self, filename: str, mode: str = "geminicli") -> Dict[str, Any]: - """获取凭证状态""" - self._ensure_initialized() - return await self._backend.get_credential_state(filename, mode) - - async def get_all_credential_states(self, mode: str = "geminicli") -> Dict[str, Dict[str, Any]]: - """获取所有凭证状态""" - self._ensure_initialized() - return await self._backend.get_all_credential_states(mode) - - # ============ 配置管理 ============ - - async def set_config(self, key: str, value: Any) -> bool: - """设置配置项""" - self._ensure_initialized() - return await self._backend.set_config(key, value) - - async def get_config(self, key: str, default: Any = None) -> Any: - """获取配置项""" - self._ensure_initialized() - return await self._backend.get_config(key, default) - - async def get_all_config(self) -> Dict[str, Any]: - """获取所有配置""" - self._ensure_initialized() - return await self._backend.get_all_config() - - async def delete_config(self, key: str) -> bool: - """删除配置项""" - self._ensure_initialized() - return await self._backend.delete_config(key) - - # ============ 工具方法 ============ - - async def export_credential_to_json(self, filename: str, output_path: str = None) -> bool: - """将凭证导出为JSON文件""" - self._ensure_initialized() - if hasattr(self._backend, "export_credential_to_json"): - return await self._backend.export_credential_to_json(filename, output_path) - # MongoDB后端的fallback实现 - credential_data = await self.get_credential(filename) - if credential_data is None: - return False - - if output_path is None: - output_path = f"{filename}.json" - - import aiofiles - - try: - async with aiofiles.open(output_path, "w", encoding="utf-8") as f: - await f.write(json.dumps(credential_data, indent=2, ensure_ascii=False)) - return True - except Exception: - return False - - async def import_credential_from_json(self, json_path: str, filename: str = None) -> bool: - """从JSON文件导入凭证""" - self._ensure_initialized() - if hasattr(self._backend, "import_credential_from_json"): - return await self._backend.import_credential_from_json(json_path, filename) - # MongoDB后端的fallback实现 - try: - import aiofiles - - async with aiofiles.open(json_path, "r", encoding="utf-8") as f: - content = await f.read() - - credential_data = json.loads(content) - - if filename is None: - filename = os.path.basename(json_path) - - return await self.store_credential(filename, credential_data) - except Exception: - return False - - def get_backend_type(self) -> str: - """获取当前存储后端类型""" - if not self._backend: - return "none" - - # 检查后端类型 - backend_class_name = self._backend.__class__.__name__ - if "SQLite" in backend_class_name or "sqlite" in backend_class_name.lower(): - return "sqlite" - elif "MongoDB" in backend_class_name or "mongo" in backend_class_name.lower(): - return "mongodb" - elif "PSQL" in backend_class_name or "Postgres" in backend_class_name or "psql" in backend_class_name.lower(): - return "postgresql" - else: - return "unknown" - - async def get_backend_info(self) -> Dict[str, Any]: - """获取存储后端信息""" - self._ensure_initialized() - - backend_type = self.get_backend_type() - info = {"backend_type": backend_type, "initialized": self._initialized} - - # 获取底层存储信息 - if hasattr(self._backend, "get_database_info"): - try: - db_info = await self._backend.get_database_info() - info.update(db_info) - except Exception as e: - info["database_error"] = str(e) - else: - backend_type = self.get_backend_type() - if backend_type == "sqlite": - info.update( - { - "database_path": getattr(self._backend, "_db_path", None), - "credentials_dir": getattr(self._backend, "_credentials_dir", None), - } - ) - elif backend_type == "mongodb": - info.update( - { - "database_name": getattr(self._backend, "_db", {}).name if hasattr(self._backend, "_db") else None, - } - ) - elif backend_type == "postgresql": - info.update( - { - "dsn": getattr(self._backend, "_dsn", None), - } - ) - - return info - - -# 全局存储适配器实例 -_storage_adapter: Optional[StorageAdapter] = None - - -async def get_storage_adapter() -> StorageAdapter: - """获取全局存储适配器实例""" - global _storage_adapter - - if _storage_adapter is None: - _storage_adapter = StorageAdapter() - await _storage_adapter.initialize() - - return _storage_adapter - - -async def close_storage_adapter(): - """关闭全局存储适配器""" - global _storage_adapter - - if _storage_adapter: - await _storage_adapter.close() - _storage_adapter = None diff --git a/src/task_manager.py b/src/task_manager.py deleted file mode 100644 index 9e0343ab1918c8e871e706b7a63676c536fb42af..0000000000000000000000000000000000000000 --- a/src/task_manager.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Global task lifecycle management module -管理应用程序中所有异步任务的生命周期,确保正确清理 -""" - -import asyncio -import weakref -from typing import Any, Dict, Set - -from log import log - - -class TaskManager: - """全局异步任务管理器 - 单例模式""" - - _instance = None - _lock = asyncio.Lock() - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - - self._tasks: Set[asyncio.Task] = set() - self._resources: Set[Any] = set() # 需要关闭的资源 - self._shutdown_event = asyncio.Event() - self._initialized = True - log.debug("TaskManager initialized") - - def register_task(self, task: asyncio.Task, description: str = None) -> asyncio.Task: - """注册一个任务供生命周期管理""" - self._tasks.add(task) - task.add_done_callback(lambda t: self._tasks.discard(t)) - - if description: - task.set_name(description) - - log.debug(f"Registered task: {task.get_name() or 'unnamed'}") - return task - - def create_task(self, coro, *, name: str = None) -> asyncio.Task: - """创建并注册一个任务""" - task = asyncio.create_task(coro, name=name) - return self.register_task(task, name) - - def register_resource(self, resource: Any) -> Any: - """注册一个需要清理的资源(如HTTP客户端、文件句柄等)""" - # 使用弱引用避免循环引用 - self._resources.add(weakref.ref(resource)) - log.debug(f"Registered resource: {type(resource).__name__}") - return resource - - async def shutdown(self, timeout: float = 30.0): - """关闭所有任务和资源""" - log.info("TaskManager shutdown initiated") - - # 设置关闭标志 - self._shutdown_event.set() - - # 取消所有未完成的任务 - cancelled_count = 0 - for task in list(self._tasks): - if not task.done(): - task.cancel() - cancelled_count += 1 - - if cancelled_count > 0: - log.info(f"Cancelled {cancelled_count} pending tasks") - - # 等待所有任务完成(包括取消) - if self._tasks: - try: - await asyncio.wait_for( - asyncio.gather(*self._tasks, return_exceptions=True), timeout=timeout - ) - except asyncio.TimeoutError: - log.warning(f"Some tasks did not complete within {timeout}s timeout") - - # 清理资源 - 改进弱引用处理 - cleaned_resources = 0 - failed_resources = 0 - for resource_ref in list(self._resources): - resource = resource_ref() - if resource is not None: - try: - if hasattr(resource, "close"): - if asyncio.iscoroutinefunction(resource.close): - await resource.close() - else: - resource.close() - elif hasattr(resource, "aclose"): - await resource.aclose() - cleaned_resources += 1 - except Exception as e: - log.warning(f"Failed to close resource {type(resource).__name__}: {e}") - failed_resources += 1 - # 如果弱引用已失效,资源已经被自动回收,无需操作 - - if cleaned_resources > 0: - log.info(f"Cleaned up {cleaned_resources} resources") - if failed_resources > 0: - log.warning(f"Failed to clean {failed_resources} resources") - - self._tasks.clear() - self._resources.clear() - log.info("TaskManager shutdown completed") - - @property - def is_shutdown(self) -> bool: - """检查是否已经开始关闭""" - return self._shutdown_event.is_set() - - def get_stats(self) -> Dict[str, int]: - """获取任务管理统计信息""" - return { - "active_tasks": len(self._tasks), - "registered_resources": len(self._resources), - "is_shutdown": self.is_shutdown, - } - - -# 全局任务管理器实例 -task_manager = TaskManager() - - -def create_managed_task(coro, *, name: str = None) -> asyncio.Task: - """创建一个被管理的异步任务的便捷函数""" - return task_manager.create_task(coro, name=name) - - -def register_resource(resource: Any) -> Any: - """注册资源的便捷函数""" - return task_manager.register_resource(resource) - - -async def shutdown_all_tasks(timeout: float = 30.0): - """关闭所有任务的便捷函数""" - await task_manager.shutdown(timeout) diff --git a/src/token_estimator.py b/src/token_estimator.py deleted file mode 100644 index ed82cf28ce0d5805f6d3debe0531e16eb3db5290..0000000000000000000000000000000000000000 --- a/src/token_estimator.py +++ /dev/null @@ -1,30 +0,0 @@ -"""简单的 token 估算,不追求精确""" -from __future__ import annotations - -from typing import Any, Dict - - -def estimate_input_tokens(payload: Dict[str, Any]) -> int: - """粗略估算 token 数:字符数 / 4 + 图片固定值""" - total_chars = 0 - image_count = 0 - - # 统计所有文本字符 - def count_str(obj: Any) -> None: - nonlocal total_chars, image_count - if isinstance(obj, str): - total_chars += len(obj) - elif isinstance(obj, dict): - # 检测图片 - if obj.get("type") == "image" or "inlineData" in obj: - image_count += 1 - for v in obj.values(): - count_str(v) - elif isinstance(obj, list): - for item in obj: - count_str(item) - - count_str(payload) - - # 粗略估算:字符数/4 + 每张图片300 tokens - return max(1, total_chars // 4 + image_count * 300) diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index 6c89de220eb8e7280c58aa506a853451ec440040..0000000000000000000000000000000000000000 --- a/src/utils.py +++ /dev/null @@ -1,285 +0,0 @@ -from typing import List, Optional - -from config import get_api_password, get_panel_password -from fastapi import Depends, HTTPException, Header, Query, Request, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from log import log - -# HTTP Bearer security scheme -security = HTTPBearer() - -# ====================== OAuth Configuration ====================== - -_GEMINICLI_VERSION = "0.35.2" -_GEMINICLI_PLATFORM = "win32" -_GEMINICLI_ARCH = "x64" -_GEMINICLI_SURFACE = "cloud-shell" - -def get_geminicli_user_agent(model: str = "") -> str: - """生成动态 User-Agent: GeminiCLI/{version}/{model} ({platform}; {arch}; {surface})""" - if model: - return f"GeminiCLI/{_GEMINICLI_VERSION}/{model} ({_GEMINICLI_PLATFORM}; {_GEMINICLI_ARCH}; {_GEMINICLI_SURFACE})" - return f"GeminiCLI/{_GEMINICLI_VERSION} ({_GEMINICLI_PLATFORM}; {_GEMINICLI_ARCH}; {_GEMINICLI_SURFACE})" - -# 静态常量 -GEMINICLI_USER_AGENT = get_geminicli_user_agent() - -ANTIGRAVITY_USER_AGENT = "antigravity/2.22.2 windows/amd64" - -# OAuth Configuration - 标准模式 -CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" -CLIENT_SECRET = "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" -SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -] - -# Antigravity OAuth Configuration -ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" -ANTIGRAVITY_CLIENT_SECRET = "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" -ANTIGRAVITY_SCOPES = [ - 'https://www.googleapis.com/auth/cloud-platform', - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/cclog', - 'https://www.googleapis.com/auth/experimentsandconfigs' -] - -# 统一的 Token URL(两种模式相同) -TOKEN_URL = "https://oauth2.googleapis.com/token" - -# 回调服务器配置 -CALLBACK_HOST = "localhost" - -# Model name lists for different features -BASE_MODELS = [ - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-3-flash-preview", - "gemini-3.1-pro-preview", - "gemini-3.1-flash-lite-preview" -] - - -# ====================== Model Helper Functions ====================== - -def is_fake_streaming_model(model_name: str) -> bool: - """Check if model name indicates fake streaming should be used.""" - return model_name.startswith("假流式/") - - -def is_anti_truncation_model(model_name: str) -> bool: - """Check if model name indicates anti-truncation should be used.""" - return model_name.startswith("流式抗截断/") - - -def get_base_model_from_feature_model(model_name: str) -> str: - """Get base model name from feature model name.""" - # Remove feature prefixes - for prefix in ["假流式/", "流式抗截断/"]: - if model_name.startswith(prefix): - return model_name[len(prefix) :] - return model_name - - -def get_available_models(router_type: str = "openai") -> List[str]: - """ - Get available models with feature prefixes. - - Args: - router_type: "openai" or "gemini" - - Returns: - List of model names with feature prefixes - """ - models = [] - - for base_model in BASE_MODELS: - # 基础模型 - models.append(base_model) - - # 假流式模型 (前缀格式) - models.append(f"假流式/{base_model}") - - # 流式抗截断模型 (仅在流式传输时有效,前缀格式) - models.append(f"流式抗截断/{base_model}") - - # 定义思考后缀(根据模型系列不同) - thinking_suffixes = [] - - # Gemini 2.5 系列: 使用思考预算后缀 - if "gemini-2.5" in base_model: - thinking_suffixes = ["-max", "-high", "-medium", "-low", "-minimal"] - # Gemini 3 系列: 使用思考等级后缀 - elif "gemini-3" in base_model: - if "flash" in base_model: - # 3-flash-preview: 支持 high/medium/low/minimal - thinking_suffixes = ["-high", "-medium", "-low", "-minimal"] - elif "pro" in base_model: - # 3-pro-preview: 支持 high/low - thinking_suffixes = ["-low"] - - search_suffix = "-search" - - # 1. 单独的 thinking 后缀 - for thinking_suffix in thinking_suffixes: - models.append(f"{base_model}{thinking_suffix}") - models.append(f"假流式/{base_model}{thinking_suffix}") - models.append(f"流式抗截断/{base_model}{thinking_suffix}") - - # 2. 单独的 search 后缀 - models.append(f"{base_model}{search_suffix}") - models.append(f"假流式/{base_model}{search_suffix}") - models.append(f"流式抗截断/{base_model}{search_suffix}") - - # 3. thinking + search 组合后缀 - for thinking_suffix in thinking_suffixes: - combined_suffix = f"{thinking_suffix}{search_suffix}" - models.append(f"{base_model}{combined_suffix}") - models.append(f"假流式/{base_model}{combined_suffix}") - models.append(f"流式抗截断/{base_model}{combined_suffix}") - - return models - - -# ====================== Authentication Functions ====================== - -async def authenticate_flexible( - request: Request, - authorization: Optional[str] = Header(None), - x_api_key: Optional[str] = Header(None, alias="x-api-key"), - access_token: Optional[str] = Header(None, alias="access_token"), - x_goog_api_key: Optional[str] = Header(None, alias="x-goog-api-key"), - x_anthropic_auth_token: Optional[str] = Header(None, alias="x-anthropic-auth-token"), - anthropic_auth_token: Optional[str] = Header(None, alias="anthropic-auth-token"), - key: Optional[str] = Query(None) -) -> str: - """ - 统一的灵活认证函数,支持多种认证方式 - - 此函数可以直接用作 FastAPI 的 Depends 依赖 - - 支持的认证方式: - - URL 参数: key - - HTTP 头部: Authorization (Bearer token) - - HTTP 头部: x-api-key - - HTTP 头部: access_token - - HTTP 头部: x-goog-api-key - - HTTP 头部: x-anthropic-auth-token - - HTTP 头部: anthropic-auth-token - - Args: - request: FastAPI Request 对象 - authorization: Authorization 头部值(自动注入) - x_api_key: x-api-key 头部值(自动注入) - access_token: access_token 头部值(自动注入) - x_goog_api_key: x-goog-api-key 头部值(自动注入) - x_anthropic_auth_token: x-anthropic-auth-token 头部值(自动注入) - anthropic_auth_token: anthropic-auth-token 头部值(自动注入) - key: URL 参数 key(自动注入) - - Returns: - 验证通过的token - - Raises: - HTTPException: 认证失败时抛出异常 - - 使用示例: - @router.post("/endpoint") - async def endpoint(token: str = Depends(authenticate_flexible)): - # token 已验证通过 - pass - """ - password = await get_api_password() - token = None - auth_method = None - - # 1. 尝试从 URL 参数 key 获取(Google 官方标准方式) - if key: - token = key - auth_method = "URL parameter 'key'" - - # 2. 尝试从 x-goog-api-key 头部获取(Google API 标准方式) - elif x_goog_api_key: - token = x_goog_api_key - auth_method = "x-goog-api-key header" - - # 3. 尝试从 x-anthropic-auth-token 头部获取(Anthropic 标准方式) - elif x_anthropic_auth_token: - token = x_anthropic_auth_token - auth_method = "x-anthropic-auth-token header" - - # 4. 尝试从 anthropic-auth-token 头部获取(Anthropic 替代方式) - elif anthropic_auth_token: - token = anthropic_auth_token - auth_method = "anthropic-auth-token header" - - # 5. 尝试从 x-api-key 头部获取 - elif x_api_key: - token = x_api_key - auth_method = "x-api-key header" - - # 6. 尝试从 access_token 头部获取 - elif access_token: - token = access_token - auth_method = "access_token header" - - # 7. 尝试从 Authorization 头部获取 - elif authorization: - if not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication scheme. Use 'Bearer '", - headers={"WWW-Authenticate": "Bearer"}, - ) - token = authorization[7:] # 移除 "Bearer " 前缀 - auth_method = "Authorization Bearer header" - - # 检查是否提供了任何认证凭据 - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing authentication credentials. Use 'key' URL parameter, 'x-goog-api-key', 'x-anthropic-auth-token', 'anthropic-auth-token', 'x-api-key', 'access_token' header, or 'Authorization: Bearer '", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # 验证 token - if token != password: - log.debug(f"Authentication failed using {auth_method}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="密码错误" - ) - - log.debug(f"Authentication successful using {auth_method}") - return token - - -# 为了保持向后兼容,保留旧函数名作为别名 -authenticate_bearer = authenticate_flexible -authenticate_gemini_flexible = authenticate_flexible - - -# ====================== Panel Authentication Functions ====================== - -async def verify_panel_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: - """ - 简化的控制面板密码验证函数 - - 直接验证Bearer token是否等于控制面板密码 - - Args: - credentials: HTTPAuthorizationCredentials 自动注入 - - Returns: - 验证通过的token - - Raises: - HTTPException: 密码错误时抛出401异常 - """ - - password = await get_panel_password() - if credentials.credentials != password: - raise HTTPException(status_code=401, detail="密码错误") - return credentials.credentials diff --git a/start.bat b/start.bat deleted file mode 100644 index adb1ee9f9bf1c9de1e34c61cb5426ba63bccf586..0000000000000000000000000000000000000000 --- a/start.bat +++ /dev/null @@ -1,7 +0,0 @@ -git fetch --all -for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD') do set branch=%%b -git reset --hard origin/%branch% -uv sync -call .venv\Scripts\activate.bat -python web.py -pause \ No newline at end of file diff --git a/start.sh b/start.sh deleted file mode 100644 index 1b4a2f05f0941acf257ab84d15e88506544ae1e1..0000000000000000000000000000000000000000 --- a/start.sh +++ /dev/null @@ -1,6 +0,0 @@ -echo "强制同步项目代码,忽略本地修改..." -git fetch --all -git reset --hard origin/$(git rev-parse --abbrev-ref HEAD) -uv sync -source .venv/bin/activate -python web.py \ No newline at end of file diff --git a/termux-install.sh b/termux-install.sh deleted file mode 100644 index 579607761ec8f63658c6d55530268bd484adec38..0000000000000000000000000000000000000000 --- a/termux-install.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/bin/bash - -# 避免交互式提示 -export DEBIAN_FRONTEND=noninteractive - -if [ "$(whoami)" = "root" ]; then - echo "检测到root用户,正在退出..." - exit -fi - -echo "检查Termux镜像源配置..." - -# 检查当前镜像源是否已经是Cloudflare镜像 -target_mirror="https://packages-cf.termux.dev/apt/termux-main" -fallback_mirror="https://packages.termux.dev/apt/termux-main" -if [ -f "$PREFIX/etc/apt/sources.list" ] && grep -q "$target_mirror" "$PREFIX/etc/apt/sources.list"; then - echo "✅ 镜像源已经配置为Cloudflare镜像,跳过修改" -else - echo "正在设置Termux镜像为Cloudflare镜像..." - - # 备份原始sources.list文件 - if [ -f "$PREFIX/etc/apt/sources.list" ]; then - echo "备份原始sources.list文件..." - cp "$PREFIX/etc/apt/sources.list" "$PREFIX/etc/apt/sources.list.backup.$(date +%s)" - fi - - # 写入新的镜像源 - echo "写入新的镜像源配置..." - cat > "$PREFIX/etc/apt/sources.list" << 'EOF' -# Cloudflare镜像源 -deb https://packages-cf.termux.dev/apt/termux-main stable main -EOF - - echo "✅ 镜像源已更新为: $target_mirror" -fi - -ensure_dpkg_ready() { - echo "检查并修复 dpkg/apt 状态..." - # 等待可能存在的 apt/dpkg 进程结束 - if pgrep -f "apt|dpkg" >/dev/null 2>&1; then - echo "检测到 apt/dpkg 正在运行,等待其结束..." - while pgrep -f "apt|dpkg" >/dev/null 2>&1; do sleep 1; done - fi - # 清理可能残留的锁(若无进程) - for f in "$PREFIX/var/lib/dpkg/lock" \ - "$PREFIX/var/lib/apt/lists/lock" \ - "$PREFIX/var/cache/apt/archives/lock"; do - [ -e "$f" ] && rm -f "$f" - done - # 尝试继续未完成的配置 - dpkg --configure -a || true -} - -# 更新包列表并检查错误 -echo "正在更新包列表..." -ensure_dpkg_ready -apt_output=$(apt update 2>&1) -if [ $? -ne 0 ]; then - if echo "$apt_output" | grep -qi "is not signed"; then - echo "⚠️ 检测到仓库未签名,尝试切换到官方镜像并修复 keyring..." - # 切换到官方镜像 - sed -i "s#${target_mirror}#${fallback_mirror}#g" "$PREFIX/etc/apt/sources.list" || true - # 清理列表与锁 - rm -rf "$PREFIX/var/lib/apt/lists/"* || true - rm -f "$PREFIX/var/lib/dpkg/lock" "$PREFIX/var/lib/apt/lists/lock" "$PREFIX/var/cache/apt/archives/lock" || true - # 重新安装 termux-keyring(若已安装则强制重装) - apt-get install --reinstall -y termux-keyring || true - # 再次更新 - ensure_dpkg_ready - apt update - else - echo "apt update 失败,错误信息:" - echo "$apt_output" | head -20 - exit 1 - fi -else - echo "$apt_output" -fi - -echo "✅ Termux镜像设置完成!" -echo "📁 原始配置已备份到: $PREFIX/etc/apt/sources.list.backup.*" -echo "🔄 如需恢复原始镜像,可以运行:" -echo " cp \$PREFIX/etc/apt/sources.list.backup.* \$PREFIX/etc/apt/sources.list && apt update" - -# 检查是否需要更新包管理器和安装软件 -need_update=false -packages_to_install="" - -# 检查 uv 是否已安装 -if ! command -v uv &> /dev/null; then - need_update=true - packages_to_install="$packages_to_install uv" -fi - -# 检查 python 是否已安装 -if ! command -v python &> /dev/null; then - need_update=true - packages_to_install="$packages_to_install python" -fi - -# 检查 nodejs 是否已安装 -if ! command -v node &> /dev/null; then - need_update=true - packages_to_install="$packages_to_install nodejs" -fi - -# 检查 git 是否已安装 -if ! command -v git &> /dev/null; then - need_update=true - packages_to_install="$packages_to_install git" -fi - -# 如果需要安装软件,则更新包管理器并安装 -if [ "$need_update" = true ]; then - echo "正在更新包管理器..." - ensure_dpkg_ready - echo "正在安装缺失的软件包: $packages_to_install" - pkg install $packages_to_install -y -else - echo "所需软件包已全部安装,跳过更新和安装步骤" -fi - -# 检查 pm2 是否已安装 -if ! command -v pm2 &> /dev/null; then - echo "正在安装 pm2..." - npm install pm2 -g -else - echo "pm2 已安装,跳过安装" -fi - -# 项目目录处理逻辑 -if [ -f "./web.py" ]; then - # Already in target directory; skip clone and cd - echo "已在目标目录中,跳过克隆操作" -elif [ -f "./gcli2api/web.py" ]; then - echo "进入已存在的 gcli2api 目录" - cd ./gcli2api -else - echo "克隆项目仓库..." - git clone https://github.com/su-kaka/gcli2api.git - cd ./gcli2api -fi - -echo "强制同步项目代码,忽略本地修改..." -git fetch --all -git reset --hard origin/$(git rev-parse --abbrev-ref HEAD) - -# 只在不存在时创建 -if [ ! -d ".venv" ]; then - echo "创建虚拟环境..." - PYTHON_VERSION=$(python -V 2>&1 | grep -oP '\d+\.\d+' | head -1) - if [ -n "$PYTHON_VERSION" ]; then - echo "检测到 Python $PYTHON_VERSION,固定版本..." - uv python pin "$PYTHON_VERSION" - fi - rm pyproject.toml - uv init - uv venv -else - echo "虚拟环境已存在,跳过创建" -fi - -echo "安装 Python 依赖..." -uv add -r requirements-termux.txt - -echo "激活虚拟环境并启动服务..." -source .venv/bin/activate -pm2 start .venv/bin/python --name web -- web.py -cd .. \ No newline at end of file diff --git a/termux-start.sh b/termux-start.sh deleted file mode 100644 index ed22db2fd4a65df6c0905cfb533eab698f616c74..0000000000000000000000000000000000000000 --- a/termux-start.sh +++ /dev/null @@ -1,12 +0,0 @@ -echo "强制同步项目代码,忽略本地修改..." -git fetch --all -git reset --hard origin/$(git rev-parse --abbrev-ref HEAD) -echo "创建虚拟环境..." -PYTHON_VERSION=$(python -V 2>&1 | grep -oP '\d+\.\d+' | head -1) -if [ -n "$PYTHON_VERSION" ]; then - echo "检测到 Python $PYTHON_VERSION,固定版本..." - uv python pin "$PYTHON_VERSION" -fi -uv add -r requirements-termux.txt -source .venv/bin/activate -pm2 start .venv/bin/python --name gcli2api -- web.py diff --git a/version.txt b/version.txt deleted file mode 100644 index f9f2e2ad1a1637bc9ea44504cc97e02633417c86..0000000000000000000000000000000000000000 --- a/version.txt +++ /dev/null @@ -1,4 +0,0 @@ -full_hash=689dc539e986a7f4cce1d9439218bc78abc52138 -short_hash=689dc53 -message=反重力缓存 -date=2026-05-21 20:50:15 +0800 diff --git a/web.py b/web.py deleted file mode 100644 index 3b632323c537c9245ce5eb831a1ef1cc00e13e1d..0000000000000000000000000000000000000000 --- a/web.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Main Web Integration - Integrates all routers and modules -集合router并开启主服务 -""" - -import asyncio -import os -from contextlib import asynccontextmanager - -from fastapi import FastAPI, Response -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles - -from config import get_server_host, get_server_port -from log import log - -# Import managers and utilities -from src.credential_manager import credential_manager - -# Import all routers -from src.router.antigravity.openai import router as antigravity_openai_router -from src.router.antigravity.gemini import router as antigravity_gemini_router -from src.router.antigravity.anthropic import router as antigravity_anthropic_router -from src.router.antigravity.model_list import router as antigravity_model_list_router -from src.router.geminicli.openai import router as geminicli_openai_router -from src.router.geminicli.gemini import router as geminicli_gemini_router -from src.router.geminicli.anthropic import router as geminicli_anthropic_router -from src.router.geminicli.model_list import router as geminicli_model_list_router -from src.task_manager import shutdown_all_tasks -from src.panel import router as panel_router -from src.keeplive import keepalive_service - -# 全局凭证管理器 -global_credential_manager = None - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """应用生命周期管理""" - global global_credential_manager - - log.info("启动 GCLI2API 主服务") - - # 初始化配置缓存(优先执行) - try: - import config - await config.init_config() - log.info("配置缓存初始化成功") - except Exception as e: - log.error(f"配置缓存初始化失败: {e}") - - # 初始化全局凭证管理器(通过单例工厂) - try: - # credential_manager 会在第一次调用时自动初始化 - # 这里预先触发初始化以便在启动时检测错误 - await credential_manager._get_or_create() - log.info("凭证管理器初始化成功") - except Exception as e: - log.error(f"凭证管理器初始化失败: {e}") - global_credential_manager = None - - # OAuth回调服务器将在需要时按需启动 - - # 启动保活服务(未配置URL时自动跳过,零开销) - try: - await keepalive_service.start() - except Exception as e: - log.error(f"保活服务启动失败: {e}") - - yield - - # 清理资源 - log.info("开始关闭 GCLI2API 主服务") - - # 停止保活服务 - try: - await keepalive_service.stop() - except Exception as e: - log.error(f"关闭保活服务时出错: {e}") - - # 首先关闭所有异步任务 - try: - await shutdown_all_tasks(timeout=10.0) - log.info("所有异步任务已关闭") - except Exception as e: - log.error(f"关闭异步任务时出错: {e}") - - # 然后关闭凭证管理器 - if global_credential_manager: - try: - await global_credential_manager.close() - log.info("凭证管理器已关闭") - except Exception as e: - log.error(f"关闭凭证管理器时出错: {e}") - - log.info("GCLI2API 主服务已停止") - - -# 创建FastAPI应用 -app = FastAPI( - title="GCLI2API", - description="Gemini API proxy with OpenAI compatibility", - version="2.0.0", - lifespan=lifespan, -) - -# CORS中间件 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# 挂载路由器 -# OpenAI兼容路由 - 处理OpenAI格式请求 -app.include_router(geminicli_openai_router, prefix="", tags=["Geminicli OpenAI API"]) - -# Gemini原生路由 - 处理Gemini格式请求 -app.include_router(geminicli_gemini_router, prefix="", tags=["Geminicli Gemini API"]) - -# Geminicli模型列表路由 - 处理Gemini格式的模型列表请求 -app.include_router(geminicli_model_list_router, prefix="", tags=["Geminicli Model List"]) - -# Antigravity路由 - 处理OpenAI格式请求并转换为Antigravity API -app.include_router(antigravity_openai_router, prefix="", tags=["Antigravity OpenAI API"]) - -# Antigravity路由 - 处理Gemini格式请求并转换为Antigravity API -app.include_router(antigravity_gemini_router, prefix="", tags=["Antigravity Gemini API"]) - -# Antigravity模型列表路由 - 处理Gemini格式的模型列表请求 -app.include_router(antigravity_model_list_router, prefix="", tags=["Antigravity Model List"]) - -# Antigravity Anthropic Messages 路由 - Anthropic Messages 格式兼容 -app.include_router(antigravity_anthropic_router, prefix="", tags=["Antigravity Anthropic Messages"]) - -# Geminicli Anthropic Messages 路由 - Anthropic Messages 格式兼容 (Geminicli) -app.include_router(geminicli_anthropic_router, prefix="", tags=["Geminicli Anthropic Messages"]) - -# Panel路由 - 包含认证、凭证管理和控制面板功能 -app.include_router(panel_router, prefix="", tags=["Panel Interface"]) - -# ===== XTC 听歌识曲路由(无侵入集成,失败不影响主服务) ===== -try: - from src.router.xtc_recognize import router as xtc_recognize_router - app.include_router(xtc_recognize_router, tags=["XTC Recognize"]) - log.info("XTC 听歌识曲路由加载成功") -except Exception as e: - log.error(f"XTC 听歌识曲路由加载失败(不影响主服务): {e}") - -# 静态文件路由 - 服务docs目录下的文件 -app.mount("/docs", StaticFiles(directory="docs"), name="docs") - -# 静态文件路由 - 服务front目录下的文件(HTML、JS、CSS等) -app.mount("/front", StaticFiles(directory="front"), name="front") - - -# 保活接口(仅响应 HEAD) -@app.head("/keepalive") -async def keepalive() -> Response: - return Response(status_code=200) - -def main(): - """主启动函数""" - from hypercorn.asyncio import serve - from hypercorn.config import Config - from hypercorn.run import run - - workers = int(os.environ.get("WORKERS", 1)) - - async def _run(): - port = await get_server_port() - host = await get_server_host() - - log.info("=" * 60) - log.info("启动 GCLI2API") - log.info("=" * 60) - log.info(f"控制面板: http://127.0.0.1:{port}") - if workers > 1: - log.info(f"Worker 数量: {workers}") - log.info("=" * 60) - - config = Config() - config.bind = [f"{host}:{port}"] - config.accesslog = "-" - config.errorlog = "-" - config.loglevel = "INFO" - - await serve(app, config) - - if workers == 1: - asyncio.run(_run()) - else: - # 多 worker 模式下 hypercorn run 自行管理进程,先同步获取配置 - port = int(os.environ.get("PORT", 7861)) - host = os.environ.get("HOST", "0.0.0.0") - - log.info("=" * 60) - log.info("启动 GCLI2API") - log.info("=" * 60) - log.info(f"控制面板: http://127.0.0.1:{port}") - log.info(f"Worker 数量: {workers}") - log.info("=" * 60) - - config = Config() - config.bind = [f"{host}:{port}"] - config.accesslog = "-" - config.errorlog = "-" - config.loglevel = "INFO" - config.workers = workers - config.application_path = "web:app" - - run(config) - - -if __name__ == "__main__": - main() diff --git a/zeabur.yaml b/zeabur.yaml deleted file mode 100644 index 961526ec3e86c4bb84379735e30a67d429c02362..0000000000000000000000000000000000000000 --- a/zeabur.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: zeabur.com/v1 -kind: Template -metadata: - name: gcli2api -spec: - description: "将 GeminiCLI 转换为 OpenAI 和 GEMINI API 接口" - tags: - - ai - - api - - gemini - - openai - variables: - - key: PASSWORD - type: STRING - name: API密码 - description: 用于访问API和控制面板的密码 - - key: DOMAIN - type: DOMAIN - name: 域名 - description: 服务访问域名 - readme: | - # GeminiCLI to API - - 将 GeminiCLI 转换为 OpenAI 和 GEMINI API 接口 - - 部署后请访问您的域名进行配置。 - services: - - name: gcli2api - template: PREBUILT - spec: - id: gcli2api - name: gcli2api - source: - image: ghcr.io/su-kaka/gcli2api:latest - ports: - - id: web - port: 7861 - type: HTTP - env: - PASSWORD: - default: ${PASSWORD} - PORT: - default: "7861" - HOST: - default: "0.0.0.0" - DOMAIN: - default: ${DOMAIN} - volumes: - - id: creds - dir: /app/creds \ No newline at end of file