diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..294a9a2893083221a952174dd4990a25be93a2fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +** + +!VERSION + +!config.yaml + +!backend/ +!backend/go.mod +!backend/go.sum +!backend/cmd/ +!backend/cmd/** +!backend/internal/ +!backend/internal/** +backend/**/*_test.go +!backend/docs/ +!backend/docs/docs.go + +!frontend/ +!frontend/package.json +!frontend/pnpm-lock.yaml +!frontend/index.html +!frontend/vite.config.ts +!frontend/tsconfig.json +!frontend/tsconfig.app.json +!frontend/tsconfig.node.json +!frontend/public/ +!frontend/public/** +!frontend/src/ +!frontend/src/** + +!docker/ +!docker/entrypoint.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..66a518fa8db6c48b08dc9cb83355afeb7f732369 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +frontend/public/**/*.png filter=lfs diff=lfs merge=lfs -text +frontend/public/**/*.jpg filter=lfs diff=lfs merge=lfs -text +frontend/public/**/*.ico filter=lfs diff=lfs merge=lfs -text diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..ae37d7403b611f4e1f4d63d21193890e0436181d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,118 @@ +name: Bug 报告 +description: 报告可稳定复现的功能、兼容性或部署问题。 +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + 感谢反馈问题。请尽量提供最小复现步骤和经过脱敏的日志、请求或响应。 + + 请勿提交 Access Token、Refresh Token、Cookie、API Key、账号密码或其他敏感信息。安全漏洞请不要通过公开 Issue 披露。 + + - type: dropdown + id: area + attributes: + label: 问题模块 + description: 请选择问题主要出现在哪个模块。 + options: + - OpenAI API 兼容层(Responses / Chat Completions) + - 模型同步、映射、路由与定价 + - 账号、Provider、OAuth 与凭据 + - 额度、计费与请求审计 + - 图片、视频与媒体处理 + - 管理端前端界面 + - SQLite / PostgreSQL 数据库 + - Docker、配置、部署与网络出口 + - 其他 + validations: + required: true + + - type: dropdown + id: provider + attributes: + label: 相关 Provider + description: 请选择受影响的上游渠道。 + options: + - Grok Build + - Grok Web + - Grok Console + - 多个 Provider + - 与 Provider 无关 + validations: + required: true + + - type: input + id: version + attributes: + label: 版本信息 + description: 请提供版本号、Docker 镜像标签或 Commit SHA。 + placeholder: "v0.2.99、ghcr.io/chenyme/grok2api:latest、main@abcdef1" + validations: + required: true + + - type: textarea + id: environment + attributes: + label: 部署与运行环境 + description: 请填写与问题相关的部署方式、系统、架构、数据库和网络环境。 + placeholder: | + 部署方式:Docker Compose / Docker Run / 源码运行 + 操作系统:Debian 13 / Ubuntu 24.04 / macOS ... + CPU 架构:amd64 / arm64 + 数据库:SQLite / PostgreSQL + 反向代理:Nginx / Nginx Proxy Manager / 无 + 出口网络:直连 / HTTP Proxy / SOCKS5 / WARP + validations: + required: true + + - type: textarea + id: steps + attributes: + label: 复现步骤 + description: 请提供能够稳定复现问题的最小操作步骤。 + placeholder: | + 1. 使用配置……启动服务 + 2. 导入或选择……账号 + 3. 请求……接口 + 4. 出现……错误 + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 预期行为 + description: 正常情况下应该发生什么? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: 实际行为 + description: 实际发生了什么?请包含完整错误信息。 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: 请求、响应、日志与截图 + description: 请提供脱敏后的最小请求、状态码、响应体、容器日志、请求 ID 或截图。长日志请仅保留问题前后的关键部分。 + placeholder: 可粘贴内容或直接拖放截图、日志文件;提交前请再次确认内容已经脱敏。 + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: 提交前检查 + options: + - label: 我已经搜索现有 Issue 和 Pull Request,确认没有相同问题。 + required: true + - label: 我已经删除 Token、Cookie、API Key、账号凭据和个人信息。 + required: true + - label: 这不是需要私下披露的安全漏洞。 + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..3ba13e0cec6cbbfd462e9ebf529dd2093148cd69 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000000000000000000000000000000000000..7dd0c7a993db255bd3834fc75376d32fcabcf1a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,95 @@ +name: 文档改进 +description: 报告缺失、错误、过时或难以理解的文档内容。 +title: "[Docs]: " +labels: + - documentation +body: + - type: markdown + attributes: + value: | + 请指出具体文档位置,并说明当前内容的问题和建议修改方向。 + + - type: dropdown + id: area + attributes: + label: 文档范围 + options: + - README + - 配置文件与 config.example.yaml + - Docker 与部署说明 + - Grok Build / Web / Console 账号接入 + - OpenAI API 兼容与 Swagger 契约 + - 模型同步、映射、路由与定价 + - 故障排查与常见问题 + - 开发规范与项目架构 + - 管理端界面文案 + - 其他 + validations: + required: true + + - type: dropdown + id: issue_type + attributes: + label: 问题类型 + options: + - 文档缺失 + - 内容错误 + - 内容已过时 + - 描述不清晰 + - 缺少示例 + - 链接失效 + - 格式或用词不统一 + - 其他 + validations: + required: true + + - type: input + id: location + attributes: + label: 文档位置 + description: 请提供文件路径、页面链接、章节名称或 API 路径。 + placeholder: "README.md#docker、config.example.yaml、/v1/responses" + validations: + required: true + + - type: textarea + id: issue + attributes: + label: 当前问题 + description: 当前文档缺少什么、哪里错误或为什么难以理解? + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 建议修改 + description: 请描述建议补充、修正或重写的内容;如有条件可提供示例文本。 + validations: + required: false + + - type: dropdown + id: audience + attributes: + label: 主要受众 + options: + - 新用户 + - API 调用方 + - Docker 部署者 + - 系统管理员 + - 前端开发者 + - 后端开发者 + - 项目贡献者 + - 其他 + validations: + required: true + + - type: checkboxes + id: checklist + attributes: + label: 提交前检查 + options: + - label: 我已经搜索现有 Issue 和 Pull Request,确认没有相同文档问题。 + required: true + - label: 我已经提供尽可能具体的文档位置。 + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000000000000000000000000000000000..506317ced756b936191ea1bf6027854f779db524 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,114 @@ +name: 功能建议 +description: 提议新的功能、兼容能力或工程改进。 +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + 请围绕一个明确的问题或使用场景提出建议,并说明预期行为、兼容性和部署影响。 + + - type: dropdown + id: area + attributes: + label: 功能模块 + description: 请选择建议主要影响的模块。 + options: + - OpenAI API 兼容层(Responses / Chat Completions) + - 模型同步、映射、路由与定价 + - 账号、Provider、OAuth 与凭据 + - 额度、计费与请求审计 + - 图片、视频与媒体处理 + - 管理端前端界面 + - SQLite / PostgreSQL 数据库 + - Docker、配置、部署与网络出口 + - 开发体验与工程质量 + - 其他 + validations: + required: true + + - type: dropdown + id: provider + attributes: + label: 相关 Provider + options: + - Grok Build + - Grok Web + - Grok Console + - 多个 Provider + - 新 Provider + - 与 Provider 无关 + validations: + required: true + + - type: textarea + id: problem + attributes: + label: 问题与使用场景 + description: 当前存在什么限制?谁会受到影响?为什么需要解决? + placeholder: 请描述实际使用场景,而不仅是功能名称。 + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: 建议方案 + description: 请描述期望的交互、API、配置或运行行为。 + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: 验收标准 + description: 满足哪些可验证条件时,可以认为该功能已经完成? + placeholder: | + - 当……时,系统应…… + - API 应返回…… + - 现有配置和客户端应继续兼容…… + validations: + required: true + + - type: textarea + id: api_example + attributes: + label: API、配置或界面示例 + description: 如适用,请提供期望的请求响应、配置片段或界面流程。 + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: 替代方案 + description: 请描述目前的解决办法、绕过方式或考虑过的其他设计。 + validations: + required: false + + - type: textarea + id: compatibility + attributes: + label: 兼容性、部署与迁移影响 + description: 请说明是否影响 API 协议、模型命名、配置、数据库、Docker 部署或现有客户端。 + placeholder: | + API 兼容性: + 配置变更: + 数据库迁移: + Docker / 网络变更: + 向后兼容性: + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: 提交前检查 + options: + - label: 我已经搜索现有 Issue 和 Pull Request,确认没有相同建议。 + required: true + - label: 该 Issue 只聚焦一个功能或改进方向。 + required: true + - label: 我已经说明用户价值和可验证的验收标准。 + required: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000000000000000000000000000000000..99e7d25de8ed18a206d00af46db281171e93f92b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,49 @@ +name: CodeQL Advanced + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * *" + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/ghcr-image.yml b/.github/workflows/ghcr-image.yml new file mode 100644 index 0000000000000000000000000000000000000000..06aa63bd46fde09c4433dd580a90ff7a6b64cd57 --- /dev/null +++ b/.github/workflows/ghcr-image.yml @@ -0,0 +1,219 @@ +name: GHCR Image + +on: + push: + branches: + - main + tags: + - "v*.*.*" + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: ghcr-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Test backend + working-directory: backend + run: go test ./... + + - name: Vet backend + working-directory: backend + run: go vet ./... + + - name: Verify Swagger document + run: | + make swagger + git diff --exit-code -- backend/docs/docs.go backend/docs/swagger.json backend/docs/swagger.yaml + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.5.2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Lint frontend + working-directory: frontend + run: pnpm lint + + - name: Build frontend + working-directory: frontend + run: pnpm build + + check_ghcr_image: + name: Build image (${{ matrix.arch }}) + if: github.event_name != 'push' + needs: verify + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + push: false + cache-from: type=gha,scope=${{ matrix.arch }} + provenance: false + sbom: false + + build_ghcr_image: + name: Publish image (${{ matrix.arch }}) + if: github.event_name == 'push' + needs: verify + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Normalize image name + id: image + shell: bash + run: | + image_name=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]') + echo "name=ghcr.io/$image_name" >> "$GITHUB_OUTPUT" + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=ref,event=branch,suffix=-${{ matrix.arch }} + type=ref,event=tag,suffix=-${{ matrix.arch }} + type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }} + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,scope=${{ matrix.arch }},mode=max + provenance: mode=max + sbom: true + + merge: + name: Merge image + if: github.event_name == 'push' + needs: + - build_ghcr_image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Normalize image name + id: image + shell: bash + run: | + image_name=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]') + echo "name=ghcr.io/$image_name" >> "$GITHUB_OUTPUT" + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create manifest list + shell: bash + run: | + jq -cr '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON" | while read -r tag; do + docker buildx imagetools create \ + -t "$tag" \ + "${tag}-amd64" \ + "${tag}-arm64" + done + + - name: Inspect image + run: docker buildx imagetools inspect "${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000000000000000000000000000000000..e563421346f2142a46a7a946ac7f4c978c5cce2a --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,30 @@ +name: Close inactive issues and PRs + +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + name: Mark and close inactive issues and PRs + runs-on: ubuntu-latest + + steps: + - name: Process stale issues and PRs + uses: actions/stale@v10 + with: + days-before-issue-stale: 7 + days-before-pr-stale: 7 + days-before-issue-close: 7 + days-before-pr-close: 7 + stale-issue-label: stale + stale-pr-label: stale + stale-issue-message: This issue has had no activity for 7 days. It will be closed in 7 days if there is no further activity. + stale-pr-message: This pull request has had no activity for 7 days. It will be closed in 7 days if there is no further activity. + close-issue-message: This issue was closed because it had no activity for 7 days after being marked stale. + close-pr-message: This pull request was closed because it had no activity for 7 days after being marked stale. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f2a4ca63f4d41bd7244cbea2c21ebda40c8009f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# OS and editors +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Local environment and secrets +# 注意: HF 部署需要 config.yaml 入库(打进镜像),所以不忽略它 +.env +.env.* +!.env.example +/docs/* +/docs/rules/* +/backend/docs/goal/ +*.har +*.pcap +*.pcapng + +# Runtime data +/data/ +/backend/data/ +*.log + +# Go +/.gocache/ +/.gocache-*/ +/backend/grok2api +/backend/coverage.out +*.coverprofile +*.test + +# Frontend +/.pnpm-store/ +/frontend/node_modules/ +/frontend/dist/ +/frontend/.cache/ +/frontend/*.tsbuildinfo + +# Temporary files +/release/ +/.tmp/ +/tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c3ff11d85711a9a9ff7bec27fa0cfc03fe684a7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +ARG NODE_VERSION=22 +ARG GO_VERSION=1.26 +ARG ALPINE_VERSION=3.23 + +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS frontend-builder + +WORKDIR /src/frontend +RUN corepack enable + +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN --mount=type=cache,id=grok2api-pnpm,target=/pnpm/store \ + pnpm config set store-dir /pnpm/store && \ + pnpm fetch --frozen-lockfile + +RUN --mount=type=cache,id=grok2api-pnpm,target=/pnpm/store \ + pnpm config set store-dir /pnpm/store && \ + pnpm install --offline --frozen-lockfile + +COPY frontend/index.html frontend/vite.config.ts frontend/tsconfig.json frontend/tsconfig.app.json frontend/tsconfig.node.json ./ +COPY frontend/public ./public +COPY frontend/src ./src +RUN --mount=type=cache,id=grok2api-tsc,target=/src/frontend/.cache,sharing=locked \ + pnpm build + + +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS backend-builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src/backend +RUN apk add --no-cache ca-certificates git + +COPY backend/go.mod backend/go.sum ./ +RUN --mount=type=cache,id=grok2api-go-mod,target=/go/pkg/mod,sharing=locked \ + go mod download + +COPY backend/cmd ./cmd +COPY backend/internal ./internal +COPY backend/docs/docs.go ./docs/docs.go +RUN --mount=type=cache,id=grok2api-go-mod,target=/go/pkg/mod,sharing=locked \ + --mount=type=cache,id=grok2api-go-build,target=/root/.cache/go-build,sharing=locked \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -buildvcs=false -trimpath -ldflags="-s -w" -o /out/grok2api ./cmd/grok2api + + +FROM alpine:${ALPINE_VERSION} + +ENV TZ=Asia/Shanghai \ + GROK2API_CONFIG_SOURCE=/run/grok2api/config.yaml + +RUN apk add --no-cache ca-certificates su-exec tzdata openssl && \ + addgroup -S -g 10001 grok2api && \ + adduser -S -D -H -u 10001 -G grok2api grok2api && \ + mkdir -p /app /run/grok2api && \ + chown -R grok2api:grok2api /app /run/grok2api + +WORKDIR /app + +COPY --from=backend-builder --chmod=0755 /out/grok2api /app/grok2api +COPY --from=frontend-builder /src/frontend/dist /app/frontend/dist +COPY VERSION /app/VERSION +COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/grok2api-entrypoint +# Hugging Face Spaces: 把配置打进镜像(HF 无卷挂载,靠 entrypoint 落盘) +COPY config.yaml /run/grok2api/config.yaml + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8000/healthz >/dev/null || exit 1 + +ENTRYPOINT ["/usr/local/bin/grok2api-entrypoint"] +CMD ["/app/grok2api", "--config", "/app/config.yaml", "--listen", "0.0.0.0:8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..16c55bbbc3577f8e13af37b54d23fe310492a170 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chenyme + +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/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..9603c2db1a4df64034f0bba78e36be0f7e6c5456 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: run swagger + +CONFIG ?= $(CURDIR)/config.yaml + +run: + cd backend && GOCACHE=$(CURDIR)/.gocache go run ./cmd/grok2api --config "$(abspath $(CONFIG))" $(RUN_ARGS) + +swagger: + cd backend && GOCACHE=$(CURDIR)/.gocache go run github.com/swaggo/swag/cmd/swag@v1.16.6 init \ + -g main.go \ + -d cmd/grok2api,internal/transport/http \ + --parseInternal \ + --output docs \ + --outputTypes go,json,yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0c92a10e3a2fa2c33a6b51786269f91f5a27f100 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +--- +title: Grok2API +emoji: 🚀 +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 8000 +pinned: false +--- + +# Grok2API - Hugging Face Spaces 部署版 (v3.0.11) + +Grok Build / Grok Web / Grok Console 多账号 API 网关,OpenAI / Anthropic 兼容接口。 +本仓库为 [chenyme/grok2api](https://github.com/chenyme/grok2api) v3.0.11 的 HF Spaces 适配版。 + +## 部署要点 + +1. **端口**:`app_port: 8000`(上方 YAML 头块),与容器内监听端口一致。 +2. **配置**:`config.yaml` 已打进 Docker 镜像(`/run/grok2api/config.yaml`), + 密钥/管理员密码直接写在文件里。**公开空间下这些值可见,建议把 Space 设为 Private。** +3. **数据持久化**:数据库与媒体文件存放在 `/data`(HF Persistent Storage 挂载点)。 + 请在 Space Settings 中开启 Persistent Storage,否则重启后数据丢失。 +4. **管理员登录**:部署完成后访问本 Space 根路径进入管理后台, + 用户名 `admin`,密码见 `config.yaml` 的 `bootstrapAdmin.password`, + 首次登录后请在管理端修改密码。 + +## 环境变量 + +本版 Go 服务**不读取**旧版 Python 时代的环境变量(`APP_KEY`、`API_KEY`、 +`BOOTSTRAP_ADMIN_PASSWORD` 等均无效),所有配置都在 `config.yaml` 中完成。 + +## 许可证 + +MIT License diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000000000000000000000000000000000000..d2c869cf34ee19a854b602cbe5de611729130c71 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,359 @@ +

+ Grok2API +

+ +

+ 面向 Grok Build、Grok Web 与 Grok Console 的多账号 API 网关 +

+ +

+ English | 简体中文 +

+ +

+ Go + React + Docker +

+ +

+ chenyme%2Fgrok2api | Trendshift +

+ +> [!TIP] +> 推荐个人新项目 [DEEIX-AI / DEEIX-Chat](https://github.com/DEEIX-AI/DEEIX-Chat):面向多模型路由、对话、文件、工具、计费与运维的一体化轻量 AI 平台。 + +> [!NOTE] +> 本项目仅供技术研究与学习交流。使用时请务必遵循 Grok 官方的使用条款及当地法律法规,否则一切后果自负! + +## 赞助商 + +> [希望赞助这个项目?](mailto:chenyme03@gmail.com) + + + + + + + + + + + + + + +
Krill AI感谢 Krill AI 赞助了本项目!Krill 提供 GPT / Claude / Gemini / 多款国产模型的官方稳定极速的 API 中转服务,支持企业级定制、报销开票、7×16h 专属技术支持。更有独家适配的 WebSocket 连接,畅享极速首字速度。Krill 为本项目提供了特别优惠,使用此链接注册并在下订单时填写「grok2api」优惠码,首购套餐可享 Codex 77 折优惠!
DEEIX AI / DEEIX ChatDEEIX-Chat 是一款开源可部署的 AI Chat 平台,面向需要长期、稳定、统一使用多模型能力的个人、团队与企业,将模型、对话、文件、工具调用与后台管理整合为一套可部署、可扩展的系统。点击 此处 开始部署!
RightCodeRight Code 是一个企业级 AI Agent 分发平台,主要提供稳定的 Claude Code、Codex、Gemini 等模型的中转服务。充值即可开票,企业、团队用户一对一对接。感谢 Right Code 提供的 Tokens 支持,点击 此处 注册并开始使用!
+ +
+ +## 项目简介 + +Grok2API 是一个内置 React 管理端的 Go 网关。它分别管理 Grok Build、Grok Web 和 Grok Console 账号池,并对外提供统一的 OpenAI 与 Anthropic 兼容接口。 + +### 项目架构 + +```mermaid +flowchart LR + %% 颜色定义 + classDef access fill:#e1f5fe,stroke:#01579b + classDef core fill:#fff3e0,stroke:#e65100 + classDef providers fill:#f3e5f5,stroke:#4a148c + classDef infra fill:#e8f5e9,stroke:#1b5e20 + classDef upstream fill:#fce4ec,stroke:#880e4f + + subgraph Access["接入域"] + direction LR + Clients["API 客户端"] + Admin["React 管理端"] + end + + subgraph Core["网关核心域"] + direction LR + Management["管理服务
账号 · 模型 · 密钥 · 设置"] + Sync["账号同步
凭据 · 额度 · 模型"] + Gateway["网关服务
协议 · 路由 · 选号 · 重试"] + Audit["审计服务
用量 · 客户端计费"] + Management --> Sync + Gateway -.-> Audit + end + + subgraph Providers["Provider 渠道域"] + direction LR + Registry["Provider 注册表"] + Build["Grok Build
OAuth · 动态模型 · Billing"] + Web["Grok Web
SSO · 远端额度 · 媒体"] + Console["Grok Console
SSO · 本地窗口 · 无状态"] + Registry --> Build + Registry --> Web + Registry --> Console + end + + subgraph Infra["共享基础设施域"] + direction LR + Egress["出口管理器
作用域 · 代理池 · 回退 · Clearance"] + Database[("SQLite / PostgreSQL")] + Runtime[("Memory / Redis")] + end + + Upstream["🌐 Grok 上游"] + + %% 跨域调用 + Clients --> Gateway + Admin --> Management + Gateway --> Registry + Sync --> Registry + Build -->|grok_build| Egress + Web -->|grok_web / asset| Egress + Console -->|grok_console| Egress + Egress --> Upstream + Management --> Database + Audit --> Database + Gateway <--> Runtime + + %% 应用样式 + class Clients,Admin access + class Management,Sync,Gateway,Audit core + class Registry,Build,Web,Console providers + class Egress,Database,Runtime infra + class Upstream upstream +``` + +网关通过 Provider 注册表分发请求,账号同步负责刷新凭据、额度和模型。三个渠道独立维护账号状态并使用隔离的出口作用域;请求结束后统一结算用量、审计和客户端计费。 + +### 核心能力 + +| 模块 | 能力 | +| :-- | :-- | +| 接口 | Responses、Chat Completions、Anthropic Messages、Images 与异步 Videos | +| 客户端 | Codex、Claude Code,以及 OpenAI/Anthropic 兼容 SDK | +| 账号 | 批量导入导出、额度同步、凭据续期、转换、账号工具与清理 | +| 路由 | 模型发现、Provider 限定、会话粘滞、额度/并发门禁和有界切换 | +| 会话 | stored response、compact、Prompt Cache 亲和与可选 reasoning replay | +| 媒体 | 图片生成与编辑、视频任务、本地归档及 URL/Base64/SSE 输出 | +| 出口 | HTTP/SOCKS/Resin、订阅、探测、代理池、调配、回退与 FlareSolverr | +| 运维 | Dashboard、模型路由、客户端密钥、审计、运行设置和媒体库 | + +### Provider 边界 + +| Provider | 认证 | 模型 | 主要能力 | +| :-- | :-- | :-- | :-- | +| Grok Build | OAuth / 设备授权 | 按账号动态发现 | Responses、Chat、Messages、compact、stored response、视频 | +| Grok Web | SSO | 内置并按等级过滤 | Responses、Chat、Messages、图片、图片编辑、视频 | +| Grok Console | SSO | 内置 | 无状态 Responses、Chat、Messages | + +三个 Provider 独立维护凭据、额度、健康、冷却、并发与模型能力。故障切换不会跨 Provider 混用账号状态。 + +## 快速部署 + +官方镜像支持 `linux/amd64` 和 `linux/arm64`。 + +```bash +git clone https://github.com/chenyme/grok2api.git +cd grok2api +cp config.example.yaml config.yaml +``` + +生成密钥并写入 `config.yaml`: + +```bash +openssl rand -hex 32 +openssl rand -base64 32 +``` + +```yaml +secrets: + jwtSecret: "替换为生成的 Hex 密钥" + credentialEncryptionKey: "替换为生成的 Base64 密钥" + +bootstrapAdmin: + username: "admin" + password: "替换为强密码" +``` + +启动服务: + +```bash +docker compose pull +docker compose up -d +docker compose logs -f grok2api +``` + +访问 `http://127.0.0.1:8000`。镜像已包含前端,SQLite 数据库与本地媒体保存在 Compose 数据卷中。 + +### 源码运行 + +```bash +cp config.example.yaml config.yaml +make run +``` + +单独运行前端开发服务: + +```bash +cd frontend +pnpm install +pnpm dev +``` + +## 初始化网关 + +1. 使用初始管理员登录。 +2. 接入 Build、Web 或 Console 账号。 +3. 等待额度和模型能力同步完成。 +4. 在“模型路由”中确认公开模型。 +5. 在“客户端密钥”中创建密钥。 +6. 使用该密钥调用 `/v1/*`。 + +首次登录后请修改管理员密码,并从配置中删除 `bootstrapAdmin`。账号写入后不要更换 `credentialEncryptionKey`。 + +### 账号操作 + +| Provider | 接入或导入 | 导出 | +| :-- | :-- | :-- | +| Build | 设备授权、JSON/JSONL | 可重新导入的账号文件 | +| Web | 粘贴/TXT SSO、JSON/JSONL | 可重新导入的账号文件 | +| Console | 粘贴/TXT SSO、JSON/JSONL | 可重新导入的账号文件 | + +导入兼容 UTF-8 BOM。批量额度同步、Build 凭据续期、Web→Build/Console 转换、账号工具和账号清理均显示实时进度。 + +Web 账号工具支持接受协议、设置对应 20–40 岁的随机生日和开启 NSFW;已完成步骤会记录并在后续执行时跳过。 + +系统支持自动删除长期处于 `reauthRequired` 的账号,默认关闭;存在活动推理租约或视频任务的账号不会被删除。 + +> [!TIP] +> 从 Python 版迁移时,请将 Grok Web SSO 导出为 TXT,再导入“Grok Web”。旧数据库和号池元数据不兼容。 + +## 模型与路由 + +Build 模型根据账号能力动态发现;Web、Console 使用内置目录。请以模型页面或 `GET /v1/models` 为准,README 不再维护容易过期的静态模型清单。 + +公开模型名通常不带 Provider。内部路由使用 `Build/`、`Web/` 或 `Console/` 前缀;带前缀名称可显式限定来源。 + +Web 可与对应的 Build、Console 建立一对一弱关联。关联只共享匿名出口身份和来源展示,不合并凭据、额度、健康、冷却、并发、模型能力或计费。 + +### Codex、Claude Code 与 Prompt Cache + +Responses 与 Messages 支持流式、工具、推理、多轮会话和 compact。客户端会话信号会保持稳定,用于 Grok Build Prompt Cache 亲和;实际命中仍要求上游账号兼容且请求前缀未变化。 + +Responses 与 Chat Completions 按 OpenAI 语义报告输入总量;Messages 按 Anthropic 语义分开报告未缓存输入和缓存读取。审计保留输入总量与缓存部分,用于计费对账。 + +## API + +推理接口使用客户端密钥: + +```http +Authorization: Bearer g2a_xxx_xxx +``` + +| 方法 | 路径 | 用途 | +| :-- | :-- | :-- | +| `GET` | `/healthz`、`/readyz` | 存活与就绪检查 | +| `GET` | `/v1/models` | 当前可服务模型 | +| `POST` | `/v1/responses` | Responses JSON/SSE | +| `POST` | `/v1/responses/compact` | 压缩支持的 Response 会话 | +| `GET`、`DELETE` | `/v1/responses/{id}` | 查询或删除 stored response | +| `POST` | `/v1/chat/completions` | Chat Completions JSON/SSE | +| `POST` | `/v1/messages` | Anthropic Messages JSON/SSE | +| `POST` | `/v1/images/generations`、`/v1/images/edits` | 生成或编辑图片 | +| `POST`、`GET` | `/v1/videos/*` | 创建和查询视频任务 | +| `GET` | `/v1/media/images/{asset_id}`、`/v1/media/videos/{asset_id}` | 读取归档媒体 | + +stored response 和 compact 取决于最终 Provider。登录管理端后可在 `/docs` 查看当前模型与调用示例;仅在 `server.swaggerEnabled: true` 时提供 Swagger。 + +客户端密钥支持模型白名单,以及可选的 RPM、并发、用量和截止日期限制。 + +```bash +curl http://127.0.0.1:8000/v1/responses \ + -H "Authorization: Bearer g2a_xxx_xxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "your-model", + "input": "用三句话解释量子隧穿。", + "stream": true + }' +``` + +## 出口与 Cloudflare + +出口节点按 Build、Web、Console 或 Web 资源隔离。管理端支持: + +- HTTP、HTTPS、SOCKS4/4A、SOCKS5/5H 与 Resin +- 订阅和文本/Base64 导入 +- 批量探测、筛选、删除、分配与均衡 +- 按作用域配置无回退、直连或固定节点 +- 代理池模式,单次连接失败不会触发全局冷却 + +Resin 用户名支持 `{account}`: + +```text +socks5h://Default.{account}:RESIN_PROXY_TOKEN@resin:2260 +``` + +占位符会替换为稳定的匿名身份。已关联的 Web、Build、Console 可共享该身份,不直接使用 Token 或 Email。 + +如需自动维护 Web/Console Cloudflare Clearance: + +```bash +docker compose --profile flaresolverr up -d +``` + +随后在 **运行设置 → 媒体与网络 → Clearance** 选择 `FlareSolverr`,地址填写 `http://flaresolverr:8191`。 + +出口层只重试可以确认发生在请求提交前的连接故障,不会重放已经提交的生成请求、认证失败、额度耗尽或上游限流。 + +## 配置与部署 + +`config.yaml` 保存启动配置;Provider 和运维参数由管理端维护,未标记“重启生效”的设置支持热加载。 + +| 场景 | 数据库 | 运行态 | 媒体 | +| :-- | :-- | :-- | :-- | +| 单实例 | SQLite | Memory | 本地目录 | +| 多实例 | PostgreSQL | Redis | 共享且可读写的目录 | + +多实例需要为每个副本设置唯一的 `deployment.instanceID`,统一使用同一个 `clusterID`;只有媒体目录已正确共享时才设置 `sharedMedia: true`。 + +重要的可选设置: + +- `audit.ledgerMode`:`observe` 仅报告账本故障;`enforce` 可暂停新推理以保护计费准确性。 +- `routing.segmentedSelectorEnabled`:用于大型账号池,同时保留完整选号回退与原子门禁。 +- Build 响应头超时和精确匹配的 403 失效规则支持热加载。 +- “同步最新版本”可应用已验证的 Grok Build 客户端版本和 User-Agent。 + +## 生产检查 + +- 使用 HTTPS,并启用 `auth.secureCookies`。 +- 公网部署保持 Swagger 关闭。 +- 使用强密钥并妥善备份;不要提交凭据、Cookie、账号导出或数据库。 +- 备份 `config.yaml`、数据库和媒体目录。 +- 多实例同时使用 PostgreSQL、Redis 与共享媒体。 +- 公网服务前置反向代理与访问控制。 + +## 开发验证 + +```bash +cd backend +go test ./... +go test -race ./... +go vet ./... +go build ./cmd/grok2api +``` + +```bash +cd frontend +pnpm install --frozen-lockfile +pnpm lint +pnpm build +``` + +修改公开 API 注释后重新生成 Swagger: + +```bash +make swagger +``` + +## 相关文档 + +- [English README](./README.md) +- [后端说明](./backend/README.md) +- [前端说明](./frontend/README.md) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..7dfa25084e560df1d116d770863f8144518d5cac --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v3.0.11 \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..bab275a086c49b63918f115f9953bdb34b6cb8f4 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,5 @@ +.PHONY: run + +# 在 backend/ 目录复用项目根目录的启动入口和配置路径。 +run: + $(MAKE) -C .. run diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d2dfbea88b7c8dacd61416e2eebf1f58fca4810f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,82 @@ +# Grok2API Backend + +Grok2API 的 Go 后端,负责上游账号调度、协议转换、额度管理、请求审计和管理 API,并可直接托管前端构建产物。 + +## 技术栈 + +- Go 1.26、Gin、GORM +- SQLite / PostgreSQL +- Memory / Redis +- Grok Build OAuth、Grok Web SSO 与 Grok Console SSO Provider + +## 本地运行 + +配置文件位于仓库根目录。首次运行前创建本地配置并设置安全密钥: + +```bash +cp config.example.yaml config.yaml +openssl rand -hex 32 +openssl rand -base64 32 +``` + +将生成值写入 `config.yaml` 的 `secrets`,并修改 `bootstrapAdmin` 初始密码,然后启动: + +```bash +cd backend +go run ./cmd/grok2api +``` + +服务默认监听 `http://127.0.0.1:8000`。也可以显式指定配置文件或监听地址: + +```bash +go run ./cmd/grok2api --config /path/to/config.yaml --listen 0.0.0.0:8000 +``` + +## 配置与存储 + +启动配置统一由根目录 `config.yaml` 管理,启动阶段字段见 [`config.example.yaml`](../config.example.yaml)。Provider、服务容量、批量任务、路由、媒体、审计和客户端密钥默认限制由管理端设置页持久化;除页面明确标记“重启生效”的字段外均会热加载。 + +| 场景 | 数据库 | 运行态存储 | +| --- | --- | --- | +| 本地开发 / 单实例 | SQLite | Memory | +| 多实例部署 | PostgreSQL | Redis | + +关系型数据库保存账号、凭据、模型、额度、客户端密钥、审计和媒体任务;Redis 仅承载限流、并发租约、粘滞路由、分布式锁和事件通知。敏感凭据使用 `credentialEncryptionKey` 加密,该密钥必须长期保留且不得提交到版本库。 + +运行设置与代理参数由管理端设置页维护;数据库驱动、监听地址、Redis、JWT 与加密密钥仍通过 YAML 配置并在启动时生效。 + +## 服务入口 + +- `/v1/*`:兼容 API +- `/api/admin/v1/*`:管理 API +- `/healthz`、`/readyz`:健康与就绪探针 +- `/swagger/index.html`:公开 API Swagger,仅在 `server.swaggerEnabled: true` 时注册 +- `frontend.staticPath`:前端静态目录,默认 `./frontend/dist` + +详细协议说明见 [`docs`](./docs)。 + +修改公开接口注释后,在仓库根目录执行 `make swagger` 更新 `backend/docs/docs.go`、`swagger.json` 与 `swagger.yaml`。生产配置应保持 `server.swaggerEnabled: false`。 + +## 代码结构 + +```text +cmd/grok2api/ 进程入口 +internal/domain/ 领域模型与规则 +internal/application/ 应用服务与用例 +internal/infra/ 数据库、Provider、运行态与安全实现 +internal/transport/ HTTP 路由、鉴权与协议适配 +internal/repository/ 持久化接口 +``` + +依赖方向保持为 Transport → Application → Domain,基础设施通过接口接入,不在领域层依赖 HTTP、数据库或具体 Provider。 + +三个 Provider 通过声明式 Definition 和小型能力接口注册:Build 使用远程模型目录与 Billing,Web 使用按等级过滤的静态目录与上游额度窗口,Console 使用静态目录和无状态 Responses。Gateway 只根据能力声明完成路由与调度,不在通用 Handler 中拼装 Provider 私有请求。 + +## 验证 + +```bash +go test ./... +go test -race ./... +go vet ./... +go build ./cmd/grok2api +``` diff --git a/backend/cmd/grok2api/main.go b/backend/cmd/grok2api/main.go new file mode 100644 index 0000000000000000000000000000000000000000..bbaf1c227b571787b4a6e7624fd499751e2fd54d --- /dev/null +++ b/backend/cmd/grok2api/main.go @@ -0,0 +1,24 @@ +package main + +// @title Grok2API +// @version 1.0 +// @description Grok Build 与 Grok Web 多账号 API 网关。 +// @BasePath / +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description 使用 "Bearer g2a_xxx_xxx"。 + +import ( + "fmt" + "os" + + "github.com/chenyme/grok2api/backend/internal/cli" +) + +func main() { + if err := cli.Run(os.Args[1:]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000000000000000000000000000000000000..0585d1eed9a0b1bddc4f2de0036ef12f5caf5780 --- /dev/null +++ b/backend/docs/docs.go @@ -0,0 +1,809 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/healthz": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "存活检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/readyz": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "就绪检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/v1/chat/completions": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "支持 JSON 与 SSE、图片输入和函数工具。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "创建 Chat Completion", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerChatRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/images/edits": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Images" + ], + "summary": "编辑图片", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerImageEditRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/images/generations": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Images" + ], + "summary": "生成图片", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerImageGenerationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/media/images/{asset_id}": { + "get": { + "produces": [ + "image/png" + ], + "tags": [ + "Images" + ], + "summary": "获取归档图片", + "parameters": [ + { + "type": "string", + "description": "Asset ID", + "name": "asset_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/v1/messages": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "创建 Anthropic Message", + "parameters": [ + { + "type": "string", + "default": "2023-06-01", + "description": "Anthropic API version", + "name": "anthropic-version", + "in": "header", + "required": true + }, + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerMessagesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Models" + ], + "summary": "获取可用模型", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/responses": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "支持 JSON 与 SSE;stream=true 时返回 text/event-stream。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "创建 Response", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerResponsesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/responses/compact": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "压缩 Response 上下文", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerResponsesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/responses/{response_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "查询 Response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "response_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "删除 Response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "response_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/videos/generations": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Videos" + ], + "summary": "创建异步视频任务", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerVideoGenerationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/videos/{request_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Videos" + ], + "summary": "查询异步视频任务", + "parameters": [ + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "definitions": { + "httpserver.SwaggerChatRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/httpserver.SwaggerMessage" + } + }, + "model": { + "type": "string", + "example": "grok-chat-fast" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerImageEditRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "example": "1:1" + }, + "image": { + "$ref": "#/definitions/httpserver.SwaggerImageReference" + }, + "model": { + "type": "string", + "example": "grok-imagine-image-edit" + }, + "n": { + "type": "integer", + "example": 1 + }, + "partial_images": { + "type": "integer", + "example": 0 + }, + "prompt": { + "type": "string", + "example": "Change the background to black" + }, + "resolution": { + "type": "string", + "example": "1k" + }, + "response_format": { + "type": "string", + "example": "url" + }, + "size": { + "type": "string", + "example": "1024x1024" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerImageGenerationRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "example": "16:9" + }, + "model": { + "type": "string", + "example": "grok-imagine-image-quality" + }, + "n": { + "type": "integer", + "example": 1 + }, + "partial_images": { + "type": "integer", + "example": 0 + }, + "prompt": { + "type": "string", + "example": "A cinematic city at night" + }, + "resolution": { + "type": "string", + "example": "2k" + }, + "response_format": { + "type": "string", + "example": "url" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerImageReference": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://example.com/source.png" + } + } + }, + "httpserver.SwaggerMessage": { + "type": "object", + "properties": { + "content": {}, + "role": { + "type": "string", + "example": "user" + } + } + }, + "httpserver.SwaggerMessagesRequest": { + "type": "object", + "properties": { + "max_tokens": { + "type": "integer", + "example": 1024 + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/httpserver.SwaggerMessage" + } + }, + "model": { + "type": "string", + "example": "grok-chat-expert" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerResponsesRequest": { + "type": "object", + "properties": { + "input": {}, + "model": { + "type": "string", + "example": "grok-chat-auto" + }, + "previous_response_id": { + "type": "string" + }, + "prompt_cache_key": { + "type": "string" + }, + "store": { + "type": "boolean", + "example": false + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerVideoGenerationRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "example": "16:9" + }, + "duration": { + "type": "integer", + "example": 8 + }, + "model": { + "type": "string", + "example": "grok-imagine-video" + }, + "prompt": { + "type": "string", + "example": "A cinematic tracking shot in the rain" + }, + "resolution": { + "type": "string", + "example": "720p" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "使用 \"Bearer g2a_xxx_xxx\"。", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "Grok2API", + Description: "Grok Build 与 Grok Web 多账号 API 网关。", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json new file mode 100644 index 0000000000000000000000000000000000000000..4124d5ecb327eedf32269b55b4bfd7a7fc0401b1 --- /dev/null +++ b/backend/docs/swagger.json @@ -0,0 +1,784 @@ +{ + "swagger": "2.0", + "info": { + "description": "Grok Build 与 Grok Web 多账号 API 网关。", + "title": "Grok2API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/healthz": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "存活检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/readyz": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "就绪检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + }, + "/v1/chat/completions": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "支持 JSON 与 SSE、图片输入和函数工具。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "创建 Chat Completion", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerChatRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/images/edits": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Images" + ], + "summary": "编辑图片", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerImageEditRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/images/generations": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Images" + ], + "summary": "生成图片", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerImageGenerationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/media/images/{asset_id}": { + "get": { + "produces": [ + "image/png" + ], + "tags": [ + "Images" + ], + "summary": "获取归档图片", + "parameters": [ + { + "type": "string", + "description": "Asset ID", + "name": "asset_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/v1/messages": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "创建 Anthropic Message", + "parameters": [ + { + "type": "string", + "default": "2023-06-01", + "description": "Anthropic API version", + "name": "anthropic-version", + "in": "header", + "required": true + }, + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerMessagesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Models" + ], + "summary": "获取可用模型", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/responses": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "支持 JSON 与 SSE;stream=true 时返回 text/event-stream。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "创建 Response", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerResponsesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/responses/compact": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "压缩 Response 上下文", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerResponsesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/responses/{response_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "查询 Response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "response_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Responses" + ], + "summary": "删除 Response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "response_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/videos/generations": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Videos" + ], + "summary": "创建异步视频任务", + "parameters": [ + { + "description": "请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.SwaggerVideoGenerationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/videos/{request_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Videos" + ], + "summary": "查询异步视频任务", + "parameters": [ + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "definitions": { + "httpserver.SwaggerChatRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/httpserver.SwaggerMessage" + } + }, + "model": { + "type": "string", + "example": "grok-chat-fast" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerImageEditRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "example": "1:1" + }, + "image": { + "$ref": "#/definitions/httpserver.SwaggerImageReference" + }, + "model": { + "type": "string", + "example": "grok-imagine-image-edit" + }, + "n": { + "type": "integer", + "example": 1 + }, + "partial_images": { + "type": "integer", + "example": 0 + }, + "prompt": { + "type": "string", + "example": "Change the background to black" + }, + "resolution": { + "type": "string", + "example": "1k" + }, + "response_format": { + "type": "string", + "example": "url" + }, + "size": { + "type": "string", + "example": "1024x1024" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerImageGenerationRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "example": "16:9" + }, + "model": { + "type": "string", + "example": "grok-imagine-image-quality" + }, + "n": { + "type": "integer", + "example": 1 + }, + "partial_images": { + "type": "integer", + "example": 0 + }, + "prompt": { + "type": "string", + "example": "A cinematic city at night" + }, + "resolution": { + "type": "string", + "example": "2k" + }, + "response_format": { + "type": "string", + "example": "url" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerImageReference": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://example.com/source.png" + } + } + }, + "httpserver.SwaggerMessage": { + "type": "object", + "properties": { + "content": {}, + "role": { + "type": "string", + "example": "user" + } + } + }, + "httpserver.SwaggerMessagesRequest": { + "type": "object", + "properties": { + "max_tokens": { + "type": "integer", + "example": 1024 + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/httpserver.SwaggerMessage" + } + }, + "model": { + "type": "string", + "example": "grok-chat-expert" + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerResponsesRequest": { + "type": "object", + "properties": { + "input": {}, + "model": { + "type": "string", + "example": "grok-chat-auto" + }, + "previous_response_id": { + "type": "string" + }, + "prompt_cache_key": { + "type": "string" + }, + "store": { + "type": "boolean", + "example": false + }, + "stream": { + "type": "boolean", + "example": false + } + } + }, + "httpserver.SwaggerVideoGenerationRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "example": "16:9" + }, + "duration": { + "type": "integer", + "example": 8 + }, + "model": { + "type": "string", + "example": "grok-imagine-video" + }, + "prompt": { + "type": "string", + "example": "A cinematic tracking shot in the rain" + }, + "resolution": { + "type": "string", + "example": "720p" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "使用 \"Bearer g2a_xxx_xxx\"。", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c0a724b46aeced4c5341cb2c07828ebfd31af663 --- /dev/null +++ b/backend/docs/swagger.yaml @@ -0,0 +1,514 @@ +basePath: / +definitions: + httpserver.SwaggerChatRequest: + properties: + messages: + items: + $ref: '#/definitions/httpserver.SwaggerMessage' + type: array + model: + example: grok-chat-fast + type: string + stream: + example: false + type: boolean + type: object + httpserver.SwaggerImageEditRequest: + properties: + aspect_ratio: + example: "1:1" + type: string + image: + $ref: '#/definitions/httpserver.SwaggerImageReference' + model: + example: grok-imagine-image-edit + type: string + "n": + example: 1 + type: integer + partial_images: + example: 0 + type: integer + prompt: + example: Change the background to black + type: string + resolution: + example: 1k + type: string + response_format: + example: url + type: string + size: + example: 1024x1024 + type: string + stream: + example: false + type: boolean + type: object + httpserver.SwaggerImageGenerationRequest: + properties: + aspect_ratio: + example: "16:9" + type: string + model: + example: grok-imagine-image-quality + type: string + "n": + example: 1 + type: integer + partial_images: + example: 0 + type: integer + prompt: + example: A cinematic city at night + type: string + resolution: + example: 2k + type: string + response_format: + example: url + type: string + stream: + example: false + type: boolean + type: object + httpserver.SwaggerImageReference: + properties: + url: + example: https://example.com/source.png + type: string + type: object + httpserver.SwaggerMessage: + properties: + content: {} + role: + example: user + type: string + type: object + httpserver.SwaggerMessagesRequest: + properties: + max_tokens: + example: 1024 + type: integer + messages: + items: + $ref: '#/definitions/httpserver.SwaggerMessage' + type: array + model: + example: grok-chat-expert + type: string + stream: + example: false + type: boolean + type: object + httpserver.SwaggerResponsesRequest: + properties: + input: {} + model: + example: grok-chat-auto + type: string + previous_response_id: + type: string + prompt_cache_key: + type: string + store: + example: false + type: boolean + stream: + example: false + type: boolean + type: object + httpserver.SwaggerVideoGenerationRequest: + properties: + aspect_ratio: + example: "16:9" + type: string + duration: + example: 8 + type: integer + model: + example: grok-imagine-video + type: string + prompt: + example: A cinematic tracking shot in the rain + type: string + resolution: + example: 720p + type: string + type: object +info: + contact: {} + description: Grok Build 与 Grok Web 多账号 API 网关。 + title: Grok2API + version: "1.0" +paths: + /healthz: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: boolean + type: object + summary: 存活检查 + tags: + - System + /readyz: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: boolean + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: boolean + type: object + summary: 就绪检查 + tags: + - System + /v1/chat/completions: + post: + consumes: + - application/json + description: 支持 JSON 与 SSE、图片输入和函数工具。 + parameters: + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerChatRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 创建 Chat Completion + tags: + - Chat + /v1/images/edits: + post: + consumes: + - application/json + parameters: + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerImageEditRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 编辑图片 + tags: + - Images + /v1/images/generations: + post: + consumes: + - application/json + parameters: + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerImageGenerationRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 生成图片 + tags: + - Images + /v1/media/images/{asset_id}: + get: + parameters: + - description: Asset ID + in: path + name: asset_id + required: true + type: string + produces: + - image/png + responses: + "200": + description: OK + schema: + type: file + "404": + description: Not Found + summary: 获取归档图片 + tags: + - Images + /v1/messages: + post: + consumes: + - application/json + parameters: + - default: "2023-06-01" + description: Anthropic API version + in: header + name: anthropic-version + required: true + type: string + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerMessagesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 创建 Anthropic Message + tags: + - Messages + /v1/models: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 获取可用模型 + tags: + - Models + /v1/responses: + post: + consumes: + - application/json + description: 支持 JSON 与 SSE;stream=true 时返回 text/event-stream。 + parameters: + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerResponsesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 创建 Response + tags: + - Responses + /v1/responses/{response_id}: + delete: + parameters: + - description: Response ID + in: path + name: response_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 删除 Response + tags: + - Responses + get: + parameters: + - description: Response ID + in: path + name: response_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 查询 Response + tags: + - Responses + /v1/responses/compact: + post: + consumes: + - application/json + parameters: + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerResponsesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 压缩 Response 上下文 + tags: + - Responses + /v1/videos/{request_id}: + get: + parameters: + - description: Request ID + in: path + name: request_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 查询异步视频任务 + tags: + - Videos + /v1/videos/generations: + post: + consumes: + - application/json + parameters: + - description: 请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/httpserver.SwaggerVideoGenerationRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 创建异步视频任务 + tags: + - Videos +securityDefinitions: + BearerAuth: + description: 使用 "Bearer g2a_xxx_xxx"。 + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..7cdc90a9f09186044da7d01585385c7c0b644a25 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,87 @@ +module github.com/chenyme/grok2api/backend + +go 1.26 + +require ( + github.com/bdandy/go-socks4 v1.2.3 + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.15.1 + github.com/bogdanfinn/websocket v1.5.5-barnius + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.21.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.54.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.22.0 + google.golang.org/protobuf v1.36.10 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..e91d31b7094bedfd6031a82302f2cebcfeb812cd --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,255 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= +github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= +github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8= +github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= +github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= +github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= +github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= +github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/backend/internal/app/application.go b/backend/internal/app/application.go new file mode 100644 index 0000000000000000000000000000000000000000..17adc59672bc0425301bbe5c34b4efe824ef6105 --- /dev/null +++ b/backend/internal/app/application.go @@ -0,0 +1,764 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + accountsyncapp "github.com/chenyme/grok2api/backend/internal/application/accountsync" + "github.com/chenyme/grok2api/backend/internal/application/adminauth" + auditapp "github.com/chenyme/grok2api/backend/internal/application/audit" + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + dashboardapp "github.com/chenyme/grok2api/backend/internal/application/dashboard" + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + "github.com/chenyme/grok2api/backend/internal/application/gateway" + invalidationapp "github.com/chenyme/grok2api/backend/internal/application/invalidation" + mediaapp "github.com/chenyme/grok2api/backend/internal/application/media" + modelapp "github.com/chenyme/grok2api/backend/internal/application/model" + quotarecoveryapp "github.com/chenyme/grok2api/backend/internal/application/quotarecovery" + settingsapp "github.com/chenyme/grok2api/backend/internal/application/settings" + updatecheckapp "github.com/chenyme/grok2api/backend/internal/application/updatecheck" + "github.com/chenyme/grok2api/backend/internal/buildinfo" + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/config" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + inframedia "github.com/chenyme/grok2api/backend/internal/infra/media" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + cliprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/cli" + consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console" + webprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/web" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + redisruntime "github.com/chenyme/grok2api/backend/internal/infra/runtime/redis" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/chenyme/grok2api/backend/internal/pkg/reasoningreplay" + "github.com/chenyme/grok2api/backend/internal/repository" + httpserver "github.com/chenyme/grok2api/backend/internal/transport/http" + httpmiddleware "github.com/chenyme/grok2api/backend/internal/transport/http/middleware" +) + +const ( + responseOwnershipCleanupBatchSize = 1000 + webResponseStateCleanupBatchSize = 50 + responseCleanupMaxBatches = 100 + responseCleanupInterval = 5 * time.Minute + responseCleanupBudget = 30 * time.Second + responseCleanupLockTTL = 2 * time.Minute +) + +// Application 管理后端进程生命周期和本地后台任务。 +type Application struct { + logger *slog.Logger + database *relational.Database + server *http.Server + audits *auditapp.Service + responses repository.ResponseRepository + cleanupLock repository.DistributedLock + runtime io.Closer + settingsBus repository.SettingsChangeBus + invalidationBus repository.InvalidationBus + settings *settingsapp.Service + gateway *gateway.Service + media *mediaapp.Service + quotaRecovery *quotarecoveryapp.Service + accounts *accountapp.Service + models *modelapp.Service + clientKeys *clientkeyapp.Service + updates *updatecheckapp.Service + invalidations *invalidationapp.Service + accountRepo repository.AccountRepository + modelRepo repository.ModelRepository + providers *provider.Registry + web *webprovider.Adapter + egress *infraegress.Manager + egressOps *egressapp.Service + startup *startupState +} + +// New 完成数据库、Provider、应用服务和 HTTP 路由装配。 +func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Application, error) { + var database *relational.Database + var err error + switch cfg.Database.Driver { + case "sqlite": + database, err = relational.OpenSQLite(ctx, cfg.Database.SQLite.Path) + case "postgres": + database, err = relational.OpenPostgres(ctx, cfg.Database.Postgres.DSN, cfg.Database.Postgres.MaxOpenConns, cfg.Database.Postgres.MaxIdleConns) + default: + return nil, fmt.Errorf("不支持的数据库驱动: %s", cfg.Database.Driver) + } + if err != nil { + return nil, err + } + if err := database.InitializeSchema(ctx); err != nil { + database.Close() + return nil, err + } + cipher, err := security.NewCipher(cfg.Secrets.CredentialEncryptionKey) + if err != nil { + database.Close() + return nil, err + } + + adminRepo := relational.NewAdminRepository(database) + sessionRepo := relational.NewAdminSessionRepository(database) + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + clientKeyRepo := relational.NewClientKeyRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + dashboardRepo := relational.NewDashboardRepository(database) + runtimeSettingsRepo := relational.NewRuntimeSettingsRepository(database, cipher) + egressRepo := relational.NewEgressRepository(database) + mediaJobRepo := relational.NewMediaJobRepository(database) + mediaAssetRepo := relational.NewMediaAssetRepository(database) + mediaUploadTicketRepo := relational.NewMediaUploadTicketRepository(database) + loadedConfig, settingsUpdatedAt, settingsRevision, err := settingsapp.LoadPersisted(ctx, cfg, runtimeSettingsRepo) + if err != nil { + database.Close() + return nil, err + } + cfg = loadedConfig + localMediaStore, err := inframedia.NewLocalStore(cfg.Media.Local.Path) + if err != nil { + database.Close() + return nil, err + } + if err := preflightDeployment(cfg); err != nil { + database.Close() + return nil, err + } + var rateLimiter repository.RateLimiter + var concurrency repository.ConcurrencyLimiter + var sticky repository.StickySessionRepository + var reasoningReplayStore repository.ReasoningReplayRepository + var deviceSessions repository.DeviceSessionRepository + var refreshLock repository.DistributedLock + var settingsBus repository.SettingsChangeBus + var quotaQueue repository.QuotaRecoveryQueue + var quotaRefreshState repository.QuotaRefreshCoordinator + var observedModelStore repository.ObservedModelStateRepository + var invalidationBus repository.InvalidationBus + var runtimeStore io.Closer + runtimeHealth := func(context.Context) error { return nil } + switch cfg.RuntimeStore.Driver { + case "redis": + redisStore, openErr := redisruntime.Open(ctx, redisruntime.Config{ + Address: cfg.RuntimeStore.Redis.Address, Username: cfg.RuntimeStore.Redis.Username, + Password: cfg.RuntimeStore.Redis.Password, Database: cfg.RuntimeStore.Redis.Database, + KeyPrefix: cfg.RuntimeStore.Redis.KeyPrefix, TLS: cfg.RuntimeStore.Redis.TLS, + ConcurrencyLease: cfg.Server.RequestTimeout.Value() + time.Minute, + }) + if openErr != nil { + database.Close() + return nil, openErr + } + runtimeStore = redisStore + invalidationBus = redisStore + runtimeHealth = redisStore.Ping + rateLimiter = redisStore + concurrency = redisruntime.NewConcurrencyLimiter(redisStore) + sticky = redisStore + reasoningReplayStore = redisruntime.NewReasoningReplayStore(redisStore) + deviceSessions = redisruntime.NewDeviceSessionStore(redisStore) + refreshLock = redisruntime.NewLockStore(redisStore) + settingsBus = redisStore + quotaQueue = redisStore + quotaRefreshState = redisStore + observedModelStore = redisStore + case "memory": + rateLimiter = memory.NewRateLimiter() + concurrency = memory.NewConcurrencyLimiter() + sticky = memory.NewStickyStore() + reasoningReplayStore = memory.NewReasoningReplayStore(cfg.Routing.ReasoningReplayMaxEntries) + deviceSessions = memory.NewDeviceSessionStore() + refreshLock = memory.NewLockStore() + quotaQueue = memory.NewQuotaRecoveryQueue() + quotaRefreshState = memory.NewQuotaRefreshCoordinator() + default: + database.Close() + return nil, fmt.Errorf("不支持的运行态驱动: %s", cfg.RuntimeStore.Driver) + } + logger.Info("deployment_topology", "replicas", cfg.Deployment.Replicas, "instance_id", cfg.Deployment.InstanceID, "cluster_id", cfg.Deployment.ClusterID, "database", cfg.Database.Driver, "runtime_store", cfg.RuntimeStore.Driver, "media_driver", cfg.Media.Driver, "shared_media", cfg.Deployment.SharedMedia) + mediaService := mediaapp.NewServiceWithTickets(mediaAssetRepo, mediaJobRepo, mediaUploadTicketRepo, localMediaStore, refreshLock, mediaConfig(cfg)) + + egressManager := infraegress.NewManager(egressRepo, cipher) + egressManager.SetLogger(logger) + egressManager.SetClearanceLock(refreshLock) + egressManager.UpdateClearanceConfig(clearanceConfig(cfg)) + egressManager.UpdateBuildResponseHeaderTimeout(cfg.Provider.Build.ResponseHeaderTimeout.Value()) + cliAdapter := cliprovider.NewAdapter(cliprovider.Config{ + BaseURL: cfg.Provider.Build.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(cfg.Provider.Build.FallbackBaseURL), + ClientVersion: cfg.Provider.Build.ClientVersion, ClientIdentifier: cfg.Provider.Build.ClientIdentifier, + TokenAuth: cfg.Provider.Build.TokenAuth, UserAgent: cfg.Provider.Build.UserAgent, + ResponseHeaderTimeout: cfg.Provider.Build.ResponseHeaderTimeout.Value(), + }, cipher) + cliAdapter.SetLogger(logger) + cliAdapter.SetEgress(egressManager) + cliAdapter.SetVideoUploadIssuer(mediaService) + reasoningReplay := reasoningreplay.New(reasoningReplayStore, reasoningreplay.Config{ + Enabled: cfg.Routing.ReasoningReplayEnabled, + TTL: cfg.Routing.ReasoningReplayTTL.Value(), + }, logger) + cliAdapter.SetReasoningReplay(reasoningReplay) + webAdapter := webprovider.NewAdapter(webProviderConfig(cfg), egressManager, cipher, responseRepo, mediaService) + webAdapter.SetLogger(logger) + consoleAdapter := consoleprovider.NewAdapter(consoleProviderConfig(cfg), egressManager, cipher) + providers := provider.NewRegistry(cliAdapter, webAdapter, consoleAdapter) + if err := providers.Validate(); err != nil { + if runtimeStore != nil { + _ = runtimeStore.Close() + } + database.Close() + return nil, fmt.Errorf("校验 Provider 注册表: %w", err) + } + adminService := adminauth.NewService(adminRepo, sessionRepo, security.NewTokenService(cfg.Secrets.JWTSecret), cfg.Auth.AccessTokenTTL.Value(), cfg.Auth.RefreshTokenTTL.Value()) + adminService.SetLoginRateLimiter(rateLimiter) + if err := adminService.Bootstrap(ctx, cfg.BootstrapAdmin.Username, cfg.BootstrapAdmin.Password); err != nil { + if runtimeStore != nil { + _ = runtimeStore.Close() + } + database.Close() + return nil, err + } + bulkPool := batch.NewSharedPool(maxBatchConcurrency(cfg.Batch), concurrency, "bulk:upstream") + importPool := batch.NewSharedChildPool(cfg.Batch.ImportConcurrency, concurrency, "bulk:import", bulkPool) + conversionPool := batch.NewSharedChildPool(cfg.Batch.ConversionConcurrency, concurrency, "bulk:conversion", bulkPool) + syncPool := batch.NewSharedChildPool(cfg.Batch.SyncConcurrency, concurrency, "bulk:sync", bulkPool) + refreshPool := batch.NewSharedChildPool(cfg.Batch.RefreshConcurrency, concurrency, "bulk:refresh", bulkPool) + for _, pool := range []*batch.Pool{importPool, conversionPool, syncPool, refreshPool} { + pool.UpdateJitter(cfg.Batch.RandomDelay.Value()) + } + accountService := accountapp.NewService(accountRepo, auditRepo, deviceSessions, sticky, providers, cipher, refreshLock) + cliAdapter.SetFallbackMarker(accountService) + accountService.SetLogger(logger) + accountService.UpdateAutoCleanConfig(accountAutoCleanConfig(cfg.Accounts)) + accountService.SetConcurrencyLimiter(concurrency) + accountService.SetQuotaRecoveryQueue(quotaQueue) + accountService.SetQuotaRefreshCoordinator(quotaRefreshState) + accountService.SetObservedModelStore(observedModelStore) + accountService.SetTaskPools(conversionPool, syncPool, refreshPool) + windows, err := accountRepo.ListQuotaRecoveryWindows(ctx, 100000) + if err != nil { + if runtimeStore != nil { + _ = runtimeStore.Close() + } + database.Close() + return nil, fmt.Errorf("加载 Web 额度恢复事件: %w", err) + } + for _, window := range windows { + if window.ResetAt != nil { + if err := quotaQueue.ScheduleQuotaRecovery(ctx, account.QuotaRecoveryEvent{AccountID: window.AccountID, Mode: window.Mode, DueAt: *window.ResetAt}); err != nil { + if runtimeStore != nil { + _ = runtimeStore.Close() + } + database.Close() + return nil, fmt.Errorf("恢复 Web 额度事件: %w", err) + } + } + } + modelService := modelapp.NewService(modelRepo, accountRepo, accountService, providers) + modelService.SetBulkPool(syncPool) + modelService.SetLogger(logger) + if err := modelRepo.ReplaceProviderRoutes(ctx, account.ProviderWeb, webprovider.Routes()); err != nil { + if runtimeStore != nil { + _ = runtimeStore.Close() + } + database.Close() + return nil, fmt.Errorf("初始化 Grok Web 模型目录: %w", err) + } + if err := modelRepo.ReplaceProviderRoutes(ctx, account.ProviderConsole, consoleprovider.Routes()); err != nil { + if runtimeStore != nil { + _ = runtimeStore.Close() + } + database.Close() + return nil, fmt.Errorf("初始化 Grok Console 模型目录: %w", err) + } + accountSyncService := accountsyncapp.NewService(logger, accountService, accountService, accountService, modelService) + accountSyncService.SetBulkPool(importPool) + accountSyncService.UpdateConcurrency(cfg.Batch.ImportConcurrency) + egressService := egressapp.NewService(egressRepo, cipher, infraegress.DefaultUserAgent, accountRepo) + egressService.SetClearanceManager(egressManager) + egressService.SetNodeProber(egressManager) + egressService.SetOperationsConfigInvalidator(egressManager) + clientKeyService := clientkeyapp.NewService(clientKeyRepo, rateLimiter, concurrency, cfg.ClientKeyDefaults.RPMLimit, cfg.ClientKeyDefaults.MaxConcurrent, cipher) + auditService := auditapp.NewService(auditRepo, logger, cfg.Audit.BufferSize, cfg.Audit.BatchSize, cfg.Audit.FlushInterval.Value()) + auditService.UpdateWriterConfig(cfg.Audit.BatchSize, cfg.Audit.FlushInterval.Value(), cfg.Audit.CommitDelay.Value()) + auditService.UpdateLedgerConfig(auditLedgerConfig(cfg.Audit)) + auditService.SetCommitObserver(clientKeyService.CompleteBillingBatch) + auditService.SetDropObserver(clientKeyService.ReleaseBillingProtectionBatch) + dashboardService := dashboardapp.NewService(dashboardRepo) + selector := gateway.NewSelector(accountRepo, concurrency, sticky, providers, cfg.Routing.StickyTTL.Value(), cfg.Routing.CooldownBase.Value(), cfg.Routing.CooldownMax.Value(), cfg.Routing.CapacityWait.Value()) + selector.UpdatePreferFreeBuild(cfg.Routing.PreferFreeBuild) + selector.UpdateSegmentedSelector(cfg.Routing.SegmentedSelectorEnabled, cfg.Routing.SegmentedMinCandidates, cfg.Routing.SegmentedWindowSize) + invalidationService := invalidationapp.NewService(invalidationBus, invalidationSourceInstance(cfg), func(event repository.InvalidationEvent) { + selector.ApplyInvalidation(event) + clientKeyService.ApplyInvalidation(event) + }, logger) + accountRepo.SetInvalidationObserver(invalidationService.Notify) + modelRepo.SetInvalidationObserver(invalidationService.Notify) + clientKeyRepo.SetInvalidationObserver(invalidationService.Notify) + gatewayService := gateway.NewService(modelService, auditService, accountService, clientKeyService, providers, selector, responseRepo, cfg.Routing.MaxAttempts) + gatewayService.SetLogger(logger) + gatewayService.UpdateBuildForbiddenReauthPolicy(cfg.Accounts.MarkBuildForbiddenReauth, cfg.Accounts.BuildForbiddenReauthCodes) + gatewayService.UpdateRequestTimeout(cfg.Server.RequestTimeout.Value()) + gatewayService.ConfigureMedia(mediaJobRepo, cfg.Provider.Web.MediaConcurrency) + gatewayService.ConfigureMediaAssets(mediaService) + quotaRecoveryService := quotarecoveryapp.NewService(logger, quotaQueue, accountService, cfg.Provider.Web.RecoveryBackoffBase.Value(), cfg.Provider.Web.RecoveryBackoffMax.Value()) + quotaRecoveryService.SetBulkPool(syncPool) + inferenceConcurrency := httpmiddleware.NewConcurrencyGate(cfg.Server.MaxConcurrentRequests) + var notifySettings func(context.Context) + if settingsBus != nil { + notifySettings = func(notifyCtx context.Context) { + publishCtx, cancel := context.WithTimeout(context.WithoutCancel(notifyCtx), 3*time.Second) + defer cancel() + if err := settingsBus.PublishSettingsChanged(publishCtx); err != nil { + logger.Warn("settings_change_publish_failed", "error", err) + } + } + } + settingsService := settingsapp.NewService(cfg, settingsUpdatedAt, settingsRevision, runtimeSettingsRepo, notifySettings, func(next config.Config) { + inferenceConcurrency.UpdateLimit(next.Server.MaxConcurrentRequests) + bulkPool.UpdateLimit(maxBatchConcurrency(next.Batch)) + importPool.UpdateLimit(next.Batch.ImportConcurrency) + conversionPool.UpdateLimit(next.Batch.ConversionConcurrency) + syncPool.UpdateLimit(next.Batch.SyncConcurrency) + refreshPool.UpdateLimit(next.Batch.RefreshConcurrency) + for _, pool := range []*batch.Pool{importPool, conversionPool, syncPool, refreshPool} { + pool.UpdateJitter(next.Batch.RandomDelay.Value()) + } + cliAdapter.UpdateConfig(cliprovider.Config{ + BaseURL: next.Provider.Build.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(next.Provider.Build.FallbackBaseURL), + ClientVersion: next.Provider.Build.ClientVersion, ClientIdentifier: next.Provider.Build.ClientIdentifier, + TokenAuth: next.Provider.Build.TokenAuth, UserAgent: next.Provider.Build.UserAgent, + ResponseHeaderTimeout: next.Provider.Build.ResponseHeaderTimeout.Value(), + }) + egressManager.UpdateBuildResponseHeaderTimeout(next.Provider.Build.ResponseHeaderTimeout.Value()) + webAdapter.UpdateConfig(webProviderConfig(next)) + egressManager.UpdateClearanceConfig(clearanceConfig(next)) + consoleAdapter.UpdateConfig(consoleProviderConfig(next)) + mediaService.UpdateConfig(mediaConfig(next)) + quotaRecoveryService.UpdateConfig(next.Provider.Web.RecoveryBackoffBase.Value(), next.Provider.Web.RecoveryBackoffMax.Value()) + accountSyncService.UpdateConcurrency(next.Batch.ImportConcurrency) + selector.UpdateConfig(next.Routing.StickyTTL.Value(), next.Routing.CooldownBase.Value(), next.Routing.CooldownMax.Value(), next.Routing.CapacityWait.Value()) + selector.UpdatePreferFreeBuild(next.Routing.PreferFreeBuild) + selector.UpdateSegmentedSelector(next.Routing.SegmentedSelectorEnabled, next.Routing.SegmentedMinCandidates, next.Routing.SegmentedWindowSize) + reasoningReplay.UpdateConfig(reasoningreplay.Config{Enabled: next.Routing.ReasoningReplayEnabled, TTL: next.Routing.ReasoningReplayTTL.Value()}) + gatewayService.UpdateMaxAttempts(next.Routing.MaxAttempts) + gatewayService.UpdateBuildForbiddenReauthPolicy(next.Accounts.MarkBuildForbiddenReauth, next.Accounts.BuildForbiddenReauthCodes) + auditService.UpdateWriterConfig(next.Audit.BatchSize, next.Audit.FlushInterval.Value(), next.Audit.CommitDelay.Value()) + auditService.UpdateLedgerConfig(auditLedgerConfig(next.Audit)) + clientKeyService.UpdateDefaults(next.ClientKeyDefaults.RPMLimit, next.ClientKeyDefaults.MaxConcurrent) + accountService.UpdateAutoCleanConfig(accountAutoCleanConfig(next.Accounts)) + }) + updateService := updatecheckapp.NewService(buildinfo.CurrentVersion(), nil) + + startup := newStartupState(len(windows)) + readiness := func(readyCtx context.Context) httpserver.ReadinessSnapshot { + return readinessSnapshot(readyCtx, startup, runtimeHealth, modelRepo, accountRepo, providers, auditService) + } + router := httpserver.New(httpserver.Dependencies{Logger: logger, RequestTimeout: cfg.Server.RequestTimeout.Value(), MaxBodyBytes: cfg.Server.MaxBodyBytes, ConcurrencyGate: inferenceConcurrency, SecureCookies: cfg.Auth.SecureCookies, SwaggerEnabled: cfg.Server.SwaggerEnabled, PublicAPIBaseURL: cfg.Frontend.EffectivePublicAPIBaseURL(), FrontendStaticPath: cfg.Frontend.StaticPath, Readiness: readiness, TrafficReady: startup.acceptsTraffic, AdminAuth: adminService, Accounts: accountService, AccountSync: accountSyncService, Models: modelService, ClientKeys: clientKeyService, Audits: auditService, Dashboard: dashboardService, Gateway: gatewayService, Media: mediaService, Settings: settingsService, Egress: egressService, Updates: updateService}) + server := &http.Server{Addr: cfg.Server.Listen, Handler: router, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: cfg.Server.ReadTimeout.Value(), IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 64 << 10} + return &Application{ + logger: logger, database: database, server: server, + audits: auditService, responses: responseRepo, cleanupLock: refreshLock, runtime: runtimeStore, + settingsBus: settingsBus, invalidationBus: invalidationBus, settings: settingsService, gateway: gatewayService, media: mediaService, quotaRecovery: quotaRecoveryService, accounts: accountService, models: modelService, clientKeys: clientKeyService, updates: updateService, invalidations: invalidationService, + accountRepo: accountRepo, modelRepo: modelRepo, providers: providers, web: webAdapter, egress: egressManager, egressOps: egressService, startup: startup, + }, nil +} + +func invalidationSourceInstance(cfg config.Config) string { + if value := strings.TrimSpace(cfg.Deployment.InstanceID); value != "" { + return value + } + return fmt.Sprintf("process-%d", time.Now().UnixNano()) +} + +func maxBatchConcurrency(value config.BatchConfig) int { + return max(value.ImportConcurrency, value.ConversionConcurrency, value.SyncConcurrency, value.RefreshConcurrency) +} + +func webProviderConfig(cfg config.Config) webprovider.Config { + return webprovider.Config{ + BaseURL: cfg.Provider.Web.BaseURL, QuotaTimeoutSeconds: int(cfg.Provider.Web.QuotaTimeout.Value().Seconds()), + StatsigMode: cfg.Provider.Web.StatsigMode, StatsigManualValue: cfg.Provider.Web.StatsigManualValue, + StatsigSignerURL: cfg.Provider.Web.StatsigSignerURL, + ChatTimeoutSeconds: int(cfg.Provider.Web.ChatTimeout.Value().Seconds()), ImageTimeoutSeconds: int(cfg.Provider.Web.ImageTimeout.Value().Seconds()), + VideoTimeoutSeconds: int(cfg.Provider.Web.VideoTimeout.Value().Seconds()), MaxInputImageBytes: cfg.Media.MaxImageBytes, + AllowNSFW: cfg.Provider.Web.AllowNSFW, + } +} + +func clearanceConfig(cfg config.Config) infraegress.ClearanceConfig { + return infraegress.ClearanceConfig{ + Mode: cfg.Provider.Web.ClearanceMode, FlareSolverrURL: cfg.Provider.Web.FlareSolverrURL, + TargetURL: cfg.Provider.Web.BaseURL, Timeout: cfg.Provider.Web.ClearanceTimeout.Value(), + RefreshInterval: cfg.Provider.Web.ClearanceRefresh.Value(), + } +} + +func consoleProviderConfig(cfg config.Config) consoleprovider.Config { + return consoleprovider.Config{ + BaseURL: cfg.Provider.Console.BaseURL, SessionBaseURL: cfg.Provider.Web.BaseURL, + TimeoutSeconds: int(cfg.Provider.Console.ChatTimeout.Value().Seconds()), + } +} + +func accountAutoCleanConfig(value config.AccountsConfig) accountapp.AutoCleanConfig { + return accountapp.AutoCleanConfig{ + Enabled: value.AutoCleanReauthEnabled, + Interval: value.AutoCleanReauthInterval.Value(), + MinAge: value.AutoCleanReauthMinAge.Value(), + IncludeDisabled: value.AutoCleanIncludeDisabled, + } +} + +func auditLedgerConfig(value config.AuditConfig) auditapp.LedgerConfig { + return auditapp.LedgerConfig{ + Mode: auditapp.LedgerMode(value.LedgerMode), + FailureThreshold: value.LedgerFailureThreshold, + UnhealthyGrace: value.LedgerUnhealthyGrace.Value(), + QueueHighWatermarkPercent: value.LedgerQueueHighWatermarkPct, + } +} + +func mediaConfig(cfg config.Config) mediaapp.Config { + return mediaapp.Config{ + PublicBaseURL: cfg.Frontend.EffectivePublicAPIBaseURL(), + MaxImageBytes: cfg.Media.MaxImageBytes, MaxTotalBytes: cfg.Media.MaxTotalBytes, + CleanupThresholdPercent: cfg.Media.CleanupThresholdPercent, CleanupInterval: cfg.Media.CleanupInterval.Value(), + } +} + +// Run 启动 HTTP 服务和本地后台维护任务。 +func (a *Application) Run(ctx context.Context) error { + a.audits.Start() + defer func() { + closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := a.audits.Close(closeCtx); err != nil { + a.logger.Warn("audit_shutdown_failed", "error", err) + } + }() + runCtx, cancelBackground := context.WithCancel(ctx) + var background sync.WaitGroup + defer func() { + cancelBackground() + background.Wait() + }() + errCh := make(chan error, 1) + go func() { + a.logger.Info("server_started", "listen", a.server.Addr) + errCh <- a.server.ListenAndServe() + }() + a.reconcileStartup(runCtx) + startBackground := func(name string, task func(context.Context) error) { + background.Add(1) + go func() { + defer background.Done() + a.runSupervisedTask(runCtx, name, task) + }() + } + if a.invalidationBus != nil { + startBackground("invalidation_publisher", a.invalidations.RunPublisher) + startBackground("invalidation_subscriber", a.invalidations.RunSubscriber) + } + startBackground("settings_reconcile", func(taskCtx context.Context) error { + a.runPeriodicTask(taskCtx, 30*time.Second, "settings_reconcile", func(runCtx context.Context) error { + return a.settings.ReloadPersisted(runCtx) + }) + return nil + }) + startBackground("performance_metrics", func(taskCtx context.Context) error { + a.runPeriodicTask(taskCtx, time.Minute, "performance_metrics", func(context.Context) error { + a.logPerformanceMetrics() + return nil + }) + return nil + }) + startBackground("release_check", func(taskCtx context.Context) error { + a.updates.Check(taskCtx) + a.runPeriodicTask(taskCtx, 24*time.Hour, "release_check", func(checkCtx context.Context) error { + a.updates.Check(checkCtx) + return nil + }) + return nil + }) + startBackground("billing_reservation_cleanup", func(taskCtx context.Context) error { + a.runPeriodicTask(taskCtx, 10*time.Minute, "billing_reservation_cleanup", func(runCtx context.Context) error { + _, err := a.clientKeys.CleanupExpiredBilling(runCtx, 1000) + return err + }) + return nil + }) + startBackground("model_cooldown_cleanup", func(taskCtx context.Context) error { + a.runPeriodicTask(taskCtx, 10*time.Minute, "model_cooldown_cleanup", func(runCtx context.Context) error { + _, err := a.accountRepo.PruneExpiredModelQuotaBlocks(runCtx, time.Now().UTC(), 1000) + return err + }) + return nil + }) + startBackground("response_ownership_cleanup", func(taskCtx context.Context) error { + a.runPeriodicTask(taskCtx, responseCleanupInterval, "response_ownership_cleanup", func(runCtx context.Context) error { + return a.cleanupExpiredResponses(runCtx, time.Now().UTC()) + }) + return nil + }) + startBackground("quota_recovery", func(taskCtx context.Context) error { + a.quotaRecovery.Run(taskCtx) + return nil + }) + startBackground("web_quota_refresh", func(taskCtx context.Context) error { + a.accounts.RunWebQuotaRefresh(taskCtx) + return nil + }) + startBackground("credential_refresh", func(taskCtx context.Context) error { + a.accounts.RunCredentialRefresh(taskCtx) + return nil + }) + startBackground("account_auto_clean", func(taskCtx context.Context) error { + a.accounts.RunAccountAutoClean(taskCtx) + return nil + }) + startBackground("statsig_warmup", func(taskCtx context.Context) error { + a.runStatsigWarmup(taskCtx) + return nil + }) + startBackground("web_quota_startup_catchup", func(taskCtx context.Context) error { + a.runWebQuotaCatchup(taskCtx) + return nil + }) + startBackground("model_catalog_startup_catchup", func(taskCtx context.Context) error { + a.runModelCatalogCatchup(taskCtx) + return nil + }) + startBackground("video_recovery", func(taskCtx context.Context) error { + a.gateway.RunVideoRecovery(taskCtx) + return nil + }) + startBackground("video_workers", func(taskCtx context.Context) error { + a.gateway.RunVideoWorkers(taskCtx) + return nil + }) + startBackground("media_cleanup", func(taskCtx context.Context) error { + a.media.RunCleanup(taskCtx, func(err error) { + a.logger.Warn("media_cleanup_failed", "error", err) + }) + return nil + }) + startBackground("clearance_refresh", func(taskCtx context.Context) error { + if err := a.egress.RefreshDueClearances(taskCtx, false); err != nil { + a.logger.Warn("clearance_initial_refresh_failed", "error", err) + } + a.runPeriodicTask(taskCtx, time.Minute, "clearance_refresh", func(runCtx context.Context) error { + if err := a.egress.RefreshDueClearances(runCtx, false); err != nil { + a.logger.Warn("clearance_refresh_failed", "error", err) + } + return nil + }) + return nil + }) + startBackground("egress_operations", func(taskCtx context.Context) error { + if err := a.egressOps.RunMaintenance(taskCtx); err != nil { + a.logger.Warn("egress_operations_initial_run_failed", "error", err) + } + a.runPeriodicTask(taskCtx, time.Minute, "egress_operations", a.egressOps.RunMaintenance) + return nil + }) + if a.settingsBus != nil { + startBackground("settings_change_listener", func(taskCtx context.Context) error { + return a.settingsBus.ListenSettingsChanges(taskCtx, func(eventCtx context.Context) error { + reloadCtx, cancel := context.WithTimeout(eventCtx, 5*time.Second) + defer cancel() + if err := a.settings.ReloadPersisted(reloadCtx); err != nil { + a.logger.Warn("settings_reload_failed", "error", err) + } + return nil + }) + }) + } + a.queueDueWebQuotaRefresh(runCtx) + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := a.server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("关闭 HTTP 服务: %w", err) + } + return nil + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +func (a *Application) cleanupExpiredResponses(ctx context.Context, now time.Time) error { + cleanupCtx, cancel := context.WithTimeout(ctx, responseCleanupBudget) + defer cancel() + if a.cleanupLock != nil { + release, acquired, err := a.cleanupLock.Acquire(cleanupCtx, "response-ownership-cleanup", responseCleanupLockTTL) + if err != nil { + return err + } + if !acquired { + return nil + } + defer release() + } + var totalOwnership, totalWebState int64 + for range responseCleanupMaxBatches { + if err := cleanupCtx.Err(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + a.recordResponseCleanup(totalOwnership, totalWebState, true) + return nil + } + result, err := a.responses.DeleteExpired(cleanupCtx, now, responseOwnershipCleanupBatchSize, webResponseStateCleanupBatchSize) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + a.recordResponseCleanup(totalOwnership, totalWebState, true) + return nil + } + return err + } + totalOwnership += result.OwnershipDeleted + totalWebState += result.WebStateDeleted + if !result.HasMore { + a.recordResponseCleanup(totalOwnership, totalWebState, false) + return nil + } + } + a.recordResponseCleanup(totalOwnership, totalWebState, true) + return nil +} + +func (a *Application) recordResponseCleanup(ownershipDeleted, webStateDeleted int64, backlog bool) { + outcome := "complete" + if backlog { + outcome = "backlog" + a.logger.Warn("response_cleanup_backlog", "ownership_deleted", ownershipDeleted, "web_state_deleted", webStateDeleted) + } + labels := perfmetrics.Labels{Subsystem: "response", Operation: "cleanup", Outcome: outcome} + perfmetrics.Default.Add("response_cleanup_ownership_rows", labels, ownershipDeleted) + perfmetrics.Default.Add("response_cleanup_web_state_rows", labels, webStateDeleted) +} + +func (a *Application) logPerformanceMetrics() { + stats := a.database.Stats() + databaseLabels := perfmetrics.Labels{Subsystem: "database", Operation: a.database.Dialect()} + perfmetrics.Default.SetGauge("db_open_connections", databaseLabels, int64(stats.OpenConnections)) + perfmetrics.Default.SetGauge("db_in_use_connections", databaseLabels, int64(stats.InUse)) + perfmetrics.Default.SetGauge("db_idle_connections", databaseLabels, int64(stats.Idle)) + perfmetrics.Default.SetGauge("db_wait_count", databaseLabels, stats.WaitCount) + perfmetrics.Default.SetGauge("db_wait_duration_us", databaseLabels, stats.WaitDuration.Microseconds()) + if a.audits != nil { + a.audits.LedgerSnapshot() + } + if a.accounts != nil { + quota := a.accounts.QuotaRefreshStats() + labels := perfmetrics.Labels{Subsystem: "quota", Operation: "refresh"} + perfmetrics.Default.SetGauge("quota_refresh_pending", labels, int64(quota.Pending)) + perfmetrics.Default.SetGauge("quota_refresh_queued", labels, int64(quota.Queued)) + perfmetrics.Default.SetGauge("quota_refresh_running", labels, int64(quota.Running)) + } + for _, sample := range perfmetrics.Default.CollectAndReset() { + a.logger.Info("performance_metric", + "name", sample.Name, + "subsystem", sample.Labels.Subsystem, + "operation", sample.Labels.Operation, + "provider", sample.Labels.Provider, + "plane", sample.Labels.Plane, + "stage", sample.Labels.Stage, + "ordinal", sample.Labels.Ordinal, + "outcome", sample.Labels.Outcome, + "count", sample.Count, + "total", sample.Total, + "maximum", sample.Maximum, + "gauge", sample.Gauge, + "has_gauge", sample.HasGauge, + ) + } +} + +func (a *Application) Close() error { + var runtimeErr error + if a.runtime != nil { + runtimeErr = a.runtime.Close() + } + return errors.Join(runtimeErr, a.database.Close()) +} + +func (a *Application) runPeriodicTask(ctx context.Context, interval time.Duration, name string, task func(context.Context) error) { + timer := time.NewTimer(interval) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + runCtx, cancel := context.WithTimeout(ctx, minDuration(interval, 5*time.Minute)) + err := task(runCtx) + cancel() + if err != nil { + a.logger.Warn(name+"_failed", "error", err) + } + resetTimer(timer, interval) + } + } +} + +func (a *Application) runSupervisedTask(ctx context.Context, name string, task func(context.Context) error) { + backoff := time.Second + for { + err := batch.Do(ctx, task) + if ctx.Err() != nil { + return + } + if err == nil { + err = errors.New("后台任务意外退出") + } + var panicErr *batch.PanicError + if errors.As(err, &panicErr) { + a.logger.Error("background_task_restarting", "task", name, "backoff", backoff, "error", panicErr, "stack", string(panicErr.Stack)) + } else { + a.logger.Error("background_task_restarting", "task", name, "backoff", backoff, "error", err) + } + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + backoff = min(backoff*2, 30*time.Second) + } +} + +func resetTimer(timer *time.Timer, interval time.Duration) { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(interval) +} + +func minDuration(left, right time.Duration) time.Duration { + if left < right { + return left + } + return right +} diff --git a/backend/internal/app/console_routes_test.go b/backend/internal/app/console_routes_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0efe09016fda9542f99fdeca42fa06cb437b647b --- /dev/null +++ b/backend/internal/app/console_routes_test.go @@ -0,0 +1,32 @@ +package app + +import ( + "strings" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console" +) + +func TestConsoleRoutesUseStableProviderNamespace(t *testing.T) { + routes := consoleprovider.Routes() + if len(routes) == 0 { + t.Fatal("console catalog is empty") + } + seen := make(map[string]bool, len(routes)) + for _, route := range routes { + if route.Provider != account.ProviderConsole || !strings.HasPrefix(route.PublicID, "Console/") { + t.Fatalf("non-canonical console route = %#v", route) + } + if seen[route.PublicID] { + t.Fatalf("duplicate console public id %q", route.PublicID) + } + seen[route.PublicID] = true + } + if seen["Console/grok-4.3-console"] { + t.Fatal("legacy conflict suffix leaked into canonical Console model IDs") + } + if !seen["Console/grok-4.3"] { + t.Fatal("canonical Console/grok-4.3 route is missing") + } +} diff --git a/backend/internal/app/startup.go b/backend/internal/app/startup.go new file mode 100644 index 0000000000000000000000000000000000000000..71b4c7be0f8c579863b17a576f5a981186b4f045 --- /dev/null +++ b/backend/internal/app/startup.go @@ -0,0 +1,444 @@ +package app + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + auditapp "github.com/chenyme/grok2api/backend/internal/application/audit" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/repository" + httpserver "github.com/chenyme/grok2api/backend/internal/transport/http" +) + +const ( + startupRecoveryBudget = 20 * time.Second + startupCriticalWindow = 2 * time.Minute + startupCriticalLimit = 100 + statsigWarmupInterval = 15 * time.Minute + webQuotaStaleAfter = 30 * time.Minute + webQuotaCatchupEvery = 30 * time.Minute + modelCatalogStaleAfter = 24 * time.Hour + modelCatalogCatchupEvery = 6 * time.Hour +) + +type startupReport struct { + StartedAt time.Time + CompletedAt *time.Time + Credentials accountapp.CredentialStartupReport + CooldownsRestored int + QuotaRecoveriesRestored int + DueWebQuotasQueued int + StatsigKeysWarmed int + StaleWebQuotasFound int + StaleWebQuotasSynced int + StaleModelCatalogsFound int + StaleModelCatalogsSynced int + ErrorCount int +} + +type startupState struct { + mu sync.RWMutex + phase string + updatedAt time.Time + report startupReport + statsig httpserver.ReadinessComponent +} + +func newStartupState(restoredQuotaRecoveries int) *startupState { + now := time.Now().UTC() + return &startupState{ + phase: "booting", + updatedAt: now, + report: startupReport{ + StartedAt: now, + QuotaRecoveriesRestored: restoredQuotaRecoveries, + }, + statsig: httpserver.ReadinessComponent{State: "cold"}, + } +} + +func (s *startupState) setPhase(phase string) { + s.mu.Lock() + s.phase = phase + s.updatedAt = time.Now().UTC() + if phase == "running" { + completed := s.updatedAt + s.report.CompletedAt = &completed + } + s.mu.Unlock() +} + +func (s *startupState) updateReport(update func(*startupReport)) { + s.mu.Lock() + update(&s.report) + s.updatedAt = time.Now().UTC() + s.mu.Unlock() +} + +func (s *startupState) recordError(err error) { + if err == nil { + return + } + s.updateReport(func(report *startupReport) { + report.ErrorCount++ + }) +} + +func (s *startupState) setStatsig(state, detail string, warmed int) { + s.mu.Lock() + s.statsig = httpserver.ReadinessComponent{State: state, Detail: detail} + if warmed > 0 { + s.report.StatsigKeysWarmed = warmed + } + s.updatedAt = time.Now().UTC() + s.mu.Unlock() +} + +func (s *startupState) snapshot() (string, time.Time, startupReport, httpserver.ReadinessComponent) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.phase, s.updatedAt, s.report, s.statsig +} + +func (s *startupState) acceptsTraffic() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.phase == "running" +} + +func readinessSnapshot( + ctx context.Context, + state *startupState, + runtimeHealth func(context.Context) error, + models repository.ModelRepository, + accounts repository.AccountRepository, + providers *provider.Registry, + ledger *auditapp.Service, +) httpserver.ReadinessSnapshot { + phase, updatedAt, report, statsig := state.snapshot() + snapshot := httpserver.ReadinessSnapshot{ + Ready: false, State: phase, UpdatedAt: updatedAt, Startup: newReadinessStartupReport(report), + Components: map[string]httpserver.ReadinessComponent{ + "runtime_store": {State: "unknown"}, + "billing_ledger": {State: "unknown"}, + "grok_build": {State: "unknown"}, + "grok_web": {State: "unknown"}, + "statsig": statsig, + }, + } + if phase != "running" { + return snapshot + } + ledgerDegraded := false + healthCtx, cancel := context.WithTimeout(ctx, time.Second) + err := runtimeHealth(healthCtx) + cancel() + if err != nil { + snapshot.State = "not_ready" + snapshot.Components["runtime_store"] = httpserver.ReadinessComponent{State: "unavailable", Detail: "运行态存储不可用"} + return snapshot + } + snapshot.Components["runtime_store"] = httpserver.ReadinessComponent{State: "ready"} + if ledger != nil { + ledgerState := ledger.LedgerSnapshot() + if ledgerState.Ready { + snapshot.Components["billing_ledger"] = httpserver.ReadinessComponent{State: "ready"} + } else { + detail := fmt.Sprintf("审计账本不可用;连续失败 %d 次,丢失 %d 条,队列 %d/%d", ledgerState.ConsecutiveFailures, ledgerState.Dropped, ledgerState.QueueDepth, ledgerState.QueueCapacity) + snapshot.Components["billing_ledger"] = httpserver.ReadinessComponent{State: "degraded", Detail: detail} + if ledgerState.Irrecoverable || ledgerState.Mode == auditapp.LedgerModeEnforce { + snapshot.State = "not_ready" + return snapshot + } + ledgerDegraded = true + } + } + + routes, err := models.ListConfiguredEnabled(ctx) + if err != nil { + snapshot.State = "not_ready" + snapshot.Components["model_routes"] = httpserver.ReadinessComponent{State: "unavailable", Detail: "模型路由读取失败"} + return snapshot + } + if len(routes) == 0 { + snapshot.State = "not_ready" + snapshot.Components["model_routes"] = httpserver.ReadinessComponent{State: "unavailable", Detail: "没有启用的模型路由"} + return snapshot + } + snapshot.Components["model_routes"] = httpserver.ReadinessComponent{State: "ready", Detail: fmt.Sprintf("%d 条已启用路由", len(routes))} + + required := make(map[accountdomain.Provider]bool, 3) + usable := make(map[accountdomain.Provider]bool, 3) + providerErrors := make(map[accountdomain.Provider]bool, 3) + now := time.Now().UTC() + for _, route := range routes { + required[route.Provider] = true + if usable[route.Provider] || route.SupportedAccounts == 0 { + continue + } + candidates, listErr := accounts.ListRoutingCandidates(ctx, route.Provider, route.ID, route.UpstreamModel, providers.QuotaMode(route.Provider, route.UpstreamModel)) + if listErr != nil { + providerErrors[route.Provider] = true + continue + } + for _, candidate := range candidates { + if !startupCandidateUsable(candidate, now, providers) { + continue + } + material, materialErr := accounts.GetCredentialMaterial(ctx, candidate.Credential.ID, candidate.Credential.Provider) + if materialErr != nil { + if !errors.Is(materialErr, repository.ErrNotFound) { + providerErrors[route.Provider] = true + } + continue + } + if material.EncryptedAccessToken != "" { + usable[route.Provider] = true + break + } + } + } + + readyProviders := 0 + unavailableProviders := 0 + for _, providerValue := range accountdomain.Providers() { + name := string(providerValue) + if !required[providerValue] { + snapshot.Components[name] = httpserver.ReadinessComponent{State: "disabled"} + continue + } + if usable[providerValue] { + readyProviders++ + snapshot.Components[name] = httpserver.ReadinessComponent{State: "ready"} + continue + } + unavailableProviders++ + detail := "当前没有可用于已启用路由的账号" + if providerErrors[providerValue] { + detail = "账号候选状态读取失败" + } + snapshot.Components[name] = httpserver.ReadinessComponent{State: "unavailable", Detail: detail} + } + if required[accountdomain.ProviderWeb] && usable[accountdomain.ProviderWeb] && statsig.State != "warm" { + component := snapshot.Components[string(accountdomain.ProviderWeb)] + if statsig.State == "warming" || statsig.State == "cold" { + component.State = "warming" + } else { + component.State = "degraded" + } + component.Detail = "Statsig 尚未完成预热;请求仍可按需刷新" + snapshot.Components[string(accountdomain.ProviderWeb)] = component + unavailableProviders++ + } + if readyProviders == 0 { + snapshot.State = "not_ready" + return snapshot + } + snapshot.Ready = true + if unavailableProviders > 0 || ledgerDegraded { + snapshot.State = "degraded" + } else { + snapshot.State = "ready" + } + return snapshot +} + +// newReadinessStartupReport 只公开稳定统计,不把启动错误原文暴露到无鉴权就绪端点。 +func newReadinessStartupReport(report startupReport) *httpserver.ReadinessStartupReport { + return &httpserver.ReadinessStartupReport{ + StartedAt: report.StartedAt, + CompletedAt: report.CompletedAt, + Credentials: httpserver.ReadinessCredentialReport{ + SchedulesBackfilled: report.Credentials.SchedulesBackfilled, + CriticalFound: report.Credentials.CriticalFound, + Refreshed: report.Credentials.Refreshed, + Failed: report.Credentials.Failed, + }, + CooldownsRestored: report.CooldownsRestored, + QuotaRecoveriesRestored: report.QuotaRecoveriesRestored, + DueWebQuotasQueued: report.DueWebQuotasQueued, + StatsigKeysWarmed: report.StatsigKeysWarmed, + StaleWebQuotasFound: report.StaleWebQuotasFound, + StaleWebQuotasSynced: report.StaleWebQuotasSynced, + StaleModelCatalogsFound: report.StaleModelCatalogsFound, + StaleModelCatalogsSynced: report.StaleModelCatalogsSynced, + ErrorCount: report.ErrorCount, + } +} + +func startupCandidateUsable(candidate accountdomain.RoutingCandidate, now time.Time, providers *provider.Registry) bool { + credential := candidate.Credential + if credential.AuthType == "" || credential.AuthStatus != accountdomain.AuthStatusActive { + return false + } + refreshable := credential.AuthType == accountdomain.AuthTypeOAuth + if providers != nil { + refreshable = providers.SupportsCredentialRefresh(credential.Provider) + } + if refreshable && !credential.ExpiresAt.IsZero() && !now.Before(credential.ExpiresAt) { + return false + } + if credential.CooldownUntil != nil && now.Before(*credential.CooldownUntil) { + return false + } + if candidate.ModelCapabilityKnown && !candidate.SupportsModel { + return false + } + if candidate.ModelQuotaBlock != nil && now.Before(candidate.ModelQuotaBlock.CooldownUntil) { + return false + } + if candidate.QuotaRecovery != nil && candidate.QuotaRecovery.Status != accountdomain.QuotaRecoveryStatusActive { + return false + } + if candidate.Billing != nil && candidate.Billing.IsExhausted(credential.MinimumRemaining) { + return false + } + return candidate.QuotaWindow == nil || candidate.QuotaWindow.Remaining > 0 +} + +func (a *Application) reconcileStartup(ctx context.Context) { + a.startup.setPhase("reconciling") + recoveryCtx, cancel := context.WithTimeout(ctx, startupRecoveryBudget) + defer cancel() + + if _, err := a.clientKeys.CleanupExpiredBilling(recoveryCtx, 1000); err != nil { + a.logger.Warn("billing_reservation_cleanup_failed", "error", err) + a.startup.recordError(err) + } + if err := a.gateway.RecoverVideoJobs(recoveryCtx); err != nil { + a.logger.Warn("video_job_recovery_failed", "error", err) + a.startup.recordError(err) + } + if _, err := a.accountRepo.PruneExpiredModelQuotaBlocks(recoveryCtx, time.Now().UTC(), 1000); err != nil { + a.logger.Warn("model_cooldown_cleanup_failed", "error", err) + a.startup.recordError(err) + } + for _, providerValue := range accountdomain.Providers() { + values, err := a.accountRepo.ListEnabled(recoveryCtx, providerValue) + if err != nil { + a.startup.recordError(err) + continue + } + now := time.Now().UTC() + a.startup.updateReport(func(report *startupReport) { + for _, value := range values { + if value.CooldownUntil != nil && now.Before(*value.CooldownUntil) { + report.CooldownsRestored++ + } + } + }) + } + report, err := a.accounts.RecoverCriticalCredentials(recoveryCtx, startupCriticalWindow, startupCriticalLimit) + a.startup.updateReport(func(startup *startupReport) { startup.Credentials = report }) + if err != nil && ctx.Err() == nil { + a.logger.Warn("credential_startup_recovery_incomplete", "error", err, "found", report.CriticalFound, "refreshed", report.Refreshed, "failed", report.Failed) + a.startup.recordError(err) + } + a.startup.setPhase("running") + a.logger.Info("startup_reconciliation_completed", "credentials_backfilled", report.SchedulesBackfilled, "critical_found", report.CriticalFound, "credentials_refreshed", report.Refreshed, "credentials_failed", report.Failed) +} + +func (a *Application) runStatsigWarmup(ctx context.Context) { + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + a.startup.setStatsig("warming", "正在预热共享签名", 0) + values, err := a.accountRepo.ListEnabled(ctx, accountdomain.ProviderWeb) + if err == nil && len(values) == 0 { + a.startup.setStatsig("disabled", "没有启用的 Grok Web 账号", 0) + } else if err == nil { + warmCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + var warmed int + warmed, err = a.web.WarmStatsig(warmCtx, values[0]) + cancel() + if err == nil { + a.startup.setStatsig("warm", "共享签名已预热", warmed) + } + } + if err != nil && ctx.Err() == nil { + a.logger.Warn("web_statsig_warmup_failed", "error", err) + a.startup.setStatsig("unavailable", "预热失败,将由请求按需重试", 0) + } + resetTimer(timer, statsigWarmupInterval) + } +} + +func (a *Application) queueDueWebQuotaRefresh(ctx context.Context) { + windows, err := a.accounts.ListDueWebQuotaWindows(ctx, time.Now().UTC(), 1000) + if err != nil { + a.logger.Warn("web_quota_startup_catchup_failed", "error", err) + a.startup.recordError(err) + return + } + for _, window := range windows { + a.accounts.QueueWebQuotaRefresh(window.AccountID, window.Mode) + } + a.startup.updateReport(func(report *startupReport) { report.DueWebQuotasQueued = len(windows) }) + if len(windows) > 0 { + a.logger.Info("web_quota_startup_catchup_queued", "count", len(windows)) + } +} + +func (a *Application) runWebQuotaCatchup(ctx context.Context) { + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + ids, err := a.accountRepo.ListStaleWebQuotaAccountIDs(ctx, time.Now().UTC().Add(-webQuotaStaleAfter), 100) + if err == nil && len(ids) > 0 { + runCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + var succeeded int + succeeded, _, err = a.accounts.SyncWebQuotaAccounts(runCtx, ids) + cancel() + a.startup.updateReport(func(report *startupReport) { + report.StaleWebQuotasFound = len(ids) + report.StaleWebQuotasSynced = succeeded + }) + } + if err != nil && ctx.Err() == nil { + a.logger.Warn("web_quota_stale_catchup_failed", "error", err) + } + resetTimer(timer, webQuotaCatchupEvery) + } +} + +func (a *Application) runModelCatalogCatchup(ctx context.Context) { + timer := time.NewTimer(20 * time.Second) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + ids, err := a.modelRepo.ListStaleAccountSyncIDs(ctx, time.Now().UTC().Add(-modelCatalogStaleAfter), 100) + if err == nil && len(ids) > 0 { + runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + var succeeded int + succeeded, _, err = a.models.SyncAccounts(runCtx, ids) + cancel() + a.startup.updateReport(func(report *startupReport) { + report.StaleModelCatalogsFound = len(ids) + report.StaleModelCatalogsSynced = succeeded + }) + } + if err != nil && ctx.Err() == nil { + a.logger.Warn("model_catalog_stale_catchup_failed", "error", err) + } + resetTimer(timer, modelCatalogCatchupEvery) + } +} diff --git a/backend/internal/app/startup_test.go b/backend/internal/app/startup_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3f346552965b67d7bbb00a60d803cfec1823d128 --- /dev/null +++ b/backend/internal/app/startup_test.go @@ -0,0 +1,144 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestReadinessStartupReportDoesNotExposeInternalErrors(t *testing.T) { + state := newStartupState(0) + state.recordError(errors.New("postgres://private-host/internal")) + _, _, report, _ := state.snapshot() + payload, err := json.Marshal(newReadinessStartupReport(report)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), "private-host") || !strings.Contains(string(payload), `"errorCount":1`) { + t.Fatalf("public readiness leaked internal error: %s", payload) + } +} + +func TestReadinessKeepsBuildReadyWhenWebIsUnavailable(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "readiness.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + models := relational.NewModelRepository(database) + now := time.Now().UTC() + build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "build-ready", SourceKey: "build-ready", + EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", ExpiresAt: now.Add(time.Hour), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := models.UpsertRoutes(ctx, []modeldomain.Route{ + {PublicID: "build-model", Provider: accountdomain.ProviderBuild, UpstreamModel: "build-model", Capability: modeldomain.CapabilityResponses, Enabled: true}, + {PublicID: "web-model", Provider: accountdomain.ProviderWeb, UpstreamModel: "web-model", Capability: modeldomain.CapabilityChat, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"build-model"}, now); err != nil { + t.Fatal(err) + } + state := newStartupState(0) + state.setPhase("running") + state.setStatsig("unavailable", "test", 0) + snapshot := readinessSnapshot(ctx, state, func(context.Context) error { return nil }, models, accounts, provider.NewRegistry(), nil) + if !snapshot.Ready || snapshot.State != "degraded" { + t.Fatalf("snapshot = %#v", snapshot) + } + if snapshot.Components["grok_build"].State != "ready" || snapshot.Components["grok_web"].State != "unavailable" { + t.Fatalf("components = %#v", snapshot.Components) + } +} + +func TestReadinessRejectsAccountWithoutAccessToken(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "readiness-missing-access.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + models := relational.NewModelRepository(database) + now := time.Now().UTC() + build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "refresh-only", SourceKey: "refresh-only", EncryptedRefreshToken: "refresh", + ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := models.UpsertRoutes(ctx, []modeldomain.Route{{ + PublicID: "build-model", Provider: accountdomain.ProviderBuild, UpstreamModel: "build-model", Capability: modeldomain.CapabilityResponses, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"build-model"}, now); err != nil { + t.Fatal(err) + } + state := newStartupState(0) + state.setPhase("running") + snapshot := readinessSnapshot(ctx, state, func(context.Context) error { return nil }, models, accounts, provider.NewRegistry(), nil) + if snapshot.Ready || snapshot.Components["grok_build"].State != "unavailable" { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func TestReadinessRestoresPersistedCooldownWithoutUpstreamProbe(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "cooldown-readiness.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + models := relational.NewModelRepository(database) + now := time.Now().UTC() + cooldownUntil := now.Add(10 * time.Minute) + build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "cooling", SourceKey: "cooling", + EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", ExpiresAt: now.Add(time.Hour), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, CooldownUntil: &cooldownUntil, + }) + if err != nil { + t.Fatal(err) + } + if err := models.UpsertRoutes(ctx, []modeldomain.Route{{PublicID: "build-model", Provider: accountdomain.ProviderBuild, UpstreamModel: "build-model", Capability: modeldomain.CapabilityResponses, Enabled: true}}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"build-model"}, now); err != nil { + t.Fatal(err) + } + state := newStartupState(0) + state.setPhase("running") + snapshot := readinessSnapshot(ctx, state, func(context.Context) error { return nil }, models, accounts, provider.NewRegistry(), nil) + if snapshot.Ready || snapshot.State != "not_ready" || snapshot.Components["grok_build"].State != "unavailable" { + t.Fatalf("snapshot = %#v", snapshot) + } +} diff --git a/backend/internal/app/topology.go b/backend/internal/app/topology.go new file mode 100644 index 0000000000000000000000000000000000000000..34911baa1bd75613466f8e338157ee1291992116 --- /dev/null +++ b/backend/internal/app/topology.go @@ -0,0 +1,72 @@ +package app + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chenyme/grok2api/backend/internal/infra/config" +) + +const sharedMediaMarkerName = ".grok2api-cluster" + +// preflightDeployment validates the operator-declared shared media mount with a stable cluster marker and a read/write probe. +func preflightDeployment(cfg config.Config) error { + if cfg.Deployment.Replicas <= 1 { + return nil + } + directory := cfg.Media.Local.Path + markerPath := filepath.Join(directory, sharedMediaMarkerName) + want := strings.TrimSpace(cfg.Deployment.ClusterID) + "\n" + current, err := os.ReadFile(markerPath) + if errors.Is(err, os.ErrNotExist) { + file, createErr := os.OpenFile(markerPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if createErr == nil { + if _, writeErr := file.WriteString(want); writeErr != nil { + _ = file.Close() + return fmt.Errorf("write shared media cluster marker: %w", writeErr) + } + if closeErr := file.Close(); closeErr != nil { + return fmt.Errorf("close shared media cluster marker: %w", closeErr) + } + current = []byte(want) + } else if errors.Is(createErr, os.ErrExist) { + current, err = os.ReadFile(markerPath) + if err != nil { + return fmt.Errorf("read shared media cluster marker: %w", err) + } + } else { + return fmt.Errorf("create shared media cluster marker: %w", createErr) + } + } else if err != nil { + return fmt.Errorf("read shared media cluster marker: %w", err) + } + if string(current) != want { + return fmt.Errorf("shared media cluster marker mismatch: configured cluster %q does not own %s", cfg.Deployment.ClusterID, markerPath) + } + + probe, err := os.CreateTemp(directory, ".grok2api-preflight-") + if err != nil { + return fmt.Errorf("create shared media preflight file: %w", err) + } + probePath := probe.Name() + defer os.Remove(probePath) + payload := []byte(strings.TrimSpace(cfg.Deployment.InstanceID)) + if _, err := probe.Write(payload); err != nil { + _ = probe.Close() + return fmt.Errorf("write shared media preflight file: %w", err) + } + if err := probe.Close(); err != nil { + return fmt.Errorf("close shared media preflight file: %w", err) + } + readBack, err := os.ReadFile(probePath) + if err != nil { + return fmt.Errorf("read shared media preflight file: %w", err) + } + if string(readBack) != string(payload) { + return errors.New("shared media preflight read-back mismatch") + } + return nil +} diff --git a/backend/internal/app/topology_test.go b/backend/internal/app/topology_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d522ddaa57e55e34948f5b9ba37d60dd4579251f --- /dev/null +++ b/backend/internal/app/topology_test.go @@ -0,0 +1,32 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chenyme/grok2api/backend/internal/infra/config" +) + +func TestPreflightDeploymentCreatesAndValidatesSharedMediaMarker(t *testing.T) { + directory := t.TempDir() + cfg := config.Config{ + Deployment: config.DeploymentConfig{Replicas: 2, InstanceID: "replica-a", ClusterID: "cluster-a", SharedMedia: true}, + Media: config.MediaConfig{Local: config.LocalMediaConfig{Path: directory}}, + } + if err := preflightDeployment(cfg); err != nil { + t.Fatal(err) + } + marker, err := os.ReadFile(filepath.Join(directory, sharedMediaMarkerName)) + if err != nil || string(marker) != "cluster-a\n" { + t.Fatalf("marker = %q, err = %v", marker, err) + } + cfg.Deployment.InstanceID = "replica-b" + if err := preflightDeployment(cfg); err != nil { + t.Fatal(err) + } + cfg.Deployment.ClusterID = "cluster-b" + if err := preflightDeployment(cfg); err == nil { + t.Fatal("expected cluster marker mismatch") + } +} diff --git a/backend/internal/application/account/account_cleanup_test.go b/backend/internal/application/account/account_cleanup_test.go new file mode 100644 index 0000000000000000000000000000000000000000..afa5436d3f65f9333aa85489840bbcc85add961f --- /dev/null +++ b/backend/internal/application/account/account_cleanup_test.go @@ -0,0 +1,157 @@ +package account + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestCleanupAccountsDeletesOnlySelectedCurrentStatuses(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "account-cleanup.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + token, err := cipher.Encrypt("cleanup-token") + if err != nil { + t.Fatal(err) + } + repo := relational.NewAccountRepository(database) + service := NewService(repo, nil, nil, memory.NewStickyStore(), nil, cipher, nil) + service.now = func() time.Time { return now } + + create := func(name string, providerValue accountdomain.Provider, mutate func(*accountdomain.Credential)) uint64 { + t.Helper() + value, _, createErr := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: providerValue, AuthType: accountdomain.AuthTypeSSO, Name: name, SourceKey: fmt.Sprintf("cleanup-%s", name), + EncryptedAccessToken: token, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if createErr != nil { + t.Fatal(createErr) + } + if mutate != nil { + mutate(&value) + value, createErr = repo.Update(ctx, value) + if createErr != nil { + t.Fatal(createErr) + } + } + return value.ID + } + + normalID := create("normal", accountdomain.ProviderBuild, nil) + disabledID := create("disabled", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { value.Enabled = false }) + invalidID := create("invalid", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { value.AuthStatus = accountdomain.AuthStatusReauthRequired }) + coolingID := create("cooling", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { until := now.Add(time.Hour); value.CooldownUntil = &until }) + expiredCooldownID := create("expired-cooldown", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { until := now.Add(-time.Hour); value.CooldownUntil = &until }) + otherProviderID := create("web-disabled", accountdomain.ProviderWeb, func(value *accountdomain.Credential) { value.Enabled = false }) + + result, err := service.CleanupAccounts(ctx, accountdomain.ProviderBuild, []CleanupStatus{CleanupStatusDisabled, CleanupStatusReauthRequired, CleanupStatusCooldown, CleanupStatusDisabled}, nil) + if err != nil { + t.Fatal(err) + } + if result.Deleted != 3 || result.RootsDeleted != 3 || result.LinkedDeleted != 0 || result.Skipped != 0 { + t.Fatalf("result = %#v", result) + } + for _, id := range []uint64{disabledID, invalidID, coolingID} { + if _, err := repo.Get(ctx, id); err == nil { + t.Fatalf("account %d was not deleted", id) + } + } + for _, id := range []uint64{normalID, expiredCooldownID, otherProviderID} { + if _, err := repo.Get(ctx, id); err != nil { + t.Fatalf("account %d should remain: %v", id, err) + } + } +} + +func TestCleanupAccountsRequiresStatus(t *testing.T) { + service := NewService(nil, nil, nil, nil, nil, nil, nil) + if _, err := service.CleanupAccounts(context.Background(), accountdomain.ProviderBuild, nil, nil); err == nil { + t.Fatal("empty cleanup status unexpectedly succeeded") + } + // Linked targets cannot include the root provider or an invalid provider. + if _, err := service.CleanupAccounts(context.Background(), accountdomain.ProviderBuild, []CleanupStatus{CleanupStatusDisabled}, []accountdomain.Provider{accountdomain.ProviderBuild}); err == nil { + t.Fatal("self-target cleanup unexpectedly succeeded") + } + if _, err := service.CleanupAccounts(context.Background(), accountdomain.ProviderBuild, []CleanupStatus{CleanupStatusDisabled}, []accountdomain.Provider{accountdomain.Provider("nope")}); err == nil { + t.Fatal("invalid target cleanup unexpectedly succeeded") + } +} + +// Cleanup with linked targets removes peers regardless of peer state and reports exact counts. +func TestCleanupAccountsWithLinkedTargets(t *testing.T) { + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-cleanup-linked.db") + now := time.Now().UTC() + service.now = func() time.Time { return now } + + web, build, console := seedLinkedTrio(t, repo, strings.Repeat("7", 64), "u-cleanup") + // Mark the Web root invalid while keeping active peers to verify peer state is ignored. + web.AuthStatus = accountdomain.AuthStatusReauthRequired + if _, err := repo.Update(ctx, web); err != nil { + t.Fatal(err) + } + healthyWeb := mustUpsertLinked(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "healthy", SourceKey: "sso:" + strings.Repeat("8", 64), + }) + + result, err := service.CleanupAccounts(ctx, accountdomain.ProviderWeb, []CleanupStatus{CleanupStatusReauthRequired}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if result.Deleted != 3 || result.RootsDeleted != 1 || result.LinkedDeleted != 2 || result.Skipped != 0 { + t.Fatalf("result = %#v", result) + } + assertAccountMissing(t, repo, web.ID) + assertAccountMissing(t, repo, build.ID) + assertAccountMissing(t, repo, console.ID) + assertAccountPresent(t, repo, healthyWeb.ID) +} + +// Cleanup preview counts roots and linked targets without deleting rows. +func TestPreviewCleanupCountsWithoutDeleting(t *testing.T) { + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-cleanup-preview.db") + now := time.Now().UTC() + service.now = func() time.Time { return now } + + web, build, console := seedLinkedTrio(t, repo, strings.Repeat("9", 64), "u-preview") + web.Enabled = false + if _, err := repo.Update(ctx, web); err != nil { + t.Fatal(err) + } + + preview, err := service.PreviewCleanup(ctx, accountdomain.ProviderWeb, []CleanupStatus{CleanupStatusDisabled, CleanupStatusCooldown}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if preview.RootsByStatus["disabled"] != 1 || preview.RootsByStatus["cooldown"] != 0 || preview.RootCount != 1 || preview.Total != 3 { + t.Fatalf("preview = %#v", preview) + } + assertAccountPresent(t, repo, web.ID) + assertAccountPresent(t, repo, build.ID) + assertAccountPresent(t, repo, console.ID) + + if _, err := service.PreviewCleanup(ctx, accountdomain.ProviderWeb, nil, nil); err == nil { + t.Fatal("empty statuses preview unexpectedly succeeded") + } +} diff --git a/backend/internal/application/account/auto_clean.go b/backend/internal/application/account/auto_clean.go new file mode 100644 index 0000000000000000000000000000000000000000..cf0f78549cbc51c7332c385fd20aaeb394f4db43 --- /dev/null +++ b/backend/internal/application/account/auto_clean.go @@ -0,0 +1,265 @@ +package account + +import ( + "context" + "fmt" + "time" + + "github.com/chenyme/grok2api/backend/internal/repository" +) + +// AutoCleanConfig 是账号自动清理策略;由 app 层从运行设置映射,不依赖 infra/config。 +type AutoCleanConfig struct { + Enabled bool + Interval time.Duration + MinAge time.Duration + IncludeDisabled bool +} + +const ( + autoCleanReauthBatchSize = 100 + autoCleanReauthMaxScans = 50 + autoCleanReauthMaxDeletes = 10 + autoCleanReauthLockKey = "account-auto-clean:reauth" + autoCleanReauthLockTTL = 5 * time.Minute + autoCleanReauthRunTimeout = 4 * time.Minute + autoCleanRuntimeWriteLimit = 3 * time.Second +) + +// UpdateAutoCleanConfig 热更新账号自动清理策略。 +// 仅在策略实际变化时唤醒调度器;唤醒只重排 timer,不会直接硬删。 +func (s *Service) UpdateAutoCleanConfig(value AutoCleanConfig) { + value = normalizeAutoCleanConfig(value) + s.autoCleanMu.Lock() + if s.autoClean == value { + s.autoCleanMu.Unlock() + return + } + s.autoClean = value + s.autoCleanRevision++ + s.autoCleanMu.Unlock() + select { + case s.autoCleanWake <- struct{}{}: + default: + } +} + +func normalizeAutoCleanConfig(value AutoCleanConfig) AutoCleanConfig { + if value.Interval < time.Minute { + value.Interval = time.Minute + } + if value.Interval > time.Hour { + value.Interval = time.Hour + } + if value.MinAge < time.Minute { + value.MinAge = time.Minute + } + if value.MinAge > 30*24*time.Hour { + value.MinAge = 30 * 24 * time.Hour + } + return value +} + +func (s *Service) autoCleanSnapshot() (AutoCleanConfig, uint64) { + s.autoCleanMu.RLock() + defer s.autoCleanMu.RUnlock() + return s.autoClean, s.autoCleanRevision +} + +func (s *Service) autoCleanConfig() AutoCleanConfig { + value, _ := s.autoCleanSnapshot() + return value +} + +func (s *Service) autoCleanRevisionCurrent(expected uint64, cfg AutoCleanConfig) bool { + current, revision := s.autoCleanSnapshot() + return revision == expected && current == cfg +} + +func autoCleanInterval(cfg AutoCleanConfig) time.Duration { + if !cfg.Enabled { + return time.Hour + } + return normalizeAutoCleanConfig(cfg).Interval +} + +// RunAccountAutoClean 在启用时周期性删除过期的 reauthRequired 账号;默认关闭。 +// timer 绑定配置 revision,旧 timer 即使与热更新同时就绪也只能重排,不能使用新配置执行删除。 +func (s *Service) RunAccountAutoClean(ctx context.Context) { + // NewService / 启动接线可能已向 wake 写入;启动时统一按最新快照排程。 + select { + case <-s.autoCleanWake: + default: + } + cfg, scheduledRevision := s.autoCleanSnapshot() + timer := time.NewTimer(autoCleanInterval(cfg)) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-s.autoCleanWake: + cfg, scheduledRevision = s.autoCleanSnapshot() + resetCredentialRefreshTimer(timer, autoCleanInterval(cfg)) + case <-timer.C: + current, revision := s.autoCleanSnapshot() + if revision == scheduledRevision && current.Enabled { + if err := s.runAutoCleanReauthRevision(ctx, current, revision); err != nil && ctx.Err() == nil { + s.logger.Warn("account_auto_clean_failed", "error", err) + } + } + cfg, scheduledRevision = s.autoCleanSnapshot() + resetCredentialRefreshTimer(timer, autoCleanInterval(cfg)) + } + } +} + +// runAutoCleanReauth 保留给同包测试与维护调用;生产调度使用带 revision 的实现。 +func (s *Service) runAutoCleanReauth(ctx context.Context, cfg AutoCleanConfig) error { + _, revision := s.autoCleanSnapshot() + return s.runAutoCleanReauthRevision(ctx, cfg, revision) +} + +func (s *Service) runAutoCleanReauthRevision(ctx context.Context, cfg AutoCleanConfig, revision uint64) error { + if !cfg.Enabled || !s.autoCleanRevisionCurrent(revision, cfg) { + return nil + } + runCtx, cancel := context.WithTimeout(ctx, autoCleanReauthRunTimeout) + defer cancel() + if s.refreshLock != nil { + release, acquired, err := s.refreshLock.Acquire(runCtx, autoCleanReauthLockKey, autoCleanReauthLockTTL) + if err != nil { + return err + } + if !acquired { + s.logger.Debug("account_auto_clean_skipped", "reason", "lock_contended") + return nil + } + if release != nil { + defer release() + } + } + + markedBefore := s.now().Add(-cfg.MinAge) + var afterID uint64 + scanned := 0 + deleted := 0 + skipped := 0 + activeSkipped := 0 + scanBatches := 0 + deleteBatches := 0 + limitReached := false + exhausted := false + for scanBatches < autoCleanReauthMaxScans && deleteBatches < autoCleanReauthMaxDeletes { + if !s.autoCleanRevisionCurrent(revision, cfg) { + break + } + candidates, err := s.accounts.ListAutoCleanReauthCandidates(runCtx, markedBefore, cfg.IncludeDisabled, afterID, autoCleanReauthBatchSize) + if err != nil { + if scanned > 0 || deleted > 0 || skipped > 0 { + s.logger.Warn("auto_clean_reauth_partial", "deleted", deleted, "scanned", scanned, "skipped", skipped, "error", err) + } + return err + } + if len(candidates) == 0 { + exhausted = true + break + } + scanBatches++ + scanned += len(candidates) + afterID = candidates[len(candidates)-1] + deletable, active, err := s.excludeAccountsWithActiveLeases(runCtx, candidates) + if err != nil { + return err + } + activeSkipped += active + skipped += active + if !s.autoCleanRevisionCurrent(revision, cfg) { + break + } + if len(deletable) == 0 { + if len(candidates) < autoCleanReauthBatchSize { + exhausted = true + break + } + continue + } + deleteBatches++ + ids, err := s.accounts.DeleteAutoCleanReauthCandidates(runCtx, markedBefore, cfg.IncludeDisabled, deletable) + if err != nil { + if scanned > 0 || deleted > 0 || skipped > 0 { + s.logger.Warn("auto_clean_reauth_partial", "deleted", deleted, "scanned", scanned, "skipped", skipped, "error", err) + } + return err + } + deleted += len(ids) + skipped += len(deletable) - len(ids) + if len(ids) > 0 { + s.logger.Debug("auto_clean_reauth_batch_deleted", "account_ids", ids) + if failures, cleanupErr := s.clearDeletedAccountRuntimeState(runCtx, ids); cleanupErr != nil { + s.logger.Warn("auto_clean_reauth_runtime_cleanup_failed", "failures", failures, "error", cleanupErr) + } + } + if len(candidates) < autoCleanReauthBatchSize { + exhausted = true + break + } + } + limitReached = !exhausted && (scanBatches == autoCleanReauthMaxScans || deleteBatches == autoCleanReauthMaxDeletes) + if deleted > 0 { + s.invalidateBuildBotFlagCache() + } + if scanned > 0 || deleted > 0 || skipped > 0 { + s.logger.Info("auto_clean_reauth", "deleted", deleted, "scanned", scanned, "skipped", skipped, "active_skipped", activeSkipped, "scan_batches", scanBatches, "delete_batches", deleteBatches, "limit_reached", limitReached, "min_age", cfg.MinAge.String(), "include_disabled", cfg.IncludeDisabled) + } + return nil +} + +func (s *Service) excludeAccountsWithActiveLeases(ctx context.Context, ids []uint64) ([]uint64, int, error) { + if len(ids) == 0 || s.concurrency == nil { + return append([]uint64(nil), ids...), 0, nil + } + keys := make([]string, len(ids)) + for index, id := range ids { + keys[index] = repository.AccountConcurrencyKey(id) + } + values := make(map[string]int, len(keys)) + if reader, ok := s.concurrency.(repository.ConcurrencySnapshotReader); ok { + current, err := reader.CurrentMany(ctx, keys) + if err != nil { + return nil, 0, err + } + values = current + } else { + for _, key := range keys { + current, err := s.concurrency.Current(ctx, key) + if err != nil { + return nil, 0, err + } + values[key] = current + } + } + deletable := make([]uint64, 0, len(ids)) + active := 0 + for index, id := range ids { + if values[keys[index]] > 0 { + active++ + continue + } + deletable = append(deletable, id) + } + return deletable, active, nil +} + +func (s *Service) clearDeletedAccountRuntimeState(ctx context.Context, ids []uint64) (int, error) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), autoCleanRuntimeWriteLimit) + defer cancel() + failures, firstErr := s.deleteStickyAccounts(cleanupCtx, ids) + for _, id := range ids { + s.clearRefreshState(id) + } + if failures == 0 { + return 0, nil + } + return failures, fmt.Errorf("清理已删除账号的会话粘滞状态失败: %w", firstErr) +} diff --git a/backend/internal/application/account/auto_clean_test.go b/backend/internal/application/account/auto_clean_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a1334579f638eb20d1e65303c55d3f198a475723 --- /dev/null +++ b/backend/internal/application/account/auto_clean_test.go @@ -0,0 +1,528 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestAutoCleanReauthRespectsMinAgeAndIncludeDisabled(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + + aged := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "aged-reauth", SourceKey: "aged-reauth", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + fresh := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "fresh-reauth", SourceKey: "fresh-reauth", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-10 * time.Minute)), + }) + activePermanent := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "active-permanent", SourceKey: "active-permanent", + EncryptedAccessToken: "x", EncryptedRefreshToken: "r", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + RefreshPermanent: true, ExpiresAt: now.Add(time.Hour), + }) + cooldownUntil := now.Add(time.Hour) + cooldown := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "cooldown", SourceKey: "cooldown", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + CooldownUntil: &cooldownUntil, + }) + disabledAged := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "disabled-aged", SourceKey: "disabled-aged", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-3 * time.Hour)), + }) + disabledAged.Enabled = false + var err error + disabledAged, err = repo.Update(ctx, disabledAged) + if err != nil { + t.Fatal(err) + } + + // Flag off is a no-op. + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: false, Interval: 10 * time.Minute, MinAge: time.Hour, + }) + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertPresent(t, repo, aged.ID) + assertPresent(t, repo, fresh.ID) + assertPresent(t, repo, disabledAged.ID) + + // Enabled without include-disabled: only aged enabled reauth is deleted. + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: true, Interval: 10 * time.Minute, MinAge: time.Hour, IncludeDisabled: false, + }) + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertMissing(t, repo, aged.ID) + assertPresent(t, repo, fresh.ID) + assertPresent(t, repo, activePermanent.ID) + assertPresent(t, repo, cooldown.ID) + assertPresent(t, repo, disabledAged.ID) + + // Include disabled: aged disabled reauth is deleted; fresh remains. + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: true, Interval: 10 * time.Minute, MinAge: time.Hour, IncludeDisabled: true, + }) + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertMissing(t, repo, disabledAged.ID) + assertPresent(t, repo, fresh.ID) + assertPresent(t, repo, activePermanent.ID) + assertPresent(t, repo, cooldown.ID) + + // Advance clock past minAge for the remaining fresh reauth. + service.now = func() time.Time { return now.Add(2 * time.Hour) } + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertMissing(t, repo, fresh.ID) + assertPresent(t, repo, activePermanent.ID) + assertPresent(t, repo, cooldown.ID) +} + +func TestMarkReauthRequiredSetsAnchorAndEditDoesNotReset(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 15, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + + value := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "anchor", SourceKey: "anchor", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err := service.MarkReauthRequired(ctx, value.ID, "token rejected"); err != nil { + t.Fatal(err) + } + marked, err := repo.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if marked.AuthStatus != accountdomain.AuthStatusReauthRequired || marked.ReauthMarkedAt == nil { + t.Fatalf("expected reauth anchor, got %#v", marked) + } + anchor := *marked.ReauthMarkedAt + + // Ordinary edit must not reset reauth_marked_at. + marked.Name = "anchor-renamed" + if _, err := repo.Update(ctx, marked); err != nil { + t.Fatal(err) + } + afterEdit, err := repo.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if afterEdit.ReauthMarkedAt == nil || !afterEdit.ReauthMarkedAt.Equal(anchor) { + t.Fatalf("reauth_marked_at reset by edit: before=%s after=%v", anchor, afterEdit.ReauthMarkedAt) + } +} + +func TestAutoCleanReauthMultiBatch(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 18, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + + const total = 105 + ids := make([]uint64, 0, total) + for i := 0; i < total; i++ { + value := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "batch-" + itoa(i), SourceKey: "batch-" + itoa(i), + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + ids = append(ids, value.ID) + } + + // 直接验证 repo 分批:第一批最多 100,且 nextAfter 前进。 + candidates, err := repo.ListAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, 0, 100) + if err != nil { + t.Fatal(err) + } + deleted, err := repo.DeleteAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, candidates) + if err != nil { + t.Fatal(err) + } + if len(candidates) != 100 || len(deleted) != 100 || candidates[len(candidates)-1] == 0 { + t.Fatalf("first batch candidates=%d deleted=%d nextAfter=%d", len(candidates), len(deleted), candidates[len(candidates)-1]) + } + remaining := 0 + for _, id := range ids { + if _, getErr := repo.Get(ctx, id); getErr == nil { + remaining++ + } + } + if remaining != 5 { + t.Fatalf("remaining after first batch = %d", remaining) + } + + // 应用层应扫完全部剩余。 + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: true, Interval: 10 * time.Minute, MinAge: time.Hour, + }) + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + for _, id := range ids { + assertMissing(t, repo, id) + } +} + +func TestSecondMarkReauthKeepsOriginalAnchor(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 16, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + value := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "second-mark", SourceKey: "second-mark", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err := service.MarkReauthRequired(ctx, value.ID, "first"); err != nil { + t.Fatal(err) + } + first, err := repo.Get(ctx, value.ID) + if err != nil || first.ReauthMarkedAt == nil { + t.Fatalf("first mark = %#v err=%v", first, err) + } + anchor := *first.ReauthMarkedAt + time.Sleep(5 * time.Millisecond) + if err := service.MarkReauthRequired(ctx, value.ID, "second"); err != nil { + t.Fatal(err) + } + second, err := repo.Get(ctx, value.ID) + if err != nil || second.ReauthMarkedAt == nil || !second.ReauthMarkedAt.Equal(anchor) { + t.Fatalf("anchor reset: first=%s second=%v", anchor, second.ReauthMarkedAt) + } +} + +func TestRunAccountAutoCleanDoesNotDeleteOnEnableOrWake(t *testing.T) { + now := time.Date(2026, 7, 20, 19, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + aged := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "wake-aged", SourceKey: "wake-aged", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + + // 启用只写入配置并唤醒;本身不删除。 + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: true, Interval: time.Minute, MinAge: time.Hour, + }) + assertPresent(t, repo, aged.ID) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + service.RunAccountAutoClean(ctx) + }() + + // 启动时会 drain 启动前 wake 并 arm timer;热更 wake 只重排 timer。 + time.Sleep(150 * time.Millisecond) + assertPresent(t, repo, aged.ID) + + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: true, Interval: time.Minute, MinAge: time.Hour, IncludeDisabled: true, + }) + time.Sleep(150 * time.Millisecond) + assertPresent(t, repo, aged.ID) + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("auto-clean scheduler did not stop") + } + assertPresent(t, repo, aged.ID) +} + +func newAutoCleanTestService(t *testing.T, now time.Time) (*Service, *relational.AccountRepository) { + t.Helper() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "auto-clean.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewAccountRepository(database) + service := NewService(repo, nil, nil, memory.NewStickyStore(), nil, nil, nil) + service.now = func() time.Time { return now } + return service, repo +} + +func mustUpsert(t *testing.T, repo *relational.AccountRepository, value accountdomain.Credential) accountdomain.Credential { + t.Helper() + out, _, err := repo.UpsertByIdentity(context.Background(), value) + if err != nil { + t.Fatal(err) + } + return out +} + +func assertMissing(t *testing.T, repo *relational.AccountRepository, id uint64) { + t.Helper() + if _, err := repo.Get(context.Background(), id); err == nil { + t.Fatalf("account %d still present", id) + } else if !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("account %d get error: %v", id, err) + } +} + +func assertPresent(t *testing.T, repo *relational.AccountRepository, id uint64) { + t.Helper() + if _, err := repo.Get(context.Background(), id); err != nil { + t.Fatalf("account %d missing or error: %v", id, err) + } +} + +func ptrTime(value time.Time) *time.Time { return &value } + +func itoa(value int) string { + if value == 0 { + return "0" + } + var buf [12]byte + i := len(buf) + for value > 0 { + i-- + buf[i] = byte('0' + value%10) + value /= 10 + } + return string(buf[i:]) +} + +func TestUpdateAutoCleanConfigClamps(t *testing.T) { + service, _ := newAutoCleanTestService(t, time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)) + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: true, Interval: 30 * time.Second, MinAge: 10 * time.Second, IncludeDisabled: true, + }) + cfg := service.autoCleanConfig() + if cfg.Interval != time.Minute || cfg.MinAge != time.Minute || !cfg.IncludeDisabled || !cfg.Enabled { + t.Fatalf("low clamp = %#v", cfg) + } + service.UpdateAutoCleanConfig(AutoCleanConfig{ + Enabled: false, Interval: 2 * time.Hour, MinAge: 40 * 24 * time.Hour, + }) + cfg = service.autoCleanConfig() + if cfg.Interval != time.Hour || cfg.MinAge != 30*24*time.Hour || cfg.Enabled { + t.Fatalf("high clamp = %#v", cfg) + } + if got := autoCleanInterval(AutoCleanConfig{Enabled: false, Interval: time.Minute}); got != time.Hour { + t.Fatalf("disabled interval = %s", got) + } + if got := autoCleanInterval(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute}); got != 5*time.Minute { + t.Fatalf("enabled interval = %s", got) + } +} + +func TestAutoCleanSkipsActiveInferenceLease(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 22, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + limiter := memory.NewConcurrencyLimiter() + service.SetConcurrencyLimiter(limiter) + value := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "active-lease", SourceKey: "active-lease", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + release, acquired, err := limiter.Acquire(ctx, repository.AccountConcurrencyKey(value.ID), 1) + if err != nil || !acquired { + t.Fatalf("acquire lease: acquired=%v err=%v", acquired, err) + } + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour}) + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertPresent(t, repo, value.ID) + release() + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertMissing(t, repo, value.ID) +} + +func TestAutoCleanConfigRevisionRejectsOldTimerAndUnchangedUpdateDoesNotWake(t *testing.T) { + service, _ := newAutoCleanTestService(t, time.Date(2026, 7, 20, 23, 0, 0, 0, time.UTC)) + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: time.Hour}) + select { + case <-service.autoCleanWake: + default: + t.Fatal("initial config update did not wake scheduler") + } + cfg, revision := service.autoCleanSnapshot() + service.UpdateAutoCleanConfig(cfg) + select { + case <-service.autoCleanWake: + t.Fatal("unchanged config woke scheduler") + default: + } + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: 2 * time.Hour}) + if service.autoCleanRevisionCurrent(revision, cfg) { + t.Fatal("old timer revision remained executable after config update") + } +} + +type deniedAutoCleanLock struct{} + +func (deniedAutoCleanLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) { + return nil, false, nil +} + +func TestAutoCleanSkipsWhenDistributedLockIsHeld(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC) + service, repo := newAutoCleanTestService(t, now) + service.refreshLock = deniedAutoCleanLock{} + value := mustUpsert(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "locked", SourceKey: "locked", + EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour}) + if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + assertPresent(t, repo, value.ID) +} + +type endlessAutoCleanRepository struct { + repository.AccountRepository + listCalls int + deleteCalls int + deletedIDs []uint64 +} + +func (r *endlessAutoCleanRepository) ListAutoCleanReauthCandidates(_ context.Context, _ time.Time, _ bool, afterID uint64, _ int) ([]uint64, error) { + r.listCalls++ + ids := make([]uint64, autoCleanReauthBatchSize) + for index := range ids { + ids[index] = afterID + uint64(index) + 1 + } + return ids, nil +} + +func (r *endlessAutoCleanRepository) DeleteAutoCleanReauthCandidates(_ context.Context, _ time.Time, _ bool, ids []uint64) ([]uint64, error) { + r.deleteCalls++ + r.deletedIDs = append(r.deletedIDs, ids...) + return append([]uint64(nil), ids...), nil +} + +func TestAutoCleanLimitsWorkPerTick(t *testing.T) { + repo := &endlessAutoCleanRepository{} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour}) + if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + if repo.listCalls != autoCleanReauthMaxDeletes || repo.deleteCalls != autoCleanReauthMaxDeletes { + t.Fatalf("calls list=%d delete=%d", repo.listCalls, repo.deleteCalls) + } +} + +type activeKeyConcurrency struct { + active map[string]struct{} + all bool +} + +func (*activeKeyConcurrency) Acquire(context.Context, string, int) (func(), bool, error) { + return func() {}, true, nil +} + +func (l *activeKeyConcurrency) Current(_ context.Context, key string) (int, error) { + if l.all { + return 1, nil + } + if _, ok := l.active[key]; ok { + return 1, nil + } + return 0, nil +} + +func (l *activeKeyConcurrency) CurrentMany(_ context.Context, keys []string) (map[string]int, error) { + values := make(map[string]int, len(keys)) + for _, key := range keys { + current, _ := l.Current(context.Background(), key) + values[key] = current + } + return values, nil +} + +func TestAutoCleanActiveOnlyPagesDoNotConsumeDeleteBudget(t *testing.T) { + repo := &endlessAutoCleanRepository{} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + active := make(map[string]struct{}, 2*autoCleanReauthBatchSize) + for id := uint64(1); id <= 2*autoCleanReauthBatchSize; id++ { + active[repository.AccountConcurrencyKey(id)] = struct{}{} + } + service.SetConcurrencyLimiter(&activeKeyConcurrency{active: active}) + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour}) + if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + if repo.listCalls != autoCleanReauthMaxDeletes+2 || repo.deleteCalls != autoCleanReauthMaxDeletes { + t.Fatalf("calls list=%d delete=%d", repo.listCalls, repo.deleteCalls) + } + if len(repo.deletedIDs) == 0 || repo.deletedIDs[0] != 2*autoCleanReauthBatchSize+1 { + t.Fatalf("first deleted id=%v", repo.deletedIDs) + } +} + +func TestAutoCleanActiveOnlySourceIsBoundedByScanBudget(t *testing.T) { + repo := &endlessAutoCleanRepository{} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + service.SetConcurrencyLimiter(&activeKeyConcurrency{all: true}) + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour}) + if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + if repo.listCalls != autoCleanReauthMaxScans || repo.deleteCalls != 0 { + t.Fatalf("calls list=%d delete=%d", repo.listCalls, repo.deleteCalls) + } +} + +type configChangingConcurrency struct { + service *Service + once bool +} + +func (c *configChangingConcurrency) Acquire(context.Context, string, int) (func(), bool, error) { + return func() {}, true, nil +} + +func (c *configChangingConcurrency) Current(context.Context, string) (int, error) { + if !c.once { + c.once = true + c.service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: 2 * time.Hour}) + } + return 0, nil +} + +func TestAutoCleanPolicyChangeAbortsBeforeDelete(t *testing.T) { + repo := &endlessAutoCleanRepository{} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + service.SetConcurrencyLimiter(&configChangingConcurrency{service: service}) + service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: time.Hour}) + if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil { + t.Fatal(err) + } + if repo.deleteCalls != 0 { + t.Fatalf("delete calls after policy change=%d", repo.deleteCalls) + } +} diff --git a/backend/internal/application/account/batch_quota_test.go b/backend/internal/application/account/batch_quota_test.go new file mode 100644 index 0000000000000000000000000000000000000000..75b03f87f4393b37d44083054f9b0dd61afc004b --- /dev/null +++ b/backend/internal/application/account/batch_quota_test.go @@ -0,0 +1,93 @@ +package account + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "sync/atomic" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestBatchRefreshQuotaSupportsWebAndConsole(t *testing.T) { + for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} { + t.Run(string(providerValue), func(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedToken, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + ids := make([]uint64, 0, 2) + for index := 1; index <= 2; index++ { + value, _, createErr := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: providerValue, AuthType: accountdomain.AuthTypeSSO, + Name: fmt.Sprintf("account-%d", index), SourceKey: fmt.Sprintf("source-%d", index), + EncryptedAccessToken: encryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if createErr != nil { + t.Fatal(createErr) + } + ids = append(ids, value.ID) + } + adapter := &selectedQuotaAdapter{providerValue: providerValue} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore()) + + succeeded, failed, err := service.BatchRefreshQuota(ctx, ids) + if err != nil { + t.Fatal(err) + } + if succeeded != 2 || failed != 0 || adapter.calls.Load() != 2 { + t.Fatalf("succeeded=%d failed=%d calls=%d", succeeded, failed, adapter.calls.Load()) + } + windows, err := repository.GetQuotaWindows(ctx, ids) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + if len(windows[id]) != 1 || windows[id][0].Remaining != 7 { + t.Fatalf("account %d windows = %#v", id, windows[id]) + } + } + }) + } +} + +type selectedQuotaAdapter struct { + providerValue accountdomain.Provider + calls atomic.Int64 +} + +func (a *selectedQuotaAdapter) Provider() accountdomain.Provider { return a.providerValue } + +func (a *selectedQuotaAdapter) SyncQuota(_ context.Context, _ accountdomain.Credential) (provider.QuotaSnapshot, error) { + a.calls.Add(1) + now := time.Now().UTC() + return provider.QuotaSnapshot{ + Tier: accountdomain.WebTierSuper, SyncedAt: now, + Windows: []accountdomain.QuotaWindow{{Mode: "default", Remaining: 7, Total: 10, SyncedAt: &now, UpdatedAt: now}}, + }, nil +} + +func (a *selectedQuotaAdapter) SyncQuotaMode(_ context.Context, _ accountdomain.Credential, mode string) (accountdomain.QuotaWindow, error) { + return accountdomain.QuotaWindow{Mode: mode}, nil +} diff --git a/backend/internal/application/account/batch_update_test.go b/backend/internal/application/account/batch_update_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e1e1628997d96bbfc29851ff84c1a7160cb70ef5 --- /dev/null +++ b/backend/internal/application/account/batch_update_test.go @@ -0,0 +1,79 @@ +package account + +import ( + "context" + "errors" + "slices" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type batchUpdateRepository struct { + repository.AccountRepository + updateErr error + updatedIDs []uint64 +} + +func (r *batchUpdateRepository) UpdateMany(_ context.Context, providerValue accountdomain.Provider, ids []uint64, _ repository.AccountUpdates) (int64, error) { + if providerValue != accountdomain.ProviderBuild { + return 0, errors.New("unexpected provider") + } + r.updatedIDs = append([]uint64(nil), ids...) + if r.updateErr != nil { + return 0, r.updateErr + } + return int64(len(ids)), nil +} + +func TestBatchUpdateSupportsMoreThanAdminPageLimit(t *testing.T) { + ids := make([]uint64, 2501) + for index := range ids { + ids[index] = uint64(index + 1) + } + repo := &batchUpdateRepository{} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + maxConcurrent := 3 + + updated, err := service.BatchUpdate(context.Background(), accountdomain.ProviderBuild, ids, UpdateInput{MaxConcurrent: &maxConcurrent}) + if err != nil { + t.Fatal(err) + } + if updated != int64(len(ids)) || !slices.Equal(repo.updatedIDs, ids) { + t.Fatalf("updated = %d, ids = %d", updated, len(repo.updatedIDs)) + } +} + +func TestBatchUpdatePreservesProviderMismatchSemantics(t *testing.T) { + ids := make([]uint64, 501) + for index := range ids { + ids[index] = uint64(index + 1) + } + repo := &batchUpdateRepository{updateErr: repository.ErrAccountPoolMismatch} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + maxConcurrent := 3 + + _, err := service.BatchUpdate(context.Background(), accountdomain.ProviderBuild, ids, UpdateInput{MaxConcurrent: &maxConcurrent}) + if !errors.Is(err, ErrAccountPoolMismatch) { + t.Fatalf("error = %v, want account pool mismatch", err) + } +} + +func TestBatchUpdateRetainsBoundedRequestSize(t *testing.T) { + ids := make([]uint64, maxBatchUpdateAccounts+1) + for index := range ids { + ids[index] = uint64(index + 1) + } + repo := &batchUpdateRepository{} + service := NewService(repo, nil, nil, nil, nil, nil, nil) + maxConcurrent := 3 + + _, err := service.BatchUpdate(context.Background(), accountdomain.ProviderBuild, ids, UpdateInput{MaxConcurrent: &maxConcurrent}) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("error = %v, want invalid input", err) + } + if len(repo.updatedIDs) != 0 { + t.Fatal("oversized update reached repository") + } +} diff --git a/backend/internal/application/account/build_super_entitlement_test.go b/backend/internal/application/account/build_super_entitlement_test.go new file mode 100644 index 0000000000000000000000000000000000000000..38e4ae44ffa1a8c6db006c04b523b5bcc8d7cd16 --- /dev/null +++ b/backend/internal/application/account/build_super_entitlement_test.go @@ -0,0 +1,189 @@ +package account + +import ( + "context" + "encoding/base64" + "errors" + "path/filepath" + "sync/atomic" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func openAccountService(t *testing.T) (*Service, repository.AccountRepository) { + t.Helper() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "build-super.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + audits := relational.NewAuditRepository(database) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + service := NewService(accounts, audits, nil, nil, nil, cipher, nil) + return service, accounts +} + +type credentialMetadataAdapterStub struct { + calls *atomic.Int32 +} + +func (credentialMetadataAdapterStub) Provider() accountdomain.Provider { + return accountdomain.ProviderBuild +} + +func (s credentialMetadataAdapterStub) CredentialMetadata(credential accountdomain.Credential) provider.CredentialMetadata { + if s.calls != nil { + s.calls.Add(1) + } + return provider.CredentialMetadata{BuildBotFlagged: credential.ID == 1} +} + +func TestBuildBotFlagSummaryUsesShortLivedCache(t *testing.T) { + ctx := context.Background() + service, accounts := openAccountService(t) + var calls atomic.Int32 + service.providers = provider.NewRegistry(credentialMetadataAdapterStub{calls: &calls}) + if _, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "flagged", SourceKey: "cached-build-bot-flag", + EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true, + }); err != nil { + t.Fatal(err) + } + + if _, err := service.buildBotFlaggedAccountIDs(ctx); err != nil { + t.Fatal(err) + } + if _, err := service.buildBotFlaggedAccountIDs(ctx); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("metadata inspections = %d, want 1", got) + } + service.invalidateBuildBotFlagCache() + if _, err := service.buildBotFlaggedAccountIDs(ctx); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("metadata inspections after invalidation = %d, want 2", got) + } +} + +func TestAccountViewsIncludeBuildBotFlagMetadata(t *testing.T) { + ctx := context.Background() + service, accounts := openAccountService(t) + service.providers = provider.NewRegistry(credentialMetadataAdapterStub{}) + build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "flagged", SourceKey: "build-bot-flag", + EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + view, err := service.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + if !view.BuildBotFlagged { + t.Fatal("single account view did not include bot flag metadata") + } + views, _, err := service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderBuild)}) + if err != nil { + t.Fatal(err) + } + if len(views) != 1 || !views[0].BuildBotFlagged { + t.Fatalf("list views = %#v", views) + } + normal, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "normal", SourceKey: "build-normal", + EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + views, total, err := service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderBuild), Risk: "flagged"}) + if err != nil || total != 1 || len(views) != 1 || views[0].Credential.ID != build.ID { + t.Fatalf("flagged views=%#v total=%d err=%v", views, total, err) + } + views, total, err = service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderBuild), Risk: "normal"}) + if err != nil || total != 1 || len(views) != 1 || views[0].Credential.ID != normal.ID { + t.Fatalf("normal views=%#v total=%d err=%v", views, total, err) + } + if _, _, err := service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderWeb), Risk: "flagged"}); !errors.Is(err, ErrInvalidFilter) { + t.Fatalf("non-Build risk filter err = %v", err) + } + summary, err := service.Summary(ctx) + if err != nil || summary.Risk != 1 { + t.Fatalf("summary=%#v err=%v", summary, err) + } +} + +func TestUpdateBuildSuperEntitledBuildOnly(t *testing.T) { + ctx := context.Background() + service, accounts := openAccountService(t) + build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "build", SourceKey: "build-super-patch", + EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + web, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "web-super-patch", + EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + trueVal := true + view, err := service.Update(ctx, build.ID, UpdateInput{BuildSuperEntitled: &trueVal}) + if err != nil { + t.Fatal(err) + } + if !view.Credential.BuildSuperEntitled { + t.Fatalf("build entitlement not set: %#v", view.Credential) + } + if view.Quota.Type != QuotaTypePaid || view.Quota.Source != "buildSuperEntitlement" || view.Quota.Confidence != "confirmed" { + t.Fatalf("quota = %#v", view.Quota) + } + // 零 Billing 数值保持未知/零。 + if view.Quota.LimitKnown || view.Quota.Used != 0 || view.Quota.Limit != 0 { + t.Fatalf("must not fabricate limits: %#v", view.Quota) + } + if _, err := service.Update(ctx, web.ID, UpdateInput{BuildSuperEntitled: &trueVal}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("web update err = %v", err) + } + xaiMode := accountdomain.BuildRouteXAI + view, err = service.Update(ctx, build.ID, UpdateInput{BuildRouteMode: &xaiMode}) + if err != nil || view.Credential.BuildRouteMode != accountdomain.BuildRouteXAI { + t.Fatalf("route update view=%#v err=%v", view.Credential, err) + } + if _, err := service.Update(ctx, web.ID, UpdateInput{BuildRouteMode: &xaiMode}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("web route update err = %v", err) + } + invalidMode := accountdomain.BuildRouteMode("invalid") + if _, err := service.Update(ctx, build.ID, UpdateInput{BuildRouteMode: &invalidMode}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid route update err = %v", err) + } + // 清除 entitlement + falseVal := false + view, err = service.Update(ctx, build.ID, UpdateInput{BuildSuperEntitled: &falseVal}) + if err != nil { + t.Fatal(err) + } + if view.Credential.BuildSuperEntitled || view.Quota.Type != QuotaTypeUnknown { + t.Fatalf("cleared view = %#v quota=%#v", view.Credential, view.Quota) + } +} diff --git a/backend/internal/application/account/console_quota_test.go b/backend/internal/application/account/console_quota_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6579add537dff4796432e01dd18db7f488b4425a --- /dev/null +++ b/backend/internal/application/account/console_quota_test.go @@ -0,0 +1,25 @@ +package account + +import ( + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestPreserveActiveQuotaWindowsUntilReset(t *testing.T) { + now := time.Now().UTC() + future := now.Add(time.Hour) + past := now.Add(-time.Second) + incoming := []accountdomain.QuotaWindow{{Mode: "console", Remaining: 20, Total: 20}} + + active := preserveActiveQuotaWindows([]accountdomain.QuotaWindow{{Mode: "console", Remaining: 7, Total: 20, ResetAt: &future}}, incoming, now) + if len(active) != 1 || active[0].Remaining != 7 { + t.Fatalf("active window = %#v", active) + } + + expired := preserveActiveQuotaWindows([]accountdomain.QuotaWindow{{Mode: "console", Remaining: 0, Total: 20, ResetAt: &past}}, incoming, now) + if len(expired) != 1 || expired[0].Remaining != 20 { + t.Fatalf("expired window = %#v", expired) + } +} diff --git a/backend/internal/application/account/conversion_test.go b/backend/internal/application/account/conversion_test.go new file mode 100644 index 0000000000000000000000000000000000000000..28aa2234984318e056eb95540f9c28197a1dd70a --- /dev/null +++ b/backend/internal/application/account/conversion_test.go @@ -0,0 +1,278 @@ +package account + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "sync/atomic" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestConvertWebAccountsToBuildIsIdempotent(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conversion.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedSSO, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + webAccount, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "web-source", + EncryptedAccessToken: encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := &buildConversionAdapter{} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore()) + first, err := service.ConvertWebAccountsToBuild(ctx, []uint64{webAccount.ID}) + if err != nil { + t.Fatal(err) + } + if first.Created != 1 || first.Linked != 0 || first.Skipped != 0 || first.Failed != 0 || len(first.BuildAccountIDs) != 1 { + t.Fatalf("first conversion = %#v", first) + } + var secondProgress [][2]int + second, err := service.ConvertWebAccountsToBuildWithProgress(ctx, []uint64{webAccount.ID}, nil, func(completed, total int) error { + secondProgress = append(secondProgress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if second.Created != 0 || second.Linked != 0 || second.Skipped != 1 || second.Failed != 0 || adapter.calls.Load() != 1 { + t.Fatalf("second conversion = %#v, calls = %d", second, adapter.calls.Load()) + } + if len(secondProgress) != 1 || secondProgress[0] != [2]int{0, 0} { + t.Fatalf("second progress = %#v", secondProgress) + } + linkedWeb, err := repository.Get(ctx, webAccount.ID) + if err != nil { + t.Fatal(err) + } + if linkedWeb.LinkedAccountID != first.BuildAccountIDs[0] || linkedWeb.LinkedProvider != accountdomain.ProviderBuild { + t.Fatalf("linked web account = %#v", linkedWeb) + } +} + +func TestConvertWebAccountsToBuildAllRefreshesLinkedCredential(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conversion-refresh.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedSSO, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + webAccount, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "web-source", + EncryptedAccessToken: encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := &buildConversionAdapter{} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore()) + first, err := service.ConvertWebAccountsToBuild(ctx, []uint64{webAccount.ID}) + if err != nil { + t.Fatal(err) + } + refreshed, err := service.ConvertWebAccountsToBuildWithStrategy(ctx, []uint64{webAccount.ID}, BuildConversionAll, nil, nil) + if err != nil { + t.Fatal(err) + } + if refreshed.Created != 0 || refreshed.Linked != 1 || refreshed.Skipped != 0 || refreshed.Failed != 0 || len(refreshed.BuildAccountIDs) != 1 { + t.Fatalf("refreshed conversion = %#v", refreshed) + } + if refreshed.BuildAccountIDs[0] != first.BuildAccountIDs[0] || adapter.calls.Load() != 2 { + t.Fatalf("build ids first=%v refreshed=%v calls=%d", first.BuildAccountIDs, refreshed.BuildAccountIDs, adapter.calls.Load()) + } + buildAccount, err := repository.Get(ctx, first.BuildAccountIDs[0]) + if err != nil { + t.Fatal(err) + } + accessToken, err := cipher.Decrypt(buildAccount.EncryptedAccessToken) + if err != nil { + t.Fatal(err) + } + if accessToken != "access-2" { + t.Fatalf("access token = %q", accessToken) + } +} + +func TestConvertAllWebAccountsToBuildUsesUnlinkedPool(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conversion-all.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedSSO, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + createWeb := func(name, source string) accountdomain.Credential { + value, _, createErr := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: name, SourceKey: source, + EncryptedAccessToken: encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if createErr != nil { + t.Fatal(createErr) + } + return value + } + firstWeb := createWeb("web-1", "web-source-1") + createWeb("web-2", "web-source-2") + adapter := &buildConversionAdapter{} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore()) + if _, err := service.ConvertWebAccountsToBuild(ctx, []uint64{firstWeb.ID}); err != nil { + t.Fatal(err) + } + observed := make([]uint64, 0, 1) + progress := make([][2]int, 0, 2) + result, err := service.ConvertAllWebAccountsToBuildWithProgress(ctx, func(accountID uint64) error { + observed = append(observed, accountID) + return nil + }, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Created != 1 || result.Linked != 0 || result.Skipped != 0 || result.Failed != 0 || adapter.calls.Load() != 2 || len(observed) != 1 || observed[0] != result.BuildAccountIDs[0] { + t.Fatalf("all conversion = %#v, calls = %d", result, adapter.calls.Load()) + } + if len(progress) != 2 || progress[0] != [2]int{0, 1} || progress[1] != [2]int{1, 1} { + t.Fatalf("progress = %#v", progress) + } + empty, err := service.ConvertAllWebAccountsToBuild(ctx) + if err != nil { + t.Fatal(err) + } + if empty.Created != 0 || empty.Linked != 0 || empty.Skipped != 0 || empty.Failed != 0 || len(empty.BuildAccountIDs) != 0 || adapter.calls.Load() != 2 { + t.Fatalf("empty conversion = %#v, calls = %d", empty, adapter.calls.Load()) + } + resynced, err := service.ConvertAllWebAccountsToBuildWithStrategy(ctx, BuildConversionAll, nil, nil) + if err != nil { + t.Fatal(err) + } + if resynced.Created != 0 || resynced.Linked != 2 || resynced.Skipped != 0 || resynced.Failed != 0 || len(resynced.BuildAccountIDs) != 2 || adapter.calls.Load() != 4 { + t.Fatalf("resynced conversion = %#v, calls = %d", resynced, adapter.calls.Load()) + } +} + +func TestConvertAllWebAccountsToBuildProcessesMoreThanLegacyLimitInBatches(t *testing.T) { + const totalAccounts = maxBuildConversionAccounts + 1 + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedSSO, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + repository := &conversionBatchRepository{total: totalAccounts, encryptedSSO: encryptedSSO} + adapter := &buildConversionAdapter{} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore()) + progress := make([][2]int, 0, totalAccounts+1) + result, err := service.ConvertAllWebAccountsToBuildWithProgress(context.Background(), nil, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Created != totalAccounts || result.Linked != 0 || result.Skipped != 0 || result.Failed != 0 || len(result.BuildAccountIDs) != totalAccounts { + t.Fatalf("conversion result = %#v", result) + } + if adapter.calls.Load() != totalAccounts || repository.listCalls != 2 { + t.Fatalf("adapter calls = %d, repository batches = %d", adapter.calls.Load(), repository.listCalls) + } + if len(progress) != totalAccounts+1 || progress[0] != [2]int{0, totalAccounts} || progress[len(progress)-1] != [2]int{totalAccounts, totalAccounts} { + t.Fatalf("progress first=%v last=%v count=%d", progress[0], progress[len(progress)-1], len(progress)) + } +} + +type conversionBatchRepository struct { + repository.AccountRepository + total int + encryptedSSO string + listCalls int + nextBuildID atomic.Uint64 +} + +func (r *conversionBatchRepository) ListUnlinkedWebAccountIDs(_ context.Context, afterID uint64, limit int) ([]uint64, int64, error) { + r.listCalls++ + ids := make([]uint64, 0, limit) + for id := afterID + 1; id <= uint64(r.total) && len(ids) < limit; id++ { + ids = append(ids, id) + } + return ids, int64(r.total), nil +} + +func (r *conversionBatchRepository) Get(_ context.Context, id uint64) (accountdomain.Credential, error) { + return accountdomain.Credential{ + ID: id, Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: fmt.Sprintf("web-%d", id), SourceKey: fmt.Sprintf("web-source-%d", id), + EncryptedAccessToken: r.encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }, nil +} + +func (r *conversionBatchRepository) UpsertByIdentity(_ context.Context, value accountdomain.Credential) (accountdomain.Credential, bool, error) { + value.ID = 10_000 + r.nextBuildID.Add(1) + return value, true, nil +} + +func (r *conversionBatchRepository) LinkWebToBuild(context.Context, uint64, uint64) error { return nil } + +type buildConversionAdapter struct{ calls atomic.Int64 } + +func (a *buildConversionAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderWeb } + +func (a *buildConversionAdapter) ConvertToBuild(_ context.Context, credential accountdomain.Credential) (provider.CredentialSeed, error) { + call := a.calls.Add(1) + return provider.CredentialSeed{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", UserID: credential.SourceKey, + SourceKey: fmt.Sprintf("converted:%s:%d", credential.SourceKey, call), OIDCClientID: "client", + AccessToken: fmt.Sprintf("access-%d", call), RefreshToken: fmt.Sprintf("refresh-%d", call), ExpiresAt: time.Now().UTC().Add(time.Hour), + }, nil +} diff --git a/backend/internal/application/account/credential_refresh_test.go b/backend/internal/application/account/credential_refresh_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2ae0849c8463de9f287db87f3cd7f4bc76d6b39d --- /dev/null +++ b/backend/internal/application/account/credential_refresh_test.go @@ -0,0 +1,706 @@ +package account + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestEnsureCredentialReusesRotatedTokenAndThrottlesForcedRefresh(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + + first, err := service.EnsureCredential(ctx, credential, true) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 1 || first.EncryptedAccessToken != "access-1" { + t.Fatalf("first refresh = %#v, count = %d", first, adapter.refreshCount.Load()) + } + + fromStaleRequest, err := service.EnsureCredential(ctx, credential, true) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 1 || fromStaleRequest.EncryptedAccessToken != first.EncryptedAccessToken { + t.Fatalf("stale request caused another refresh: count = %d", adapter.refreshCount.Load()) + } + + duringCooldown, err := service.EnsureCredential(ctx, first, true) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 1 || duringCooldown.EncryptedAccessToken != first.EncryptedAccessToken { + t.Fatalf("forced refresh cooldown failed: count = %d", adapter.refreshCount.Load()) + } + + now = now.Add(forcedRefreshMinInterval + time.Second) + afterCooldown, err := service.EnsureCredential(ctx, first, true) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 2 || afterCooldown.EncryptedAccessToken != "access-2" { + t.Fatalf("refresh after cooldown = %#v, count = %d", afterCooldown, adapter.refreshCount.Load()) + } + + manual, err := service.ensureCredential(ctx, afterCooldown, ensureCredentialOptions{force: true, bypassCooldown: true, retryPermanentOnce: true}) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 3 || manual.EncryptedAccessToken != "access-3" { + t.Fatalf("manual refresh did not bypass cooldown: count = %d", adapter.refreshCount.Load()) + } +} + +func TestEnsureCredentialCollapsesConcurrentForcedRefreshes(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + adapter.delay = 30 * time.Millisecond + + const callers = 20 + start := make(chan struct{}) + errors := make(chan error, callers) + var workers sync.WaitGroup + workers.Add(callers) + for range callers { + go func() { + defer workers.Done() + <-start + value, err := service.EnsureCredential(ctx, credential, true) + if err == nil && value.EncryptedAccessToken != "access-1" { + err = fmt.Errorf("access token = %q", value.EncryptedAccessToken) + } + errors <- err + }() + } + close(start) + workers.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + if adapter.refreshCount.Load() != 1 { + t.Fatalf("refresh count = %d", adapter.refreshCount.Load()) + } +} + +func TestEnsureCredentialCollapsesRefreshAcrossServiceInstances(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "credential-refresh-multi-instance.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + credential, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "multi-instance", SourceKey: "multi-instance", + EncryptedAccessToken: "access-0", EncryptedRefreshToken: "refresh-0", ExpiresAt: now.Add(time.Hour), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + adapter := &credentialRefreshAdapter{delay: 40 * time.Millisecond} + registry := provider.NewRegistry(adapter) + lock := memory.NewLockStore() + first := NewService(repository, nil, nil, nil, registry, nil, lock) + second := NewService(repository, nil, nil, nil, registry, nil, lock) + start := make(chan struct{}) + errors := make(chan error, 2) + for _, service := range []*Service{first, second} { + go func(service *Service) { + <-start + _, refreshErr := service.EnsureCredential(ctx, credential, true) + errors <- refreshErr + }(service) + } + close(start) + for range 2 { + if err := <-errors; err != nil { + t.Fatal(err) + } + } + if adapter.refreshCount.Load() != 1 { + t.Fatalf("refresh count = %d", adapter.refreshCount.Load()) + } +} + +func TestEnsureCredentialRefreshesWhenAccessTokenIsMissing(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + credential, err := service.accounts.UpdateTokens(ctx, credential.ID, "", "refresh-only", now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + + refreshed, err := service.EnsureCredential(ctx, credential, false) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 1 || refreshed.EncryptedAccessToken != "access-1" { + t.Fatalf("refresh-only credential was not refreshed: %#v, count = %d", refreshed, adapter.refreshCount.Load()) + } +} + +func TestCredentialRefreshSchedulerRefreshesOnlyDueAccounts(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return time.Now().UTC() } + dueAt := now.Add(-time.Minute) + credential.RefreshDueAt = &dueAt + credential, err := service.accounts.Update(ctx, credential) + if err != nil { + t.Fatal(err) + } + far, _, err := service.accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "far", SourceKey: "far", + EncryptedAccessToken: "far-access", EncryptedRefreshToken: "far-refresh", ExpiresAt: now.Add(6 * time.Hour), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + service.RunCredentialRefresh(runCtx) + close(done) + }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("credential refresh scheduler did not stop") + } + }) + + deadline := time.Now().Add(2 * time.Second) + var updated accountdomain.Credential + for time.Now().Before(deadline) { + updated, err = service.accounts.Get(ctx, credential.ID) + if err == nil && adapter.refreshCount.Load() == 1 && updated.LastRefreshAt != nil && updated.RefreshFailureCount == 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if adapter.refreshCount.Load() != 1 { + t.Fatalf("refresh count = %d", adapter.refreshCount.Load()) + } + if err != nil { + t.Fatal(err) + } + if updated.RefreshDueAt == nil || !updated.RefreshDueAt.After(time.Now()) || updated.LastRefreshAt == nil || updated.RefreshFailureCount != 0 { + t.Fatalf("updated credential = %#v", updated) + } + farUpdated, err := service.accounts.Get(ctx, far.ID) + if err != nil { + t.Fatal(err) + } + if farUpdated.EncryptedAccessToken != "far-access" || farUpdated.LastRefreshAt != nil { + t.Fatalf("far credential was refreshed: %#v", farUpdated) + } +} + +func TestStartupRecoveryPreservesFutureRefreshSchedule(t *testing.T) { + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + originalDue := credential.RefreshDueAt + + report, err := service.RecoverCriticalCredentials(context.Background(), 2*time.Minute, 100) + if err != nil { + t.Fatal(err) + } + if report.CriticalFound != 0 || adapter.refreshCount.Load() != 0 { + t.Fatalf("report=%#v refreshes=%d", report, adapter.refreshCount.Load()) + } + stored, err := service.accounts.Get(context.Background(), credential.ID) + if err != nil { + t.Fatal(err) + } + if originalDue == nil || stored.RefreshDueAt == nil || !stored.RefreshDueAt.Equal(*originalDue) { + t.Fatalf("refresh due changed: before=%v after=%v", originalDue, stored.RefreshDueAt) + } +} + +func TestStartupRecoveryRefreshesExpiredCredential(t *testing.T) { + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + expired, err := service.accounts.UpdateTokens(context.Background(), credential.ID, credential.EncryptedAccessToken, credential.EncryptedRefreshToken, now.Add(-time.Minute)) + if err != nil { + t.Fatal(err) + } + if expired.RefreshDueAt == nil || expired.RefreshDueAt.After(now) { + t.Fatalf("expired refresh due = %v", expired.RefreshDueAt) + } + + report, err := service.RecoverCriticalCredentials(context.Background(), 2*time.Minute, 100) + if err != nil { + t.Fatal(err) + } + if report.CriticalFound != 1 || report.Refreshed != 1 || report.Failed != 0 || adapter.refreshCount.Load() != 1 { + t.Fatalf("report=%#v refreshes=%d", report, adapter.refreshCount.Load()) + } +} + +func TestStartupRecoveryRespectsContextBudget(t *testing.T) { + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + if _, err := service.accounts.UpdateTokens(context.Background(), credential.ID, credential.EncryptedAccessToken, credential.EncryptedRefreshToken, now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + adapter.delay = time.Second + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + started := time.Now() + _, err := service.RecoverCriticalCredentials(ctx, 2*time.Minute, 100) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v", err) + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("startup recovery exceeded budget: %s", elapsed) + } +} + +func TestCredentialRefreshDueQueryStaysBoundedForLargePool(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + service, _, _ := newCredentialRefreshTestService(t, now) + values := make([]accountdomain.Credential, 0, 1000) + for index := range 1000 { + name := fmt.Sprintf("large-%04d", index) + values = append(values, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: name, SourceKey: name, + EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", ExpiresAt: now.Add(time.Minute), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, + }) + } + if _, err := service.accounts.UpsertManyByIdentity(ctx, values); err != nil { + t.Fatal(err) + } + ids, err := service.accounts.ListDueCredentialRefreshIDs(ctx, now, credentialRefreshBatchSize) + if err != nil { + t.Fatal(err) + } + if len(ids) != credentialRefreshBatchSize { + t.Fatalf("due batch size = %d", len(ids)) + } + next, err := service.accounts.NextCredentialRefreshDueAt(ctx) + if err != nil || next == nil || next.After(now) { + t.Fatalf("next due = %v, err = %v", next, err) + } +} + +func TestCredentialRefreshFailureDistinguishesTransientAndPermanent(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + + adapter.refreshErr = &provider.CredentialRefreshError{Status: 503, Code: "oauth_unavailable", Message: "Please retry later", Response: `{"error":"oauth_unavailable","message":"Please retry later"}`} + if _, err := service.EnsureCredential(ctx, credential, true); err == nil { + t.Fatal("transient refresh unexpectedly succeeded") + } + transient, err := service.accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if transient.AuthStatus != accountdomain.AuthStatusActive || transient.RefreshFailureCount != 1 || transient.LastRefreshErrorStatus != 503 || transient.LastRefreshErrorCode != "oauth_unavailable" || transient.LastRefreshErrorMessage != "Please retry later" || transient.LastRefreshErrorResponse == "" || transient.RefreshPermanent || transient.RefreshDueAt == nil || !transient.RefreshDueAt.After(now) { + t.Fatalf("transient state = %#v", transient) + } + + service.clearRefreshState(credential.ID) + adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Message: "Refresh token has expired", Response: `{"error":"invalid_grant","error_description":"Refresh token has expired"}`, Permanent: true} + if _, err := service.EnsureCredential(ctx, transient, true); err == nil { + t.Fatal("permanent refresh unexpectedly succeeded") + } + permanent, err := service.accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if permanent.AuthStatus != accountdomain.AuthStatusActive || permanent.RefreshFailureCount != 2 || permanent.LastRefreshErrorStatus != 400 || permanent.LastRefreshErrorCode != "invalid_grant" || permanent.LastRefreshErrorMessage != "Refresh token has expired" || permanent.LastRefreshErrorResponse == "" || !permanent.RefreshPermanent || permanent.RefreshDueAt == nil || !permanent.RefreshDueAt.Equal(permanent.ExpiresAt) { + t.Fatalf("permanent with valid token should stay active: %#v", permanent) + } + dueIDs, err := service.accounts.ListDueCredentialRefreshIDs(ctx, now, credentialRefreshBatchSize) + if err != nil { + t.Fatal(err) + } + if len(dueIDs) != 0 { + t.Fatalf("permanent refresh failure remained immediately due: %#v", dueIDs) + } + dueAtExpiry, err := service.accounts.ListDueCredentialRefreshIDs(ctx, permanent.ExpiresAt, credentialRefreshBatchSize) + if err != nil { + t.Fatal(err) + } + if len(dueAtExpiry) != 1 || dueAtExpiry[0] != credential.ID { + t.Fatalf("permanent refresh failure was not scheduled at expiry: %#v", dueAtExpiry) + } + refreshCount := adapter.refreshCount.Load() + service.now = func() time.Time { return permanent.ExpiresAt.Add(-time.Minute) } + usable, err := service.EnsureCredential(ctx, permanent, false) + if err != nil { + t.Fatalf("valid access token was rejected after permanent refresh failure: %v", err) + } + if usable.EncryptedAccessToken != permanent.EncryptedAccessToken || adapter.refreshCount.Load() != refreshCount { + t.Fatalf("usable token = %#v, refresh count = %d", usable, adapter.refreshCount.Load()) + } + service.now = func() time.Time { return now } + if _, err := service.EnsureCredential(ctx, permanent, true); err == nil { + t.Fatal("forced retry after permanent failure unexpectedly succeeded") + } + permanent, err = service.accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if !permanent.RefreshPermanent || permanent.RefreshDueAt == nil || !permanent.RefreshDueAt.Equal(permanent.ExpiresAt) || adapter.refreshCount.Load() != refreshCount { + t.Fatalf("permanent refresh state retried or changed: %#v, refresh count = %d", permanent, adapter.refreshCount.Load()) + } + + service.clearRefreshState(credential.ID) + expiredCredential := permanent + expiredCredential.ExpiresAt = now.Add(-time.Minute) + if _, err := service.accounts.UpdateTokens(ctx, permanent.ID, permanent.EncryptedAccessToken, permanent.EncryptedRefreshToken, expiredCredential.ExpiresAt); err != nil { + t.Fatal(err) + } + adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Permanent: true} + expiredState, _ := service.accounts.Get(ctx, credential.ID) + if expiredState.RefreshPermanent { + t.Fatalf("token update did not clear permanent refresh failure: %#v", expiredState) + } + if _, err := service.EnsureCredential(ctx, expiredState, true); err == nil { + t.Fatal("permanent refresh with expired token unexpectedly succeeded") + } + finalState, err := service.accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if finalState.AuthStatus != accountdomain.AuthStatusReauthRequired { + t.Fatalf("permanent with expired token should be reauthRequired: %#v", finalState) + } + manualCount := adapter.refreshCount.Load() + manualOptions := ensureCredentialOptions{force: true, bypassCooldown: true, retryPermanentOnce: true} + if _, err := service.ensureCredential(ctx, finalState, manualOptions); err == nil { + t.Fatal("manual retry should surface the repeated invalid_grant") + } + if adapter.refreshCount.Load() != manualCount+1 { + t.Fatalf("manual retry did not issue exactly one oauth request: before=%d after=%d", manualCount, adapter.refreshCount.Load()) + } + adapter.refreshErr = nil + latest, err := service.accounts.Get(ctx, finalState.ID) + if err != nil { + t.Fatal(err) + } + recovered, err := service.ensureCredential(ctx, latest, manualOptions) + if err != nil { + t.Fatalf("manual retry did not recover credential: %v", err) + } + if recovered.AuthStatus != accountdomain.AuthStatusActive || recovered.RefreshPermanent || recovered.LastRefreshErrorCode != "" { + t.Fatalf("manual recovery state = %#v", recovered) + } +} + +func TestCredentialDecryptFailedAllowsRetryAfterKeyRecovery(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + + // 旧行为会把 decrypt_failed 标 permanent;模拟已落库的 permanent 状态。 + if err := service.accounts.UpdateCredentialRefreshFailure(ctx, credential.ID, repository.CredentialRefreshFailure{Count: 1, RetryAt: now.Add(time.Hour), Code: "credential_decrypt_failed", Message: "Stored credential could not be decrypted", Permanent: true}); err != nil { + t.Fatal(err) + } + stuck, err := service.accounts.Get(ctx, credential.ID) + if err != nil || !stuck.RefreshPermanent || stuck.LastRefreshErrorCode != "credential_decrypt_failed" { + t.Fatalf("setup stuck state = %#v err=%v", stuck, err) + } + + // 密钥恢复后:手动 force 必须能再次发起刷新。 + adapter.refreshErr = nil + service.clearRefreshState(credential.ID) + recovered, err := service.EnsureCredential(ctx, stuck, true) + if err != nil { + t.Fatalf("force refresh after decrypt_failed should retry: %v", err) + } + if recovered.RefreshPermanent || recovered.LastRefreshErrorStatus != 0 || recovered.LastRefreshErrorCode != "" || recovered.LastRefreshErrorMessage != "" || recovered.LastRefreshErrorResponse != "" || adapter.refreshCount.Load() < 1 { + t.Fatalf("decrypt_failed was not cleared after successful refresh: %#v count=%d", recovered, adapter.refreshCount.Load()) + } + + // invalid_grant 仍须保持永久阻断。 + service.clearRefreshState(credential.ID) + adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Permanent: true} + if _, err := service.EnsureCredential(ctx, recovered, true); err == nil { + t.Fatal("invalid_grant should fail") + } + blocked, err := service.accounts.Get(ctx, credential.ID) + if err != nil || !blocked.RefreshPermanent || blocked.LastRefreshErrorCode != "invalid_grant" { + t.Fatalf("invalid_grant permanent state = %#v err=%v", blocked, err) + } + // force 也不得再打 OAuth(真正永久) + count := adapter.refreshCount.Load() + if _, err := service.EnsureCredential(ctx, blocked, true); err == nil { + t.Fatal("invalid_grant force should still be blocked") + } + if adapter.refreshCount.Load() != count { + t.Fatalf("invalid_grant forced another oauth call: before=%d after=%d", count, adapter.refreshCount.Load()) + } +} + +func TestRefreshAllTokensSkipsUnrefreshableAccounts(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + service, _, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + for _, value := range []accountdomain.Credential{ + {Provider: accountdomain.ProviderBuild, Name: "refreshable-2", SourceKey: "refreshable-2", EncryptedAccessToken: "access-2", EncryptedRefreshToken: "refresh-2", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive}, + {Provider: accountdomain.ProviderBuild, Name: "not-refreshable", SourceKey: "not-refreshable", EncryptedAccessToken: "access-3", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive}, + } { + if _, _, err := service.accounts.UpsertByIdentity(ctx, value); err != nil { + t.Fatal(err) + } + } + invalid, _, err := service.accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "invalid-refreshable", SourceKey: "invalid-refreshable", + EncryptedAccessToken: "access-invalid", EncryptedRefreshToken: "refresh-invalid", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + invalid.AuthStatus = accountdomain.AuthStatusReauthRequired + if _, err := service.accounts.Update(ctx, invalid); err != nil { + t.Fatal(err) + } + + progress := make([][2]int, 0, 4) + succeeded, failed, skipped, err := service.RefreshAllTokensWithProgress(ctx, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if succeeded != 3 || failed != 0 || skipped != 1 || adapter.refreshCount.Load() != 3 { + t.Fatalf("result = %d/%d/%d, refresh count = %d", succeeded, failed, skipped, adapter.refreshCount.Load()) + } + if len(progress) != 4 || progress[0] != [2]int{0, 3} || progress[1] != [2]int{1, 3} || progress[2] != [2]int{2, 3} || progress[3] != [2]int{3, 3} { + t.Fatalf("progress = %#v", progress) + } + recovered, err := service.accounts.Get(ctx, invalid.ID) + if err != nil || recovered.AuthStatus != accountdomain.AuthStatusActive { + t.Fatalf("invalid account was not recovered: %#v err=%v", recovered, err) + } +} + +func TestBatchRefreshTokensRefreshesOnlySelectedEligibleAccounts(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC) + service, refreshable, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + selected := []uint64{refreshable.ID} + var invalidID uint64 + for _, value := range []accountdomain.Credential{ + {Provider: accountdomain.ProviderBuild, Name: "missing-refresh", SourceKey: "missing-refresh", EncryptedAccessToken: "access", Enabled: true, AuthStatus: accountdomain.AuthStatusActive}, + {Provider: accountdomain.ProviderBuild, Name: "disabled", SourceKey: "disabled", EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", Enabled: true, AuthStatus: accountdomain.AuthStatusActive}, + {Provider: accountdomain.ProviderBuild, Name: "invalid", SourceKey: "invalid", EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", Enabled: true, AuthStatus: accountdomain.AuthStatusActive}, + } { + created, _, err := service.accounts.UpsertByIdentity(ctx, value) + if err != nil { + t.Fatal(err) + } + needsUpdate := false + if value.Name == "disabled" { + created.Enabled = false + needsUpdate = true + } + if value.Name == "invalid" { + created.AuthStatus = accountdomain.AuthStatusReauthRequired + invalidID = created.ID + needsUpdate = true + } + if needsUpdate { + created, err = service.accounts.Update(ctx, created) + if err != nil { + t.Fatal(err) + } + } + selected = append(selected, created.ID) + } + + succeeded, failed, skipped, err := service.BatchRefreshTokens(ctx, append(selected, refreshable.ID)) + if err != nil { + t.Fatal(err) + } + if succeeded != 2 || failed != 0 || skipped != 2 || adapter.refreshCount.Load() != 2 { + t.Fatalf("result = %d/%d/%d, refresh count = %d", succeeded, failed, skipped, adapter.refreshCount.Load()) + } + updated, err := service.accounts.Get(ctx, refreshable.ID) + if err != nil { + t.Fatal(err) + } + if updated.EncryptedAccessToken == "" || updated.EncryptedAccessToken == "access-0" { + t.Fatalf("refreshed access token = %q", updated.EncryptedAccessToken) + } + recovered, err := service.accounts.Get(ctx, invalidID) + if err != nil || recovered.AuthStatus != accountdomain.AuthStatusActive { + t.Fatalf("selected invalid account was not recovered: %#v err=%v", recovered, err) + } +} + +func TestRefreshBillingCollapsesConcurrentRequests(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + service, credential, adapter := newCredentialRefreshTestService(t, now) + adapter.billingDelay = 30 * time.Millisecond + const callers = 20 + start := make(chan struct{}) + errors := make(chan error, callers) + var workers sync.WaitGroup + workers.Add(callers) + for range callers { + go func() { + defer workers.Done() + <-start + _, err := service.RefreshBilling(ctx, credential.ID) + errors <- err + }() + } + close(start) + workers.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + if adapter.billingCount.Load() != 1 { + t.Fatalf("billing count = %d", adapter.billingCount.Load()) + } +} + +func newCredentialRefreshTestService(t *testing.T, now time.Time) (*Service, accountdomain.Credential, *credentialRefreshAdapter) { + t.Helper() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "credential-refresh.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + credential, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, + Name: "refresh-test", + SourceKey: "refresh-test", + EncryptedAccessToken: "access-0", + EncryptedRefreshToken: "refresh-0", + ExpiresAt: now.Add(time.Hour), + Enabled: true, + AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := &credentialRefreshAdapter{} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + return service, credential, adapter +} + +type credentialRefreshAdapter struct { + refreshCount atomic.Int64 + billingCount atomic.Int64 + delay time.Duration + billingDelay time.Duration + billing accountdomain.Billing + billingErr error + refreshErr error +} + +func (a *credentialRefreshAdapter) Provider() accountdomain.Provider { + return accountdomain.ProviderBuild +} + +func (a *credentialRefreshAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: accountdomain.ProviderBuild, Quota: provider.QuotaBilling, + Credential: provider.CredentialSurface{ + Refresh: true, + }, + } +} + +func (a *credentialRefreshAdapter) RefreshCredential(ctx context.Context, _ accountdomain.Credential) (provider.RefreshedCredential, error) { + if a.delay > 0 { + timer := time.NewTimer(a.delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return provider.RefreshedCredential{}, ctx.Err() + case <-timer.C: + } + } + count := a.refreshCount.Add(1) + if a.refreshErr != nil { + return provider.RefreshedCredential{}, a.refreshErr + } + return provider.RefreshedCredential{EncryptedAccessToken: fmt.Sprintf("access-%d", count), EncryptedRefreshToken: fmt.Sprintf("refresh-%d", count), ExpiresAt: time.Now().UTC().Add(time.Hour)}, nil +} + +func (a *credentialRefreshAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return nil, nil +} + +func (a *credentialRefreshAdapter) ListModels(context.Context, accountdomain.Credential) ([]string, error) { + return nil, nil +} + +func (a *credentialRefreshAdapter) GetBilling(context.Context, accountdomain.Credential) (accountdomain.Billing, error) { + if a.billingDelay > 0 { + time.Sleep(a.billingDelay) + } + a.billingCount.Add(1) + return a.billing, a.billingErr +} + +func (a *credentialRefreshAdapter) StartDeviceAuthorization(context.Context) (provider.DeviceAuthorization, error) { + return provider.DeviceAuthorization{}, nil +} + +func (a *credentialRefreshAdapter) PollDeviceAuthorization(context.Context, string) (provider.CredentialSeed, error) { + return provider.CredentialSeed{}, nil +} + +func (a *credentialRefreshAdapter) ParseImportedCredentials([]byte) ([]provider.CredentialSeed, error) { + return nil, nil +} +func (a *credentialRefreshAdapter) MarshalCredentials([]provider.CredentialSeed) ([]byte, error) { + return nil, nil +} diff --git a/backend/internal/application/account/credential_scheduler.go b/backend/internal/application/account/credential_scheduler.go new file mode 100644 index 0000000000000000000000000000000000000000..ea43179a1192149d47059e02eead0e59484f7b72 --- /dev/null +++ b/backend/internal/application/account/credential_scheduler.go @@ -0,0 +1,183 @@ +package account + +import ( + "context" + "errors" + "fmt" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +// CredentialStartupReport 汇总启动阶段的凭据调度与恢复结果。 +type CredentialStartupReport struct { + SchedulesBackfilled int + CriticalFound int + Refreshed int + Failed int +} + +// ReconcileCredentialSchedules 为升级前账号补齐持久化调度,不解密凭据,也不访问上游。 +func (s *Service) ReconcileCredentialSchedules(ctx context.Context) (int, error) { + total := 0 + for { + count, err := s.accounts.BackfillCredentialRefreshSchedules(ctx, s.now(), credentialRefreshBatchSize) + total += count + if err != nil || count < credentialRefreshBatchSize { + return total, err + } + } +} + +// RecoverCriticalCredentials 在启动预算内仅恢复缺失、已过期、两分钟内到期或失败重试到期的凭据。 +func (s *Service) RecoverCriticalCredentials(ctx context.Context, expiresWithin time.Duration, limit int) (CredentialStartupReport, error) { + report := CredentialStartupReport{} + backfilled, err := s.ReconcileCredentialSchedules(ctx) + report.SchedulesBackfilled = backfilled + if err != nil { + return report, err + } + if limit < 1 || limit > credentialRefreshBatchSize { + limit = credentialRefreshBatchSize + } + now := s.now() + ids, err := s.accounts.ListCriticalCredentialRefreshIDs(ctx, now, now.Add(expiresWithin), limit) + if err != nil { + return report, err + } + report.CriticalFound = len(ids) + if len(ids) == 0 { + return report, nil + } + report.Refreshed, report.Failed, err = s.runAccountBatch(ctx, "credential_startup_recovery", ids, s.refreshPool, nil, func(workCtx context.Context, id uint64) error { + taskCtx, cancel := context.WithTimeout(workCtx, credentialRefreshTimeout) + defer cancel() + credential, getErr := s.accounts.Get(taskCtx, id) + if getErr != nil { + return getErr + } + if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) { + if !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(s.now()) { + return nil + } + return s.MarkReauthRequired(taskCtx, id, permanentRefreshExpiredReason) + } + // 临界凭据不受进程内强制刷新节流影响;分布式账号锁和旋转 Token 比对仍避免重复 OAuth。 + _, refreshErr := s.ensureCredential(taskCtx, credential, ensureCredentialOptions{force: true, bypassCooldown: true}) + return refreshErr + }) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + return report, err + } + return report, err +} + +// WakeCredentialRefresh 合并调度唤醒;导入、手动刷新和失败退避更新不会阻塞调用方。 +func (s *Service) WakeCredentialRefresh() { + select { + case s.credentialRefreshWake <- struct{}{}: + default: + } +} + +// RunCredentialRefresh 使用单个 Timer 和数据库到期索引驱动刷新,内存占用与账号总数无关。 +func (s *Service) RunCredentialRefresh(ctx context.Context) { + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-s.credentialRefreshWake: + case <-timer.C: + } + runFailed := false + if err := s.refreshDueCredentials(ctx); err != nil && ctx.Err() == nil { + s.logger.Warn("credential_refresh_scheduler_failed", "error", err) + runFailed = true + } + delay, err := s.nextCredentialRefreshDelay(ctx) + if err != nil && ctx.Err() == nil { + s.logger.Warn("credential_refresh_schedule_read_failed", "error", err) + delay = credentialRefreshSafetyPoll + } + if runFailed && delay < 30*time.Second { + delay = 30 * time.Second + } + resetCredentialRefreshTimer(timer, delay) + } +} + +func (s *Service) refreshDueCredentials(ctx context.Context) error { + if _, err := s.ReconcileCredentialSchedules(ctx); err != nil { + return err + } + for { + ids, err := s.accounts.ListDueCredentialRefreshIDs(ctx, s.now(), credentialRefreshBatchSize) + if err != nil { + return err + } + if len(ids) == 0 { + return nil + } + _, failed, batchErr := s.runAccountBatch(ctx, "credential_auto_refresh", ids, s.refreshPool, nil, func(workCtx context.Context, id uint64) error { + taskCtx, cancel := context.WithTimeout(workCtx, credentialRefreshTimeout) + defer cancel() + credential, err := s.accounts.Get(taskCtx, id) + if err != nil { + return err + } + if !credential.Enabled || credential.AuthStatus != accountdomain.AuthStatusActive || s.providers == nil || !s.providers.SupportsCredentialRefresh(credential.Provider) || credential.EncryptedRefreshToken == "" { + return nil + } + if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) { + if !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(s.now()) { + return nil + } + return s.MarkReauthRequired(taskCtx, id, permanentRefreshExpiredReason) + } + if credential.RefreshDueAt != nil && credential.RefreshDueAt.After(s.now()) { + return nil + } + _, err = s.ensureCredential(taskCtx, credential, ensureCredentialOptions{force: true, respectSchedule: true}) + return err + }) + if batchErr != nil { + return fmt.Errorf("自动刷新批次执行失败: %w", batchErr) + } + if failed > 0 { + return fmt.Errorf("自动刷新批次失败 %d/%d", failed, len(ids)) + } + if len(ids) < credentialRefreshBatchSize { + return nil + } + } +} + +func (s *Service) nextCredentialRefreshDelay(ctx context.Context) (time.Duration, error) { + next, err := s.accounts.NextCredentialRefreshDueAt(ctx) + if err != nil { + return 0, err + } + delay := credentialRefreshSafetyPoll + if next != nil { + until := next.Sub(s.now()) + if until < delay { + delay = until + } + } + if delay < 100*time.Millisecond { + delay = 100 * time.Millisecond + } + return delay, nil +} + +func resetCredentialRefreshTimer(timer *time.Timer, delay time.Duration) { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(delay) +} diff --git a/backend/internal/application/account/export_test.go b/backend/internal/application/account/export_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e082b8a0caa82311b1cdc6eb7ef6ff8d1a9d1ef0 --- /dev/null +++ b/backend/internal/application/account/export_test.go @@ -0,0 +1,288 @@ +package account + +import ( + "context" + "encoding/base64" + "path/filepath" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + cliprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/cli" + consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console" + webprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/web" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestExportCredentialsRoundTripsImportFormat(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "export.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + accessToken, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + refreshToken, err := cipher.Encrypt("refresh-token") + if err != nil { + t.Fatal(err) + } + expiresAt := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + repository := relational.NewAccountRepository(database) + created, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "primary", Email: "user@example.com", UserID: "user-1", + SourceKey: "export-test", OIDCClientID: "client-1", EncryptedAccessToken: accessToken, + EncryptedRefreshToken: refreshToken, ExpiresAt: expiresAt, Enabled: false, + AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + adapter := cliprovider.NewAdapter(cliprovider.Config{}, cipher) + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, nil) + + result, err := service.ExportCredentials(ctx) + if err != nil { + t.Fatal(err) + } + selectedResult, err := service.ExportProviderCredentialsByIDs(ctx, accountdomain.ProviderBuild, []uint64{created.ID}) + if err != nil || selectedResult.Count != 1 { + t.Fatalf("selected export result = %#v, error = %v", selectedResult, err) + } + if _, err := service.ExportProviderCredentialsByIDs(ctx, accountdomain.ProviderWeb, []uint64{created.ID}); err == nil { + t.Fatal("expected cross-provider selected export to fail") + } + if _, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, 0, 0, maxCredentialExportAccounts+1); err == nil { + t.Fatal("expected oversized export page to fail") + } + values, err := adapter.ParseImportedCredentials(result.Data) + if err != nil { + t.Fatal(err) + } + if result.Count != 1 || len(values) != 1 { + t.Fatalf("export count = %d, imported values = %d", result.Count, len(values)) + } + value := values[0] + if value.Name != "primary" || value.Email != "user@example.com" || value.UserID != "user-1" || value.OIDCClientID != "client-1" || value.AccessToken != "access-token" || value.RefreshToken != "refresh-token" || !value.ExpiresAt.Equal(expiresAt) { + t.Fatalf("round-trip credential = %#v", value) + } + progress := make([][2]int, 0, 2) + if _, err := service.ImportCredentialsWithProgress(ctx, result.Data, nil, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }); err != nil { + t.Fatal(err) + } + if len(progress) != 2 || progress[0] != [2]int{0, 1} || progress[1] != [2]int{1, 1} { + t.Fatalf("import progress = %#v", progress) + } + + multiProgress := make([][2]int, 0, 3) + multiResult, err := service.ImportCredentialDocumentsWithProgress(ctx, [][]byte{ + result.Data, + result.Data, + []byte(`{"provider":"grok_build","name":"secondary","access_token":"second-access","refresh_token":"second-refresh","user_id":"user-2"}`), + }, nil, func(completed, total int) error { + multiProgress = append(multiProgress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if multiResult.Created != 1 || multiResult.Updated != 1 { + t.Fatalf("multi-file import result = %#v", multiResult) + } + if len(multiProgress) != 3 || multiProgress[0] != [2]int{0, 2} || multiProgress[2] != [2]int{2, 2} { + t.Fatalf("multi-file import progress = %#v", multiProgress) + } +} + +func TestExportProviderCredentialsCursorKeepsStableSnapshot(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "cursor-export.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + accessToken, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + createAccount := func(name string) accountdomain.Credential { + t.Helper() + value, _, createErr := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: name, SourceKey: "cursor-" + name, + UserID: name, EncryptedAccessToken: accessToken, Enabled: true, + AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8, + }) + if createErr != nil { + t.Fatal(createErr) + } + return value + } + first := createAccount("first") + second := createAccount("second") + third := createAccount("third") + adapter := cliprovider.NewAdapter(cliprovider.Config{}, cipher) + service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, nil) + + pageOne, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, 0, 0, 2) + if err != nil { + t.Fatal(err) + } + if pageOne.Count != 2 || !pageOne.HasMore || pageOne.NextID != second.ID || pageOne.SnapshotMaxID != third.ID { + t.Fatalf("first cursor page = %#v", pageOne) + } + createAccount("new-after-snapshot") + pageTwo, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, pageOne.NextID, pageOne.SnapshotMaxID, 2) + if err != nil { + t.Fatal(err) + } + values, err := adapter.ParseImportedCredentials(pageTwo.Data) + if err != nil { + t.Fatal(err) + } + if pageTwo.Count != 1 || pageTwo.HasMore || pageTwo.NextID != third.ID || len(values) != 1 || values[0].Name != "third" { + t.Fatalf("second cursor page = %#v, values = %#v", pageTwo, values) + } + if first.ID >= second.ID || second.ID >= third.ID { + t.Fatalf("test account IDs are not monotonic: %d, %d, %d", first.ID, second.ID, third.ID) + } + if _, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, second.ID, 0, 2); err == nil { + t.Fatal("expected continuation without snapshot boundary to fail") + } +} + +func TestValidateCredentialExportCountRejectsPartialReads(t *testing.T) { + for _, test := range []struct { + name string + expected int + total int64 + actual int + wantErr bool + }{ + {name: "exact", expected: 2, total: 2, actual: 2}, + {name: "missing at count", expected: 2, total: 1, actual: 1, wantErr: true}, + {name: "deleted after count", expected: 2, total: 2, actual: 1, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + err := validateCredentialExportCount(test.expected, test.total, test.actual) + if (err != nil) != test.wantErr { + t.Fatalf("validateCredentialExportCount() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} + +func TestExportProviderCredentialsRoundTripsSSOProviders(t *testing.T) { + for _, test := range []struct { + name string + providerValue accountdomain.Provider + adapter provider.Adapter + webTier accountdomain.WebTier + }{ + {name: "web", providerValue: accountdomain.ProviderWeb, adapter: webprovider.NewAdapter(webprovider.Config{}, nil, nil, nil, nil), webTier: accountdomain.WebTierSuper}, + {name: "console", providerValue: accountdomain.ProviderConsole, adapter: consoleprovider.NewAdapter(consoleprovider.Config{}, nil, nil)}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + nsfwAt := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC) + tosAt := nsfwAt.Add(-time.Hour) + birthDateAt := tosAt.Add(-time.Hour) + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "export-sso.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + token, err := cipher.Encrypt("sso-token") + if err != nil { + t.Fatal(err) + } + cookies, err := cipher.Encrypt("cf_clearance=clearance-token") + if err != nil { + t.Fatal(err) + } + repository := relational.NewAccountRepository(database) + created, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: test.providerValue, AuthType: accountdomain.AuthTypeSSO, WebTier: test.webTier, + Name: test.name + "-account", SourceKey: test.name + "-export-test", + EncryptedAccessToken: token, EncryptedCloudflareCookie: cookies, + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + WebNSFWEnabledAt: &nsfwAt, WebTermsAcceptedAt: &tosAt, + WebTermsAcceptedVersion: accountdomain.CurrentWebTermsVersion, WebBirthDateSetAt: &birthDateAt, + }) + if err != nil { + t.Fatal(err) + } + // 模拟旧账号先按 SSO 来源创建,后续身份同步再补齐邮箱与 user_id; + // 回导必须命中原账号,不能因新身份字段生成重复记录。 + created.Email = test.name + "@example.com" + created.UserID = test.name + "-user-id" + if _, err := repository.Update(ctx, created); err != nil { + t.Fatal(err) + } + service := NewService(repository, nil, nil, nil, provider.NewRegistry(test.adapter), cipher, nil) + + result, err := service.ExportProviderCredentials(ctx, test.providerValue) + if err != nil { + t.Fatal(err) + } + codec, ok := test.adapter.(provider.CredentialCodecAdapter) + if !ok { + t.Fatal("adapter does not implement credential codec") + } + values, err := codec.ParseImportedCredentials(result.Data) + if err != nil { + t.Fatal(err) + } + if result.Count != 1 || len(values) != 1 || values[0].Provider != test.providerValue || values[0].AccessToken != "sso-token" || values[0].CloudflareCookies != "cf_clearance=clearance-token" || values[0].Email != test.name+"@example.com" || values[0].UserID != test.name+"-user-id" { + t.Fatalf("round-trip result = %#v, values = %#v", result, values) + } + if test.providerValue == accountdomain.ProviderWeb && (values[0].WebTier != accountdomain.WebTierSuper || values[0].WebNSFWEnabledAt == nil || !values[0].WebNSFWEnabledAt.Equal(nsfwAt) || values[0].WebTermsAcceptedAt == nil || !values[0].WebTermsAcceptedAt.Equal(tosAt) || values[0].WebTermsAcceptedVersion != accountdomain.CurrentWebTermsVersion || values[0].WebBirthDateSetAt == nil || !values[0].WebBirthDateSetAt.Equal(birthDateAt)) { + t.Fatalf("web metadata = %#v", values[0]) + } + var imported ImportResult + if test.providerValue == accountdomain.ProviderWeb { + imported, err = service.ImportWebCredentialsWithProgress(ctx, result.Data, nil, nil) + } else { + imported, err = service.ImportConsoleCredentialsWithProgress(ctx, result.Data, nil, nil) + } + if err != nil || len(imported.AccountIDs) != 1 { + t.Fatalf("reimport result = %#v, error = %v", imported, err) + } + stored, err := repository.Get(ctx, imported.AccountIDs[0]) + if err != nil || stored.Email != test.name+"@example.com" || stored.UserID != test.name+"-user-id" { + t.Fatalf("reimported account = %#v, error = %v", stored, err) + } + if test.providerValue == accountdomain.ProviderWeb && (stored.WebNSFWEnabledAt == nil || !stored.WebNSFWEnabledAt.Equal(nsfwAt) || stored.WebTermsAcceptedAt == nil || !stored.WebTermsAcceptedAt.Equal(tosAt) || stored.WebTermsAcceptedVersion != accountdomain.CurrentWebTermsVersion || stored.WebBirthDateSetAt == nil || !stored.WebBirthDateSetAt.Equal(birthDateAt)) { + t.Fatalf("reimported web metadata = %#v", stored) + } + }) + } +} diff --git a/backend/internal/application/account/linked_delete_test.go b/backend/internal/application/account/linked_delete_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2f42c5eae43b379e4c9e195cb68c19ad78915098 --- /dev/null +++ b/backend/internal/application/account/linked_delete_test.go @@ -0,0 +1,264 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestBatchDeleteWithLinkedRemovesPeersAndKeepsUntargetedWeb(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-linked-delete.db") + web, build, console := seedLinkedTrio(t, repo, strings.Repeat("1", 64), "u1") + + result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderBuild, []uint64{build.ID}, []accountdomain.Provider{accountdomain.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if result.Deleted != 2 || result.LinkedDeleted != 1 { + t.Fatalf("result = %#v", result) + } + assertAccountMissing(t, repo, build.ID) + assertAccountMissing(t, repo, console.ID) + assertAccountPresent(t, repo, web.ID) + + web2 := mustUpsertLinked(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web2", SourceKey: "sso:" + strings.Repeat("2", 64), + }) + deleted, err := service.BatchDelete(ctx, []uint64{web2.ID}) + if err != nil || deleted != 1 { + t.Fatalf("legacy batch delete deleted=%d err=%v", deleted, err) + } +} + +func TestBatchDeleteWithLinkedWebDeletesBuildAndConsole(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-linked-delete-web.db") + web, build, console := seedLinkedTrio(t, repo, strings.Repeat("a", 64), "u-web") + + result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if result.Deleted != 3 || result.RootsDeleted != 1 || result.LinkedDeleted != 2 { + t.Fatalf("result = %#v", result) + } + assertAccountMissing(t, repo, web.ID) + assertAccountMissing(t, repo, build.ID) + assertAccountMissing(t, repo, console.ID) +} + +func TestBatchDeleteWithLinkedMixedBatchOnlyExpandsLinkedRoots(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-linked-delete-mix.db") + webLinked, _, console := seedLinkedTrio(t, repo, strings.Repeat("b", 64), "u-mix") + webOnly := mustUpsertLinked(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web-only", SourceKey: "sso:" + strings.Repeat("c", 64), + }) + + result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{webLinked.ID, webOnly.ID}, []accountdomain.Provider{accountdomain.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if result.Deleted != 3 { + t.Fatalf("result = %#v", result) + } + assertAccountMissing(t, repo, webLinked.ID) + assertAccountMissing(t, repo, webOnly.ID) + assertAccountMissing(t, repo, console.ID) +} + +func TestPreviewLinkedDeleteCountsWithoutDeleting(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-linked-preview.db") + web, build, console := seedLinkedTrio(t, repo, strings.Repeat("d", 64), "u-prev") + + res, err := service.PreviewLinkedDelete(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if len(res.RootIDs) != 1 || res.LinkedByProvider[accountdomain.ProviderBuild] != 1 || res.LinkedByProvider[accountdomain.ProviderConsole] != 1 || len(res.FinalIDs) != 3 { + t.Fatalf("preview = %#v", res) + } + assertAccountPresent(t, repo, web.ID) + assertAccountPresent(t, repo, build.ID) + assertAccountPresent(t, repo, console.ID) +} + +func TestBatchDeleteWithLinkedEmptyTargetsDeletesRootOnly(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-linked-empty-targets.db") + web, build, console := seedLinkedTrio(t, repo, strings.Repeat("e", 64), "u-empty") + + result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, nil) + if err != nil { + t.Fatal(err) + } + if result.Deleted != 1 || result.LinkedDeleted != 0 { + t.Fatalf("result = %#v", result) + } + assertAccountMissing(t, repo, web.ID) + assertAccountPresent(t, repo, build.ID) + assertAccountPresent(t, repo, console.ID) +} + +func TestBatchDeleteWithLinkedInvalidTarget(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-linked-bad-target.db") + web, _, _ := seedLinkedTrio(t, repo, strings.Repeat("f", 64), "u-bad") + + if _, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.Provider("nope")}); err == nil { + t.Fatal("expected invalid target error") + } + if _, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderWeb}); err == nil { + t.Fatal("expected self-target error") + } + assertAccountPresent(t, repo, web.ID) +} + +func TestDeleteMissingAccountReturnsNotFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + _, service := newLinkedDeleteTestService(t, "svc-delete-missing.db") + + if err := service.Delete(ctx, 9_999_999); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete missing: err=%v", err) + } + if _, err := service.DeleteWithLinked(ctx, accountdomain.ProviderWeb, 9_999_999, []accountdomain.Provider{accountdomain.ProviderBuild}); !errors.Is(err, ErrNotFound) { + t.Fatalf("DeleteWithLinked missing: err=%v", err) + } +} + +func TestDeleteWithLinkedRejectsRootFromAnotherProvider(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, service := newLinkedDeleteTestService(t, "svc-delete-provider-mismatch.db") + web, _, _ := seedLinkedTrio(t, repo, strings.Repeat("9", 64), "u-provider-mismatch") + + _, err := service.DeleteWithLinked(ctx, accountdomain.ProviderBuild, web.ID, []accountdomain.Provider{accountdomain.ProviderConsole}) + if !errors.Is(err, ErrConflict) { + t.Fatalf("provider mismatch error = %v", err) + } + assertAccountPresent(t, repo, web.ID) +} + +func TestFinishLinkedDeleteUsesBatchStickyCleanup(t *testing.T) { + sticky := &stickyBatchStub{} + service := &Service{sticky: sticky} + service.finishLinkedDelete(context.Background(), []uint64{3, 5, 8}) + if sticky.singleCalls != 0 { + t.Fatalf("single-account cleanup calls = %d", sticky.singleCalls) + } + if len(sticky.batchCalls) != 1 || len(sticky.batchCalls[0]) != 3 || sticky.batchCalls[0][0] != 3 || sticky.batchCalls[0][2] != 8 { + t.Fatalf("batch cleanup calls = %#v", sticky.batchCalls) + } +} + +func newLinkedDeleteTestService(t *testing.T, dbName string) (*relational.AccountRepository, *Service) { + t.Helper() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), dbName)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewAccountRepository(database) + return repo, &Service{accounts: repo, sticky: stickyStub{}, logger: nil} +} + +func seedLinkedTrio(t *testing.T, repo *relational.AccountRepository, digest, userID string) (web, build, console accountdomain.Credential) { + t.Helper() + ctx := context.Background() + web = mustUpsertLinked(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + digest, UserID: userID, + }) + build = mustUpsertLinked(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", SourceKey: "build-" + digest[:8], UserID: userID, + }) + console = mustUpsertLinked(t, repo, accountdomain.Credential{ + Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, Name: "console", SourceKey: "console-sso:" + digest, UserID: userID, + }) + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + if err := repo.ReconcileProviderLinks(ctx, web.ID); err != nil { + t.Fatal(err) + } + return web, build, console +} + +func mustUpsertLinked(t *testing.T, repo *relational.AccountRepository, value accountdomain.Credential) accountdomain.Credential { + t.Helper() + value.EncryptedAccessToken = "encrypted" + value.Enabled = true + value.AuthStatus = accountdomain.AuthStatusActive + value.Priority = accountdomain.DefaultPriority + value.MaxConcurrent = accountdomain.DefaultMaxConcurrent + stored, _, err := repo.UpsertByIdentity(context.Background(), value) + if err != nil { + t.Fatal(err) + } + return stored +} + +func assertAccountMissing(t *testing.T, repo *relational.AccountRepository, id uint64) { + t.Helper() + if _, err := repo.Get(context.Background(), id); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("account %d should be missing, err=%v", id, err) + } +} + +func assertAccountPresent(t *testing.T, repo *relational.AccountRepository, id uint64) { + t.Helper() + if _, err := repo.Get(context.Background(), id); err != nil { + t.Fatalf("account %d should remain: %v", id, err) + } +} + +type stickyStub struct{} + +func (stickyStub) Get(context.Context, string, time.Time) (uint64, bool, error) { + return 0, false, nil +} +func (stickyStub) Bind(context.Context, string, uint64, time.Time, time.Time) (uint64, error) { + return 0, nil +} +func (stickyStub) Set(context.Context, string, uint64, time.Time) error { return nil } +func (stickyStub) DeleteByAccount(context.Context, uint64) error { return nil } + +type stickyBatchStub struct { + singleCalls int + batchCalls [][]uint64 +} + +func (*stickyBatchStub) Get(context.Context, string, time.Time) (uint64, bool, error) { + return 0, false, nil +} +func (*stickyBatchStub) Bind(context.Context, string, uint64, time.Time, time.Time) (uint64, error) { + return 0, nil +} +func (*stickyBatchStub) Set(context.Context, string, uint64, time.Time) error { return nil } +func (s *stickyBatchStub) DeleteByAccount(context.Context, uint64) error { + s.singleCalls++ + return nil +} +func (s *stickyBatchStub) DeleteByAccounts(_ context.Context, ids []uint64) error { + s.batchCalls = append(s.batchCalls, append([]uint64(nil), ids...)) + return nil +} diff --git a/backend/internal/application/account/list_filter_test.go b/backend/internal/application/account/list_filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0dc8f4f41d2237f82857c3fb65efb6f94b71f3c2 --- /dev/null +++ b/backend/internal/application/account/list_filter_test.go @@ -0,0 +1,64 @@ +package account + +import ( + "context" + "errors" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestListRejectsInvalidWebFilters(t *testing.T) { + tests := []struct { + name string + filter ListFilter + }{ + {name: "agreement on non-Web provider", filter: ListFilter{Provider: string(accountdomain.ProviderBuild), Agreement: "nsfwEnabled"}}, + {name: "web association value on Build provider", filter: ListFilter{Provider: string(accountdomain.ProviderBuild), Association: "buildLinked"}}, + {name: "web association value on Console provider", filter: ListFilter{Provider: string(accountdomain.ProviderConsole), Association: "allLinked"}}, + {name: "webLinked on Web provider", filter: ListFilter{Provider: string(accountdomain.ProviderWeb), Association: "webLinked"}}, + {name: "association without provider", filter: ListFilter{Association: "webLinked"}}, + {name: "invalid agreement", filter: ListFilter{Provider: string(accountdomain.ProviderWeb), Agreement: "invalid"}}, + {name: "invalid association", filter: ListFilter{Provider: string(accountdomain.ProviderWeb), Association: "invalid"}}, + } + + service := &Service{} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, _, err := service.List(context.Background(), 1, 20, "", test.filter); !errors.Is(err, ErrInvalidFilter) { + t.Fatalf("List() error = %v, want %v", err, ErrInvalidFilter) + } + }) + } +} + +// Validate provider-specific association filters: Web supports six values; Build and Console support Web links only. +func TestValidAssociationFilterPerProvider(t *testing.T) { + web := string(accountdomain.ProviderWeb) + build := string(accountdomain.ProviderBuild) + console := string(accountdomain.ProviderConsole) + tests := []struct { + provider string + association string + want bool + }{ + {web, "", true}, + {build, "", true}, + {"", "", true}, + {web, "buildLinked", true}, + {web, "allUnlinked", true}, + {web, "webLinked", false}, + {build, "webLinked", true}, + {build, "webUnlinked", true}, + {build, "consoleLinked", false}, + {console, "webLinked", true}, + {console, "webUnlinked", true}, + {console, "buildLinked", false}, + {"", "webLinked", false}, + } + for _, test := range tests { + if got := validAssociationFilter(test.provider, test.association); got != test.want { + t.Fatalf("validAssociationFilter(%q, %q) = %v, want %v", test.provider, test.association, got, test.want) + } + } +} diff --git a/backend/internal/application/account/paid_quota_recovery_test.go b/backend/internal/application/account/paid_quota_recovery_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a66622e78ac49d3f3fb41dc47a405f3ed5ba817b --- /dev/null +++ b/backend/internal/application/account/paid_quota_recovery_test.go @@ -0,0 +1,57 @@ +package account + +import ( + "context" + "errors" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestPaidQuotaCreatesPeriodProbeAndClearsAfterRecovery(t *testing.T) { + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + periodEnd := now.Add(time.Hour).Format(time.RFC3339) + adapter.billing = accountdomain.Billing{MonthlyLimit: 100, Used: 100, BillingPeriodEnd: periodEnd} + if _, err := service.RefreshBilling(context.Background(), credential.ID); err != nil { + t.Fatal(err) + } + recovery, err := service.accounts.GetQuotaRecovery(context.Background(), credential.ID) + if err != nil { + t.Fatal(err) + } + if recovery.Kind != accountdomain.QuotaRecoveryKindPaid || recovery.Status != accountdomain.QuotaRecoveryStatusExhausted || recovery.NextProbeAt == nil || recovery.NextProbeAt.Format(time.RFC3339) != periodEnd { + t.Fatalf("recovery = %#v", recovery) + } + + adapter.billing = accountdomain.Billing{MonthlyLimit: 100, Used: 0, BillingPeriodEnd: now.Add(31 * 24 * time.Hour).Format(time.RFC3339)} + recovered, err := service.ProbePaidQuota(context.Background(), credential) + if err != nil || !recovered { + t.Fatalf("recovered = %v, err = %v", recovered, err) + } + if _, err := service.accounts.GetQuotaRecovery(context.Background(), credential.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("paid recovery state should be cleared, err = %v", err) + } + if adapter.billingCount.Load() != 2 { + t.Fatalf("billing probes = %d", adapter.billingCount.Load()) + } +} + +func TestPaidQuotaStillExhaustedBacksOffAfterDueProbe(t *testing.T) { + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + adapter.billing = accountdomain.Billing{MonthlyLimit: 100, Used: 100, BillingPeriodEnd: now.Add(-time.Minute).Format(time.RFC3339)} + recovered, err := service.ProbePaidQuota(context.Background(), credential) + if err != nil || recovered { + t.Fatalf("recovered = %v, err = %v", recovered, err) + } + recovery, err := service.accounts.GetQuotaRecovery(context.Background(), credential.ID) + if err != nil { + t.Fatal(err) + } + if recovery.NextProbeAt == nil || recovery.NextProbeAt.Before(time.Now().UTC().Add(14*time.Minute)) { + t.Fatalf("next probe should be backed off, recovery = %#v", recovery) + } +} diff --git a/backend/internal/application/account/provider_links.go b/backend/internal/application/account/provider_links.go new file mode 100644 index 0000000000000000000000000000000000000000..8ba84c5fef2bc8ab0bcaca7f2cd11ce003a23866 --- /dev/null +++ b/backend/internal/application/account/provider_links.go @@ -0,0 +1,85 @@ +package account + +import ( + "context" + "errors" + "fmt" + "strings" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +type providerLinkRepository interface { + ReconcileProviderLinks(ctx context.Context, accountID uint64) error + UpdateIdentityMetadata(ctx context.Context, accountID uint64, email, userID, teamID string) error +} + +// SyncAccountIdentity best-effort fills stable Web/Console identity metadata and reconciles trusted links. +// Definitive unauthorized signals mark the current Provider account as reauthRequired and remove it from scheduling; +// other synchronization failures do not affect account health. +func (s *Service) SyncAccountIdentity(ctx context.Context, id uint64) error { + _, err, _ := s.identitySyncs.Do(fmt.Sprintf("%d", id), func() (any, error) { + return nil, s.syncAccountIdentity(ctx, id) + }) + return err +} + +func (s *Service) syncAccountIdentity(ctx context.Context, id uint64) error { + links, ok := s.accounts.(providerLinkRepository) + if !ok { + return nil + } + value, err := s.accounts.Get(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + if value.Provider != accountdomain.ProviderWeb && value.Provider != accountdomain.ProviderConsole { + return nil + } + // Session 身份只需成功补充一次;已有 user_id 或 email 时仅做本地关联协调, + // 不再重复访问上游。没有任何身份数据的失败账号会在后续同步时重试。 + if strings.TrimSpace(value.UserID) != "" || strings.TrimSpace(value.Email) != "" { + return mapRepositoryError(links.ReconcileProviderLinks(ctx, id)) + } + if s.providers == nil { + return fmt.Errorf("Provider 注册表未初始化") + } + adapter, ok := s.providers.AccountIdentity(value.Provider) + if !ok { + return nil + } + identity, err := adapter.SyncAccountIdentity(ctx, value) + if err != nil { + if errors.Is(err, provider.ErrUnauthorized) { + markErr := s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider)) + return errors.Join(err, markErr) + } + return err + } + if len(identity.Email) > 255 || len(identity.UserID) > 255 || len(identity.TeamID) > 255 { + return fmt.Errorf("Grok Web Session 身份字段超过安全上限") + } + if err := links.UpdateIdentityMetadata(ctx, id, identity.Email, identity.UserID, identity.TeamID); err != nil { + return mapRepositoryError(err) + } + return mapRepositoryError(links.ReconcileProviderLinks(ctx, id)) +} + +func (s *Service) reconcileProviderLinksBestEffort(ctx context.Context, id uint64) { + links, ok := s.accounts.(providerLinkRepository) + if !ok { + return + } + if err := links.ReconcileProviderLinks(ctx, id); err != nil { + s.logger.Warn("account_provider_link_reconcile_failed", "account_id", id, "error", err) + } +} + +func (s *Service) syncAccountIdentityBestEffort(ctx context.Context, id uint64) error { + if err := s.SyncAccountIdentity(ctx, id); err != nil { + s.logger.Warn("account_identity_sync_failed", "account_id", id, "error", err) + return err + } + return nil +} diff --git a/backend/internal/application/account/provider_links_test.go b/backend/internal/application/account/provider_links_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9f3f71539ee3cd0972fd6cb9004e0c3d36fb628e --- /dev/null +++ b/backend/internal/application/account/provider_links_test.go @@ -0,0 +1,172 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestSyncAccountIdentityLinksUniqueBuildWithoutSharingState(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: 7, MaxConcurrent: 3, + }) + if err != nil { + t.Fatal(err) + } + build, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", SourceKey: "build", UserID: "user-1", + EncryptedAccessToken: "encrypted", Enabled: false, AuthStatus: accountdomain.AuthStatusReauthRequired, Priority: 1, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + build.Enabled = false + build, err = repo.Update(ctx, build) + if err != nil { + t.Fatal(err) + } + adapter.identity = provider.AccountIdentity{UserID: "user-1", Email: "user@example.com"} + if err := service.SyncAccountIdentity(ctx, web.ID); err != nil { + t.Fatal(err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + build, err = repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + if web.UserID != "user-1" || web.Email != "user@example.com" || len(web.LinkedAccounts) != 1 || web.LinkedAccounts[0].ID != build.ID { + t.Fatalf("web = %#v", web) + } + if !web.Enabled || web.AuthStatus != accountdomain.AuthStatusActive || web.Priority != 7 || web.MaxConcurrent != 3 { + t.Fatalf("web operational state changed: %#v", web) + } + if build.Enabled || build.AuthStatus != accountdomain.AuthStatusReauthRequired { + t.Fatalf("build operational state changed: %#v", build) + } + if err := service.SyncAccountIdentity(ctx, web.ID); err != nil { + t.Fatal(err) + } + if adapter.identityCalls != 1 { + t.Fatalf("identity calls = %d", adapter.identityCalls) + } +} + +func TestSyncAccountIdentityUnauthorizedInvalidatesCurrentProviderAccount(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + adapter.identityErr = provider.ErrUnauthorized + if err := service.SyncAccountIdentity(ctx, web.ID); !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + if web.AuthStatus != accountdomain.AuthStatusReauthRequired || !web.Enabled || web.FailureCount != 0 { + t.Fatalf("identity unauthorized state = %#v", web) + } +} + +func TestSyncAccountIdentityDoesNotRepeatWhenEmailIsKnown(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", Email: "known@example.com", + SourceKey: "sso:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter.identity = provider.AccountIdentity{UserID: "stable-user", Email: "known@example.com"} + if err := service.SyncAccountIdentity(ctx, web.ID); err != nil { + t.Fatal(err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + if web.UserID != "" || web.Email != "known@example.com" || adapter.identityCalls != 0 { + t.Fatalf("identity was fetched again: user_id=%q email=%q calls=%d", web.UserID, web.Email, adapter.identityCalls) + } +} + +func TestSyncConsoleAccountIdentityLinksUniqueWebAccountOnce(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "console-identity.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewAccountRepository(database) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", + SourceKey: "sso:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + UserID: "same-user", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + console, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, Name: "console", + SourceKey: "console-sso:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := &consoleIdentityAdapterStub{identity: provider.AccountIdentity{UserID: "same-user", Email: "same@example.com"}} + service := NewService(repo, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + if err := service.SyncAccountIdentity(ctx, console.ID); err != nil { + t.Fatal(err) + } + if err := service.SyncAccountIdentity(ctx, console.ID); err != nil { + t.Fatal(err) + } + console, err = repo.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if adapter.calls != 1 || console.UserID != "same-user" || len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID { + t.Fatalf("calls=%d console=%#v", adapter.calls, console) + } +} + +type consoleIdentityAdapterStub struct { + identity provider.AccountIdentity + calls int +} + +func (*consoleIdentityAdapterStub) Provider() accountdomain.Provider { + return accountdomain.ProviderConsole +} + +func (a *consoleIdentityAdapterStub) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) { + a.calls++ + return a.identity, nil +} diff --git a/backend/internal/application/account/quota_refresh_redis_integration_test.go b/backend/internal/application/account/quota_refresh_redis_integration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..17b4839d9b2bf4a9724eaf4255dcf5f71904fee0 --- /dev/null +++ b/backend/internal/application/account/quota_refresh_redis_integration_test.go @@ -0,0 +1,147 @@ +package account + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + redisruntime "github.com/chenyme/grok2api/backend/internal/infra/runtime/redis" + redisclient "github.com/redis/go-redis/v9" +) + +func TestRedisQuotaRefreshCrossInstanceTrailing(t *testing.T) { + address := os.Getenv("TEST_REDIS_ADDRESS") + if address == "" { + t.Skip("TEST_REDIS_ADDRESS is not configured") + } + databaseNumber, err := redisTestDatabaseNumber() + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + cleanup := redisclient.NewClient(&redisclient.Options{ + Addr: address, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), DB: databaseNumber, + }) + defer cleanup.Close() + if err := cleanup.FlushDB(ctx).Err(); err != nil { + t.Fatal(err) + } + defer func() { + if err := cleanup.FlushDB(ctx).Err(); err != nil { + t.Errorf("flush Redis test database: %v", err) + } + }() + + prefix := "grok2api:quota-refresh-integration:" + time.Now().UTC().Format("20060102150405.000000000") + ":" + config := redisruntime.Config{ + Address: address, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), Database: databaseNumber, + KeyPrefix: prefix, ConcurrencyLease: time.Minute, + } + firstRuntime, err := redisruntime.Open(ctx, config) + if err != nil { + t.Fatal(err) + } + defer firstRuntime.Close() + secondRuntime, err := redisruntime.Open(ctx, config) + if err != nil { + t.Fatal(err) + } + defer secondRuntime.Close() + + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-refresh-redis-integration.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "redis-cross-instance", SourceKey: "redis-cross-instance", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierSuper, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{modeStarted: make(chan struct{}, 4), modeRelease: make(chan struct{}, 4)} + registry := provider.NewRegistry(adapter) + first := NewService(accounts, nil, nil, nil, registry, nil, redisruntime.NewLockStore(firstRuntime)) + second := NewService(accounts, nil, nil, nil, registry, nil, redisruntime.NewLockStore(secondRuntime)) + first.SetQuotaRefreshCoordinator(firstRuntime) + second.SetQuotaRefreshCoordinator(secondRuntime) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}, 2) + go func() { first.RunWebQuotaRefresh(runCtx); done <- struct{}{} }() + go func() { second.RunWebQuotaRefresh(runCtx); done <- struct{}{} }() + t.Cleanup(func() { + for range 4 { + adapter.modeRelease <- struct{}{} + } + cancel() + <-done + <-done + }) + + first.QueueQuotaRefresh(credential.ID, "weekly") + select { + case <-adapter.modeStarted: + case <-time.After(3 * time.Second): + t.Fatal("first Redis-backed refresh did not start") + } + second.QueueQuotaRefresh(credential.ID, "weekly") + deadline := time.Now().Add(3 * time.Second) + for { + generation, dirty, generationErr := secondRuntime.QuotaRefreshGeneration(ctx, credential.ID, "weekly") + if generationErr != nil { + t.Fatal(generationErr) + } + if generation >= 2 && dirty { + break + } + if time.Now().After(deadline) { + t.Fatalf("shared Redis generation = %d, dirty = %v", generation, dirty) + } + time.Sleep(10 * time.Millisecond) + } + adapter.modeRelease <- struct{}{} + select { + case <-adapter.modeStarted: + case <-time.After(3 * time.Second): + t.Fatal("Redis-backed trailing refresh did not start") + } + adapter.modeRelease <- struct{}{} + + deadline = time.Now().Add(3 * time.Second) + for adapter.modeCalls.Load() != 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if calls := adapter.modeCalls.Load(); calls != 2 { + t.Fatalf("Redis-backed refresh calls = %d, want 2", calls) + } + time.Sleep(2 * webQuotaRefreshRetryInterval) + if calls := adapter.modeCalls.Load(); calls != 2 { + t.Fatalf("Redis-backed losing instance performed duplicate refresh: %d", calls) + } +} + +func redisTestDatabaseNumber() (int, error) { + raw := os.Getenv("TEST_REDIS_DATABASE") + if raw == "" { + return 0, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 0 { + return 0, fmt.Errorf("TEST_REDIS_DATABASE = %q", raw) + } + return value, nil +} diff --git a/backend/internal/application/account/quota_refresh_test.go b/backend/internal/application/account/quota_refresh_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2a377e00d567c352610e4ea40f0a482e1f5cbf61 --- /dev/null +++ b/backend/internal/application/account/quota_refresh_test.go @@ -0,0 +1,620 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestWebQuotaRefreshDeduplicatesPerMode(t *testing.T) { + service := NewService(nil, nil, nil, nil, nil, nil, nil) + service.QueueWebQuotaRefresh(42, "fast") + service.QueueWebQuotaRefresh(42, "expert") + service.QueueWebQuotaRefresh(42, "fast") + + service.quotaRefreshMu.Lock() + defer service.quotaRefreshMu.Unlock() + if len(service.quotaRefreshes) != 2 { + t.Fatalf("refresh states = %#v", service.quotaRefreshes) + } + if service.quotaRefreshes["42:fast"].generation != 2 || !service.quotaRefreshes["42:fast"].queued { + t.Fatal("duplicate fast refresh was not coalesced into the queued generation") + } + if service.quotaRefreshes["42:expert"].generation != 1 || !service.quotaRefreshes["42:expert"].queued { + t.Fatal("independent expert refresh state is invalid") + } + if len(service.quotaRefreshQueue) != 2 { + t.Fatalf("queued refreshes = %d", len(service.quotaRefreshQueue)) + } +} + +func TestWeeklyQuotaRefreshPreservesTrailingSnapshot(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "weekly-quota-refresh.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "weekly", SourceKey: "weekly", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierSuper, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{ + modeStarted: make(chan struct{}, 2), + modeRelease: make(chan struct{}, 2), + } + service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + + service.QueueQuotaRefresh(credential.ID, "weekly") + request := <-service.quotaRefreshQueue + done := make(chan struct{}) + go func() { + service.runWebQuotaRefresh(ctx, request) + close(done) + }() + + select { + case <-adapter.modeStarted: + case <-time.After(time.Second): + t.Fatal("first weekly refresh did not start") + } + service.QueueQuotaRefresh(credential.ID, "weekly") + adapter.modeRelease <- struct{}{} + + select { + case <-adapter.modeStarted: + case <-time.After(time.Second): + t.Fatal("trailing weekly refresh did not start") + } + adapter.modeRelease <- struct{}{} + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("weekly refresh did not finish") + } + if adapter.modeCalls.Load() != 2 { + t.Fatalf("weekly refresh calls = %d, want 2", adapter.modeCalls.Load()) + } + service.quotaRefreshMu.Lock() + _, exists := service.quotaRefreshes[request.key] + service.quotaRefreshMu.Unlock() + if exists { + t.Fatal("completed weekly refresh retained queue state") + } +} + +func TestQuotaRefreshQueueOverflowRetainsDirtyState(t *testing.T) { + service := NewService(nil, nil, nil, nil, nil, nil, nil) + service.quotaRefreshQueue = make(chan webQuotaRefreshRequest, 1) + service.QueueQuotaRefresh(1, "fast") + service.QueueQuotaRefresh(2, "fast") + + service.quotaRefreshMu.Lock() + state := service.quotaRefreshes["2:fast"] + if state == nil || state.generation != 1 || state.queued || state.running { + service.quotaRefreshMu.Unlock() + t.Fatalf("overflow state = %#v", state) + } + service.quotaRefreshMu.Unlock() + + first := <-service.quotaRefreshQueue + if first.accountID != 1 { + t.Fatalf("first queued account = %d", first.accountID) + } + service.requeueQuotaRefreshes() + second := <-service.quotaRefreshQueue + if second.accountID != 2 || second.mode != "fast" { + t.Fatalf("recovered request = %#v", second) + } + service.quotaRefreshMu.Lock() + recovered := service.quotaRefreshes["2:fast"] + service.quotaRefreshMu.Unlock() + if recovered == nil || !recovered.queued || recovered.running { + t.Fatalf("recovered state = %#v", recovered) + } +} + +func TestQuotaRefreshCrossInstanceGenerationTriggersSingleTrailingRefresh(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-cross-instance.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "cross-instance", SourceKey: "cross-instance", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierSuper, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{modeStarted: make(chan struct{}, 4), modeRelease: make(chan struct{}, 4)} + registry := provider.NewRegistry(adapter) + coordinator := memory.NewQuotaRefreshCoordinator() + lock := memory.NewLockStore() + first := NewService(accounts, nil, nil, nil, registry, nil, lock) + second := NewService(accounts, nil, nil, nil, registry, nil, lock) + first.SetQuotaRefreshCoordinator(coordinator) + second.SetQuotaRefreshCoordinator(coordinator) + runCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}, 2) + go func() { first.RunWebQuotaRefresh(runCtx); done <- struct{}{} }() + go func() { second.RunWebQuotaRefresh(runCtx); done <- struct{}{} }() + t.Cleanup(func() { + for range 4 { + adapter.modeRelease <- struct{}{} + } + cancel() + <-done + <-done + }) + + first.QueueQuotaRefresh(credential.ID, "weekly") + select { + case <-adapter.modeStarted: + case <-time.After(2 * time.Second): + t.Fatal("first refresh did not start") + } + second.QueueQuotaRefresh(credential.ID, "weekly") + deadline := time.Now().Add(2 * time.Second) + for { + generation, dirty, generationErr := coordinator.QuotaRefreshGeneration(ctx, credential.ID, "weekly") + if generationErr != nil { + t.Fatal(generationErr) + } + if generation >= 2 && dirty { + break + } + if time.Now().After(deadline) { + t.Fatalf("shared generation = %d, dirty = %v", generation, dirty) + } + time.Sleep(10 * time.Millisecond) + } + adapter.modeRelease <- struct{}{} + select { + case <-adapter.modeStarted: + case <-time.After(2 * time.Second): + t.Fatal("trailing refresh did not start") + } + adapter.modeRelease <- struct{}{} + + deadline = time.Now().Add(2 * time.Second) + for adapter.modeCalls.Load() != 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if calls := adapter.modeCalls.Load(); calls != 2 { + t.Fatalf("refresh calls = %d, want 2", calls) + } + time.Sleep(2 * webQuotaRefreshRetryInterval) + if calls := adapter.modeCalls.Load(); calls != 2 { + t.Fatalf("losing instance performed duplicate refresh: %d", calls) + } +} + +func TestRefreshQuotaModeDoesNotTriggerFullProviderSyncForAutoTier(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-mode.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "web-auto", SourceKey: "web-auto", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierAuto, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{} + service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + window, err := service.RefreshQuotaMode(ctx, credential.ID, "fast") + if err != nil { + t.Fatal(err) + } + if window.Mode != "fast" || adapter.modeCalls.Load() != 1 || adapter.fullCalls.Load() != 0 { + t.Fatalf("window = %#v, mode calls = %d, full calls = %d", window, adapter.modeCalls.Load(), adapter.fullCalls.Load()) + } + stored, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.WebTier != accountdomain.WebTierAuto { + t.Fatalf("single-mode sync changed tier to %q", stored.WebTier) + } + + service.QueueQuotaRefresh(credential.ID, "fast") + service.QueueQuotaRefresh(credential.ID, "fast") + request := <-service.quotaRefreshQueue + service.runWebQuotaRefresh(ctx, request) + if adapter.modeCalls.Load() != 2 || adapter.fullCalls.Load() != 0 { + t.Fatalf("coalesced mode calls = %d, full calls = %d", adapter.modeCalls.Load(), adapter.fullCalls.Load()) + } + service.quotaRefreshMu.Lock() + _, queued := service.quotaRefreshes[request.key] + service.quotaRefreshMu.Unlock() + if queued { + t.Fatal("completed coalesced refresh retained queue state") + } + + service.refreshLock = deniedQuotaRefreshLock{} + service.QueueQuotaRefresh(credential.ID, "fast") + request = <-service.quotaRefreshQueue + service.runWebQuotaRefresh(ctx, request) + if adapter.modeCalls.Load() != 2 { + t.Fatalf("worker without distributed lease made %d mode calls", adapter.modeCalls.Load()) + } +} + +func TestObserveResponseModelCoalescesUnchangedValues(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "observed-model.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + base := relational.NewAccountRepository(database) + credential, _, err := base.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "observed", SourceKey: "observed", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + accounts := &observedModelCountingRepository{AccountRepository: base} + service := NewService(accounts, nil, nil, nil, nil, nil, nil) + now := time.Now().UTC() + service.now = func() time.Time { return now } + + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil { + t.Fatal(err) + } + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil { + t.Fatal(err) + } + now = now.Add(observedModelPersistInterval - time.Second) + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil { + t.Fatal(err) + } + if calls := accounts.calls.Load(); calls != 1 { + t.Fatalf("unchanged model writes = %d, want 1", calls) + } + + now = now.Add(2 * time.Second) + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil { + t.Fatal(err) + } + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5-mini"); err != nil { + t.Fatal(err) + } + if calls := accounts.calls.Load(); calls != 3 { + t.Fatalf("interval and model transition writes = %d, want 3", calls) + } +} + +func TestObserveResponseModelRefreshesAfterCrossInstanceStateChange(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "observed-model-shared.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + base := relational.NewAccountRepository(database) + credential, _, err := base.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "observed-shared", SourceKey: "observed-shared", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + accounts := &observedModelCountingRepository{AccountRepository: base} + shared := &observedModelTestStore{values: make(map[uint64]repository.ObservedModelState)} + service := NewService(accounts, nil, nil, nil, nil, nil, nil) + service.SetObservedModelStore(shared) + now := time.Now().UTC() + service.now = func() time.Time { return now } + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil { + t.Fatal(err) + } + shared.mu.Lock() + shared.values[credential.ID] = repository.ObservedModelState{Model: "grok-4.5-build-free", ObservedAt: now} + shared.mu.Unlock() + now = now.Add(observedModelLocalCacheTTL + time.Second) + if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil { + t.Fatal(err) + } + if calls := accounts.calls.Load(); calls != 2 { + t.Fatalf("cross-instance model change was suppressed, writes = %d", calls) + } +} + +func TestObserveResponseModelCoalescesConcurrentFirstWrite(t *testing.T) { + accounts := &observedModelBlockingRepository{ + started: make(chan struct{}), + release: make(chan struct{}), + } + service := NewService(accounts, nil, nil, nil, nil, nil, nil) + const workers = 32 + start := make(chan struct{}) + var launched sync.WaitGroup + launched.Add(workers) + errorsCh := make(chan error, workers) + var wait sync.WaitGroup + wait.Add(workers) + for range workers { + go func() { + defer wait.Done() + launched.Done() + <-start + errorsCh <- service.ObserveResponseModel(context.Background(), 42, "grok-4.5") + }() + } + launched.Wait() + close(start) + <-accounts.started + time.Sleep(25 * time.Millisecond) + close(accounts.release) + wait.Wait() + close(errorsCh) + for err := range errorsCh { + if err != nil { + t.Fatal(err) + } + } + if calls := accounts.calls.Load(); calls != 1 { + t.Fatalf("concurrent unchanged model writes = %d, want 1", calls) + } +} + +func TestObserveResponseModelKeepsNewerLocalStateAfterOutOfOrderCompletion(t *testing.T) { + accounts := &observedModelOrderingRepository{ + olderStarted: make(chan struct{}), + olderRelease: make(chan struct{}), + } + service := NewService(accounts, nil, nil, nil, nil, nil, nil) + current := time.Now().UTC() + var nowMu sync.RWMutex + service.now = func() time.Time { + nowMu.RLock() + defer nowMu.RUnlock() + return current + } + olderDone := make(chan error, 1) + go func() { + olderDone <- service.ObserveResponseModel(context.Background(), 42, "grok-older") + }() + <-accounts.olderStarted + nowMu.Lock() + current = current.Add(time.Minute) + nowMu.Unlock() + if err := service.ObserveResponseModel(context.Background(), 42, "grok-newer"); err != nil { + t.Fatal(err) + } + close(accounts.olderRelease) + if err := <-olderDone; err != nil { + t.Fatal(err) + } + if err := service.ObserveResponseModel(context.Background(), 42, "grok-newer"); err != nil { + t.Fatal(err) + } + if calls := accounts.calls.Load(); calls != 2 { + t.Fatalf("out-of-order writes = %d, want 2", calls) + } +} + +type observedModelCountingRepository struct { + repository.AccountRepository + calls atomic.Int64 +} + +type observedModelBlockingRepository struct { + repository.AccountRepository + calls atomic.Int64 + started chan struct{} + release chan struct{} + once sync.Once +} + +type observedModelOrderingRepository struct { + repository.AccountRepository + calls atomic.Int64 + olderStarted chan struct{} + olderRelease chan struct{} +} + +type observedModelTestStore struct { + mu sync.Mutex + values map[uint64]repository.ObservedModelState +} + +func (s *observedModelTestStore) GetObservedModelState(_ context.Context, accountID uint64) (repository.ObservedModelState, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + value, ok := s.values[accountID] + return value, ok, nil +} + +func (s *observedModelTestStore) SetObservedModelState(_ context.Context, accountID uint64, value repository.ObservedModelState, _ time.Duration) error { + s.mu.Lock() + s.values[accountID] = value + s.mu.Unlock() + return nil +} + +func (r *observedModelCountingRepository) UpdateObservedModel(ctx context.Context, id uint64, model string, observedAt time.Time) error { + r.calls.Add(1) + return r.AccountRepository.UpdateObservedModel(ctx, id, model, observedAt) +} + +func (r *observedModelBlockingRepository) UpdateObservedModel(context.Context, uint64, string, time.Time) error { + r.calls.Add(1) + r.once.Do(func() { close(r.started) }) + <-r.release + return nil +} + +func (r *observedModelOrderingRepository) UpdateObservedModel(_ context.Context, _ uint64, model string, _ time.Time) error { + r.calls.Add(1) + if model == "grok-older" { + close(r.olderStarted) + <-r.olderRelease + } + return nil +} + +func TestRefreshQuotaFetchesWebIdentityOnlyUntilDataExists(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-identity.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "web-identity", SourceKey: "web-identity", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierAuto, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{} + service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + for range 2 { + if _, err := service.RefreshQuota(ctx, credential.ID); err != nil { + t.Fatal(err) + } + } + if adapter.fullCalls.Load() != 2 || adapter.identityCalls.Load() != 1 { + t.Fatalf("quota calls=%d identity calls=%d", adapter.fullCalls.Load(), adapter.identityCalls.Load()) + } + stored, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.Email != "identity@example.com" { + t.Fatalf("email = %q", stored.Email) + } +} + +func TestRefreshQuotaUnauthorizedMarksWebAccountInvalid(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-unauthorized.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "web-unauthorized", SourceKey: "web-unauthorized", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{fullErr: provider.ErrUnauthorized} + service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + if _, err := service.RefreshQuota(ctx, credential.ID); !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } + stored, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.AuthStatus != accountdomain.AuthStatusReauthRequired || !stored.Enabled { + t.Fatalf("account state = %#v", stored) + } +} + +type deniedQuotaRefreshLock struct{} + +func (deniedQuotaRefreshLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) { + return nil, false, nil +} + +type quotaCountingAdapter struct { + modeCalls atomic.Int64 + fullCalls atomic.Int64 + identityCalls atomic.Int64 + fullErr error + modeStarted chan struct{} + modeRelease chan struct{} +} + +func (a *quotaCountingAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderWeb } + +func (a *quotaCountingAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: accountdomain.ProviderWeb, ModelNamespace: accountdomain.ProviderWeb.ModelNamespace(), + Quota: provider.QuotaRemoteWindow, Credential: provider.CredentialSurface{AuthType: accountdomain.AuthTypeSSO}, + } +} + +func (a *quotaCountingAdapter) SyncQuota(context.Context, accountdomain.Credential) (provider.QuotaSnapshot, error) { + a.fullCalls.Add(1) + return provider.QuotaSnapshot{}, a.fullErr +} + +func (a *quotaCountingAdapter) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) { + a.identityCalls.Add(1) + return provider.AccountIdentity{Email: "identity@example.com"}, nil +} + +func (a *quotaCountingAdapter) SyncQuotaMode(_ context.Context, credential accountdomain.Credential, mode string) (accountdomain.QuotaWindow, error) { + a.modeCalls.Add(1) + if a.modeStarted != nil { + a.modeStarted <- struct{}{} + } + if a.modeRelease != nil { + <-a.modeRelease + } + now := time.Now().UTC() + resetAt := now.Add(time.Hour) + return accountdomain.QuotaWindow{ + AccountID: credential.ID, Mode: mode, Remaining: 0, Total: 30, + WindowSeconds: 3600, ResetAt: &resetAt, SyncedAt: &now, Source: accountdomain.QuotaSourceUpstream, UpdatedAt: now, + }, nil +} diff --git a/backend/internal/application/account/quota_test.go b/backend/internal/application/account/quota_test.go new file mode 100644 index 0000000000000000000000000000000000000000..68225ea23824511927578ba643c7c007a81d17e0 --- /dev/null +++ b/backend/internal/application/account/quota_test.go @@ -0,0 +1,256 @@ +package account + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type quotaResetRepository struct { + repository.AccountRepository + countBatchSizes []int + resetBatchSizes []int +} + +func (r *quotaResetRepository) CountProviderAccountsByIDs(_ context.Context, provider accountdomain.Provider, ids []uint64) (int64, error) { + if provider != accountdomain.ProviderBuild { + return 0, nil + } + r.countBatchSizes = append(r.countBatchSizes, len(ids)) + return int64(len(ids)), nil +} + +func (r *quotaResetRepository) ResetQuotaState(_ context.Context, provider accountdomain.Provider, ids []uint64) error { + if provider != accountdomain.ProviderBuild { + return errors.New("unexpected provider") + } + r.resetBatchSizes = append(r.resetBatchSizes, len(ids)) + return nil +} + +func TestNewQuotaViewFreeUsesObservedRollingTokens(t *testing.T) { + quota := newQuotaView(&accountdomain.Billing{IsUnifiedBillingUser: true}, 250_000, nil, "grok-4.5-build-free", false) + if quota.Type != QuotaTypeFree || quota.Unit != "tokens" || quota.Limit != 500_000 || quota.LimitKnown || quota.Confidence != "observed" { + t.Fatalf("quota = %#v", quota) + } + if quota.Used != 250_000 || quota.Remaining != 250_000 || quota.UsagePercent != 50 || quota.WindowHours != 24 || !quota.Observed { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewPaidUsesMonthlyBilling(t *testing.T) { + quota := newQuotaView(&accountdomain.Billing{MonthlyLimit: 200, Used: 50, BillingPeriodStart: "start", BillingPeriodEnd: "end"}, 900_000, nil, "", false) + if quota.Type != QuotaTypePaid || quota.Unit != "credits" || quota.Limit != 200 { + t.Fatalf("quota = %#v", quota) + } + if quota.Used != 50 || quota.Remaining != 150 || quota.UsagePercent != 25 || quota.Observed || !quota.LimitKnown { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewPaidShowsBillingProbeState(t *testing.T) { + now := time.Now().UTC() + next := now.Add(time.Hour) + quota := newQuotaView(&accountdomain.Billing{MonthlyLimit: 100, Used: 100}, 0, &accountdomain.QuotaRecovery{ + Kind: accountdomain.QuotaRecoveryKindPaid, Status: accountdomain.QuotaRecoveryStatusExhausted, + ExhaustedAt: &now, NextProbeAt: &next, + }, "", false) + if quota.Type != QuotaTypePaid || quota.Status != QuotaStatusWaitingReset || quota.NextProbeAt == nil { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewUnknownWithoutBillingSnapshot(t *testing.T) { + quota := newQuotaView(nil, 100, nil, "", false) + if quota.Type != QuotaTypeUnknown { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewEstimatesFreeFromObservedZeroBillingProfile(t *testing.T) { + quota := newQuotaView(&accountdomain.Billing{PlanName: "Free", IsUnifiedBillingUser: true, TopUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD"}, 100, nil, "", false) + if quota.Type != QuotaTypeFree || quota.Source != "billingProfile" || quota.Confidence != "estimated" || quota.Limit != 500_000 || quota.LimitKnown { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewInfersFreeFromSuccessfulZeroBillingSnapshot(t *testing.T) { + quota := newQuotaView(&accountdomain.Billing{ + IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", SyncedAt: time.Now().UTC(), + }, 100, nil, "", false) + if quota.Type != QuotaTypeFree || quota.Source != "billingProfile" || quota.Confidence != "estimated" { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewTreatsZeroUsageSuperGrokAsPaid(t *testing.T) { + quota := newQuotaView(&accountdomain.Billing{ + PlanName: "SuperGrok", IsUnifiedBillingUser: true, + UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", CreditUsagePercent: 0, + }, 0, nil, "", false) + if quota.Type != QuotaTypePaid || quota.Unit != "percent" || quota.Limit != 100 || quota.Remaining != 100 || quota.Confidence != "observed" { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewUsesConfirmedExhaustion(t *testing.T) { + now := time.Now().UTC() + next := now.Add(24 * time.Hour) + quota := newQuotaView(&accountdomain.Billing{}, 250_000, &accountdomain.QuotaRecovery{ + Status: accountdomain.QuotaRecoveryStatusExhausted, ConfirmedUsed: 1_065_387, + ConfirmedLimit: 1_000_000, ExhaustedAt: &now, NextProbeAt: &next, LastConfirmedAt: &now, + }, "", false) + if quota.Type != QuotaTypeFree || quota.Status != QuotaStatusWaitingReset || !quota.Confirmed { + t.Fatalf("quota = %#v", quota) + } + if quota.Used != 1_065_387 || quota.Limit != 1_000_000 || quota.Remaining != 0 || quota.NextProbeAt == nil || !quota.LimitKnown { + t.Fatalf("quota = %#v", quota) + } +} + +func TestNewQuotaViewBuildSuperEntitlementOverridesFreeSignals(t *testing.T) { + now := time.Now().UTC() + next := now.Add(time.Hour) + // 零 Billing profile + entitlement → paid/Super,不伪造额度。 + quota := newQuotaView(&accountdomain.Billing{IsUnifiedBillingUser: true, TopUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD"}, 100, nil, "grok-4.5-build-free", true) + if quota.Type != QuotaTypePaid || quota.Source != "buildSuperEntitlement" || quota.Confidence != "confirmed" || !quota.Confirmed { + t.Fatalf("entitlement quota = %#v", quota) + } + if quota.LimitKnown || quota.Used != 0 || quota.Limit != 0 || quota.Unit != "" { + t.Fatalf("must not fabricate limits: %#v", quota) + } + // Free recovery 不得把 entitlement 账号降回 Free。 + quota = newQuotaView(&accountdomain.Billing{}, 250_000, &accountdomain.QuotaRecovery{ + Kind: accountdomain.QuotaRecoveryKindFree, Status: accountdomain.QuotaRecoveryStatusExhausted, + ConfirmedUsed: 1_000_000, ConfirmedLimit: 1_000_000, ExhaustedAt: &now, NextProbeAt: &next, + }, "", true) + if quota.Type != QuotaTypePaid || quota.Source != "buildSuperEntitlement" { + t.Fatalf("entitlement must override free recovery: %#v", quota) + } + // Billing paid 仍优先展示真实额度。 + quota = newQuotaView(&accountdomain.Billing{MonthlyLimit: 200, Used: 50}, 0, nil, "", true) + if quota.Type != QuotaTypePaid || quota.Source != "upstreamBilling" || quota.Limit != 200 { + t.Fatalf("paid billing must win: %#v", quota) + } +} + +func TestBatchResetQuotaStatePreservesBillingAndClearsLocalBlocks(t *testing.T) { + ctx := context.Background() + service, accounts := openAccountService(t) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "build", SourceKey: "quota-reset-build", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + next := now.Add(24 * time.Hour) + if err := accounts.SaveBilling(ctx, accountdomain.Billing{AccountID: credential.ID, PlanName: "free", Used: 42, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accounts.SaveQuotaRecovery(ctx, accountdomain.QuotaRecovery{ + AccountID: credential.ID, Kind: accountdomain.QuotaRecoveryKindFree, Status: accountdomain.QuotaRecoveryStatusExhausted, + ExhaustedAt: &now, NextProbeAt: &next, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + if err := accounts.UpsertModelQuotaBlock(ctx, accountdomain.ModelQuotaBlock{ + AccountID: credential.ID, UpstreamModel: "grok-test", Reason: "model_quota_depleted", CooldownUntil: next, + }); err != nil { + t.Fatal(err) + } + if err := accounts.UpsertModelQuotaBlock(ctx, accountdomain.ModelQuotaBlock{ + AccountID: credential.ID, UpstreamModel: "grok-denied", Reason: "model_access_denied", CooldownUntil: next, + }); err != nil { + t.Fatal(err) + } + + reset, err := service.BatchResetQuotaState(ctx, []uint64{credential.ID}) + if err != nil || reset != 1 { + t.Fatalf("reset = %d, err = %v", reset, err) + } + if _, err := accounts.GetQuotaRecovery(ctx, credential.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("quota recovery should be cleared, err = %v", err) + } + billing, err := accounts.GetBilling(ctx, credential.ID) + if err != nil || billing.Used != 42 { + t.Fatalf("billing = %#v, err = %v", billing, err) + } + candidates, err := accounts.ListRoutingCandidates(ctx, accountdomain.ProviderBuild, 0, "grok-test", "") + if err != nil || len(candidates) != 1 || candidates[0].ModelQuotaBlock != nil { + t.Fatalf("candidates = %#v, err = %v", candidates, err) + } + candidates, err = accounts.ListRoutingCandidates(ctx, accountdomain.ProviderBuild, 0, "grok-denied", "") + if err != nil || len(candidates) != 1 || candidates[0].ModelQuotaBlock == nil || candidates[0].ModelQuotaBlock.Reason != "model_access_denied" { + t.Fatalf("access denial must be preserved, candidates = %#v, err = %v", candidates, err) + } +} + +func TestResetAllBuildQuotaStateOnlyProcessesEnabledBuildAccounts(t *testing.T) { + ctx := context.Background() + service, accounts := openAccountService(t) + create := func(providerValue accountdomain.Provider, sourceKey string, enabled bool) accountdomain.Credential { + value, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: providerValue, Name: sourceKey, SourceKey: sourceKey, + EncryptedAccessToken: "encrypted", Enabled: enabled, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + return value + } + enabledBuild := create(accountdomain.ProviderBuild, "reset-all-enabled", true) + disabledBuild := create(accountdomain.ProviderBuild, "reset-all-disabled", false) + web := create(accountdomain.ProviderWeb, "reset-all-web", true) + disabledBuild.Enabled = false + if _, err := accounts.Update(ctx, disabledBuild); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + next := now.Add(24 * time.Hour) + for _, value := range []accountdomain.Credential{enabledBuild, disabledBuild, web} { + if err := accounts.SaveQuotaRecovery(ctx, accountdomain.QuotaRecovery{ + AccountID: value.ID, Kind: accountdomain.QuotaRecoveryKindFree, Status: accountdomain.QuotaRecoveryStatusExhausted, + ExhaustedAt: &now, NextProbeAt: &next, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + + reset, err := service.ResetAllBuildQuotaState(ctx) + if err != nil || reset != 1 { + t.Fatalf("reset = %d, err = %v", reset, err) + } + if _, err := accounts.GetQuotaRecovery(ctx, enabledBuild.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("enabled Build recovery should be cleared, err = %v", err) + } + for _, value := range []accountdomain.Credential{disabledBuild, web} { + if _, err := accounts.GetQuotaRecovery(ctx, value.ID); err != nil { + t.Fatalf("recovery for account %d should remain, err = %v", value.ID, err) + } + } +} + +func TestBatchResetQuotaStateChunksMoreThanAdminPageLimit(t *testing.T) { + repo := "aResetRepository{} + service := &Service{accounts: repo} + ids := make([]uint64, 2501) + for index := range ids { + ids[index] = uint64(index + 1) + } + + reset, err := service.BatchResetQuotaState(context.Background(), ids) + if err != nil || reset != len(ids) { + t.Fatalf("reset = %d, err = %v", reset, err) + } + want := []int{500, 500, 500, 500, 500, 1} + if !slices.Equal(repo.countBatchSizes, want) || !slices.Equal(repo.resetBatchSizes, want) { + t.Fatalf("count batches = %v, reset batches = %v, want %v", repo.countBatchSizes, repo.resetBatchSizes, want) + } +} diff --git a/backend/internal/application/account/service.go b/backend/internal/application/account/service.go new file mode 100644 index 0000000000000000000000000000000000000000..b66c4e5e98e1dbc5e8515f31ec23d8d441dec4da --- /dev/null +++ b/backend/internal/application/account/service.go @@ -0,0 +1,3362 @@ +package account + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "sync" + "time" + + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/chenyme/grok2api/backend/internal/pkg/resultcache" + "github.com/chenyme/grok2api/backend/internal/repository" + "golang.org/x/sync/singleflight" +) + +var ( + ErrDevicePending = errors.New("Device OAuth 等待用户授权") + ErrDeviceSlowDown = errors.New("Device OAuth 轮询过快") + ErrDeviceDenied = errors.New("Device OAuth 已拒绝或过期") + ErrInvalidFilter = errors.New("账号筛选条件无效") + ErrInvalidInput = errors.New("账号参数无效") + ErrInvalidImport = errors.New("账号凭据格式无效") + ErrImportLimit = errors.New("导入账号数量超过限制") + ErrExportLimit = errors.New("导出账号数量超过限制") + ErrNotFound = errors.New("账号不存在") + ErrUnsupported = errors.New("账号来源不支持该操作") + ErrConversionBusy = errors.New("账号正在转换为 Grok Build") + ErrConflict = errors.New("账号操作存在冲突") + ErrAccountPoolMismatch = errors.New("批量操作包含不属于当前号池的账号") +) + +var ErrCredentialRefreshPermanent = errors.New("OAuth refresh token 已永久失效") + +const ( + // estimatedFreeTokenLimit is only a fallback until an upstream exhaustion + // response supplies the account-specific actual/limit pair. + estimatedFreeTokenLimit int64 = 500_000 + freeUsageWindow time.Duration = 24 * time.Hour + forcedRefreshMinInterval time.Duration = 30 * time.Second + paidProbeRetryInterval time.Duration = 15 * time.Minute + credentialRefreshAdvance time.Duration = 3 * time.Minute + credentialRefreshSafetyPoll time.Duration = time.Minute + credentialRefreshTimeout time.Duration = 30 * time.Second + credentialRefreshStateTTL time.Duration = 5 * time.Second + credentialStateWriteTimeout time.Duration = 5 * time.Second + credentialRefreshBatchSize = 100 + managedTaskWorkerCeiling = 50 + webQuotaRefreshQueueSize = 4096 + webQuotaRefreshTimeout = 30 * time.Second + webQuotaRefreshDirtyTTL = 24 * time.Hour + webQuotaRefreshRetryInterval = 500 * time.Millisecond + webQuotaRefreshSharedPoll = time.Second + observedModelPersistInterval = 30 * time.Minute + observedModelLocalCacheTTL = 5 * time.Second + observedModelLockShards = 64 + maxCredentialExportAccounts = 10000 + maxCredentialImportAccounts = 10000 + credentialImportChunkSize = 100 + maxQuotaResetAccounts = 10000 + quotaResetChunkSize = 500 + maxBatchUpdateAccounts = 10000 + maxBuildConversionAccounts = 1000 + maxWebConsoleSyncAccounts = 1000 + accountTaskBatchSize = 1000 + buildBotFlagCacheTTL time.Duration = 30 * time.Second + linkedDeleteRuntimeCleanupLimit = 3 * time.Second +) + +const permanentRefreshExpiredReason = "OAuth refresh token 已永久失效且 access token 已过期" +const buildBotFlagCacheKey = "build-bot-flagged-account-ids" + +type webQuotaRefreshState struct { + generation uint64 + publishedGeneration uint64 + sharedGeneration uint64 + queued bool + running bool + nextAttemptAt time.Time +} + +type observedModelState struct { + model string + persistedAt time.Time +} + +type observedModelShard struct { + sync.Mutex + values map[uint64]observedModelState + lastCleanupAt time.Time +} + +type webQuotaRefreshRequest struct { + key string + accountID uint64 + mode string +} + +type QuotaRefreshStats struct { + Pending int + Queued int + Running int +} + +type QuotaType string +type QuotaStatus string + +const ( + QuotaTypeUnknown QuotaType = "unknown" + QuotaTypeFree QuotaType = "free" + QuotaTypePaid QuotaType = "paid" + QuotaStatusActive QuotaStatus = "active" + QuotaStatusWaitingReset QuotaStatus = "waitingReset" + QuotaStatusProbing QuotaStatus = "probing" +) + +type QuotaView struct { + Type QuotaType + Source string + Confidence string + Unit string + Used float64 + Limit float64 + Remaining float64 + UsagePercent float64 + LimitKnown bool + WindowHours int + Observed bool + Confirmed bool + Status QuotaStatus + PeriodStart string + PeriodEnd string + ExhaustedAt *time.Time + NextProbeAt *time.Time + LastConfirmedAt *time.Time +} + +type View struct { + Credential accountdomain.Credential + Billing *accountdomain.Billing + Quota QuotaView + QuotaWindows []accountdomain.QuotaWindow + BuildBotFlagged bool +} + +type UpdateInput struct { + Name *string + Enabled *bool + Priority *int + MaxConcurrent *int + MinimumRemaining *float64 + CloudflareCookies *string + ClearCloudflareCookies bool + // BuildSuperEntitled 仅 grok_build 可设置;非 Build 返回业务错误。 + BuildSuperEntitled *bool + // BuildRouteMode 仅 grok_build 可设置;nil 表示不修改。 + BuildRouteMode *accountdomain.BuildRouteMode +} + +type CleanupStatus string + +const ( + CleanupStatusCooldown CleanupStatus = "cooldown" + CleanupStatusDisabled CleanupStatus = "disabled" + CleanupStatusReauthRequired CleanupStatus = "reauthRequired" +) + +type DeviceStartResult struct { + SessionID string + UserCode string + VerificationURI string + VerificationURIComplete string + Interval time.Duration + ExpiresAt time.Time +} + +type ImportResult struct { + Created int + Updated int + Skipped int + AccountIDs []uint64 +} + +type BuildConversionStrategy string + +const ( + BuildConversionAll BuildConversionStrategy = "all" + BuildConversionMissing BuildConversionStrategy = "missing" +) + +type WebConsoleSyncStrategy string + +const ( + WebConsoleSyncAll WebConsoleSyncStrategy = "all" + WebConsoleSyncMissing WebConsoleSyncStrategy = "missing" +) + +type ImportedAccountObserver func(accountID uint64) error + +// BatchProgressObserver 在单个账号任务结束后报告批次完成数。 +type BatchProgressObserver func(completed, total int) error + +type ExportResult struct { + Data []byte + Count int +} + +type ExportPageResult struct { + ExportResult + NextID uint64 + SnapshotMaxID uint64 + HasMore bool +} + +type BuildConversionResult struct { + Created int + Linked int + Skipped int + Failed int + BuildAccountIDs []uint64 +} + +type ListFilter struct { + Provider string + QuotaType string + Status string + Egress string + Renewal string + Risk string + // Agreement applies only to grok_web accounts. + Agreement string + // Association values are provider-specific: Web supports build, console, and combined filters; + // Build and Console support only webLinked and webUnlinked. + Association string + Sort repository.SortQuery +} + +type Summary struct { + Total int64 + Available int64 + Recovering int64 + Attention int64 + Risk int64 + Providers map[string]ProviderSummary + Recovery RecoverySummary + Issues IssueSummary +} + +type ProviderSummary struct { + Total int64 + Available int64 +} + +type RecoverySummary struct { + Cooldown int64 + WaitingReset int64 + Probing int64 +} + +type IssueSummary struct { + Disabled int64 + ReauthRequired int64 +} + +func (s *Service) Summary(ctx context.Context) (Summary, error) { + rows, err := s.accounts.Summarize(ctx, s.now()) + if err != nil { + return Summary{}, err + } + result := Summary{Providers: make(map[string]ProviderSummary, len(accountdomain.Providers()))} + for _, providerValue := range accountdomain.Providers() { + result.Providers[string(providerValue)] = ProviderSummary{} + } + for _, row := range rows { + result.Total += row.Total + result.Available += row.Available + result.Recovery.Cooldown += row.Cooldown + result.Recovery.WaitingReset += row.WaitingReset + result.Recovery.Probing += row.Probing + result.Issues.Disabled += row.Disabled + result.Issues.ReauthRequired += row.ReauthRequired + result.Providers[row.Provider] = ProviderSummary{Total: row.Total, Available: row.Available} + } + result.Recovering = result.Recovery.Cooldown + result.Recovery.WaitingReset + result.Recovery.Probing + result.Attention = result.Issues.Disabled + result.Issues.ReauthRequired + flaggedIDs, err := s.buildBotFlaggedAccountIDs(ctx) + if err != nil { + return Summary{}, err + } + result.Risk = int64(len(flaggedIDs)) + return result, nil +} + +// Service 负责 OAuth 账号接入、刷新、额度和持久化生命周期。 +type Service struct { + accounts repository.AccountRepository + audits repository.AuditRepository + deviceSessions repository.DeviceSessionRepository + sticky repository.StickySessionRepository + refreshLock repository.DistributedLock + concurrency repository.ConcurrencyLimiter + quotaQueue repository.QuotaRecoveryQueue + quotaRefreshState repository.QuotaRefreshCoordinator + providers *provider.Registry + cipher *security.Cipher + refreshes singleflight.Group + billingSyncs singleflight.Group + quotaSyncs singleflight.Group + identitySyncs singleflight.Group + observedModelWrites singleflight.Group + observedModelStore repository.ObservedModelStateRepository + refreshMu sync.Mutex + lastRefreshAt map[uint64]time.Time + observedModelShards [observedModelLockShards]observedModelShard + quotaRefreshMu sync.Mutex + quotaRefreshes map[string]*webQuotaRefreshState + quotaRefreshQueue chan webQuotaRefreshRequest + quotaRefreshWake chan struct{} + conversionPool *batch.Pool + syncPool *batch.Pool + refreshPool *batch.Pool + credentialRefreshWake chan struct{} + autoCleanMu sync.RWMutex + autoClean AutoCleanConfig + autoCleanRevision uint64 + autoCleanWake chan struct{} + buildBotFlagCache *resultcache.Cache[string, []uint64] + logger *slog.Logger + now func() time.Time +} + +func (s *Service) SetQuotaRecoveryQueue(queue repository.QuotaRecoveryQueue) { + s.quotaQueue = queue +} + +func (s *Service) SetQuotaRefreshCoordinator(value repository.QuotaRefreshCoordinator) { + s.quotaRefreshState = value +} + +func (s *Service) QuotaRefreshStats() QuotaRefreshStats { + s.quotaRefreshMu.Lock() + defer s.quotaRefreshMu.Unlock() + result := QuotaRefreshStats{Pending: len(s.quotaRefreshes)} + for _, state := range s.quotaRefreshes { + if state == nil { + continue + } + if state.queued { + result.Queued++ + } + if state.running { + result.Running++ + } + } + return result +} + +// SetConcurrencyLimiter 让账号维护任务读取与推理路由相同的活动租约。 +func (s *Service) SetConcurrencyLimiter(value repository.ConcurrencyLimiter) { + s.concurrency = value +} + +// SetObservedModelStore enables best-effort cross-instance duplicate suppression. +func (s *Service) SetObservedModelStore(value repository.ObservedModelStateRepository) { + s.observedModelStore = value +} + +func NewService(accounts repository.AccountRepository, audits repository.AuditRepository, deviceSessions repository.DeviceSessionRepository, sticky repository.StickySessionRepository, providers *provider.Registry, cipher *security.Cipher, refreshLock repository.DistributedLock) *Service { + return &Service{ + accounts: accounts, audits: audits, deviceSessions: deviceSessions, sticky: sticky, + providers: providers, cipher: cipher, refreshLock: refreshLock, + lastRefreshAt: make(map[uint64]time.Time), quotaRefreshes: make(map[string]*webQuotaRefreshState), + quotaRefreshQueue: make(chan webQuotaRefreshRequest, webQuotaRefreshQueueSize), + quotaRefreshWake: make(chan struct{}, 1), + credentialRefreshWake: make(chan struct{}, 1), + autoClean: AutoCleanConfig{ + Enabled: false, Interval: 10 * time.Minute, MinAge: time.Hour, IncludeDisabled: false, + }, + autoCleanWake: make(chan struct{}, 1), + buildBotFlagCache: resultcache.New[string, []uint64](1, buildBotFlagCacheTTL), + conversionPool: batch.NewPool(25), syncPool: batch.NewPool(25), refreshPool: batch.NewPool(25), logger: slog.Default(), + now: func() time.Time { return time.Now().UTC() }, + } +} + +func (s *Service) SetBulkPool(pool *batch.Pool) { + if pool != nil { + s.conversionPool, s.syncPool, s.refreshPool = pool, pool, pool + } +} + +// SetTaskPools 为转换、同步和凭据刷新绑定独立分类并发池。 +func (s *Service) SetTaskPools(conversion, syncPool, refresh *batch.Pool) { + if conversion != nil { + s.conversionPool = conversion + } + if syncPool != nil { + s.syncPool = syncPool + } + if refresh != nil { + s.refreshPool = refresh + } +} + +func (s *Service) SetLogger(logger *slog.Logger) { + if logger != nil { + s.logger = logger + } +} + +// ProviderDefinition 向账号同步编排层暴露只读生命周期策略,不泄露具体 Adapter。 +func (s *Service) ProviderDefinition(value accountdomain.Provider) (provider.Definition, bool) { + if s.providers == nil { + return provider.Definition{}, false + } + return s.providers.Definition(value) +} + +func (s *Service) List(ctx context.Context, page, pageSize int, search string, filter ListFilter) ([]View, int64, error) { + page, pageSize = normalizePage(page, pageSize) + if (filter.Provider != "" && !accountdomain.Provider(filter.Provider).IsValid()) || + !oneOf(filter.QuotaType, "", "free", "paid", "unknown", "auto", "basic", "super", "heavy") || + !oneOf(filter.Status, "", "active", "disabled", "reauthRequired", "cooldown", "waitingReset", "probing") || + !oneOf(filter.Egress, "", "bound", "unbound") || + !oneOf(filter.Renewal, "", "refreshable", "unrefreshable") || + !oneOf(filter.Risk, "", "flagged", "normal") || + (filter.Risk != "" && filter.Provider != string(accountdomain.ProviderBuild)) || + !oneOf(filter.Agreement, "", "nsfwEnabled", "nsfwDisabled", "termsAccepted", "termsNotAccepted", "allAccepted", "allNotAccepted") || + (filter.Agreement != "" && filter.Provider != string(accountdomain.ProviderWeb)) || + !validAssociationFilter(filter.Provider, filter.Association) || + !repository.IsValidSort(filter.Sort, "name", "type", "status", "createdAt") { + return nil, 0, ErrInvalidFilter + } + var refreshable *bool + if filter.Renewal != "" { + value := filter.Renewal == "refreshable" + refreshable = &value + } + repositoryFilter := repository.AccountListFilter{ + Provider: filter.Provider, QuotaType: filter.QuotaType, Status: filter.Status, Egress: filter.Egress, + Refreshable: refreshable, Agreement: filter.Agreement, Association: filter.Association, Now: s.now(), + } + if filter.Risk != "" { + flaggedIDs, err := s.buildBotFlaggedAccountIDs(ctx) + if err != nil { + return nil, 0, err + } + if filter.Risk == "flagged" { + repositoryFilter.AccountIDs = flaggedIDs + repositoryFilter.RestrictIDs = true + } else { + repositoryFilter.ExcludeIDs = flaggedIDs + } + } + values, total, err := s.accounts.List(ctx, repository.AccountListQuery{ + Page: repository.PageQuery{Offset: (page - 1) * pageSize, Limit: pageSize, Search: search, Sort: filter.Sort}, + Filter: repositoryFilter, + }) + if err != nil { + return nil, 0, err + } + accountIDs := make([]uint64, 0, len(values)) + for _, value := range values { + accountIDs = append(accountIDs, value.ID) + } + observedTokens, err := s.audits.SumTokensByAccountsSince(ctx, accountIDs, time.Now().UTC().Add(-freeUsageWindow)) + if err != nil { + return nil, 0, err + } + billings, err := s.accounts.GetBillings(ctx, accountIDs) + if err != nil { + return nil, 0, err + } + recoveries, err := s.accounts.GetQuotaRecoveries(ctx, accountIDs) + if err != nil { + return nil, 0, err + } + quotaWindows, err := s.accounts.GetQuotaWindows(ctx, accountIDs) + if err != nil { + return nil, 0, err + } + views := make([]View, 0, len(values)) + for _, value := range values { + metadata := s.credentialMetadata(value) + view := View{Credential: value, BuildBotFlagged: metadata.BuildBotFlagged} + if billing, ok := billings[value.ID]; ok { + view.Billing = &billing + } + var recovery *accountdomain.QuotaRecovery + if recoveryValue, ok := recoveries[value.ID]; ok { + recovery = &recoveryValue + } + view.Quota = newQuotaView(view.Billing, observedTokens[value.ID], recovery, value.ObservedModel, value.BuildSuperEntitled && value.Provider == accountdomain.ProviderBuild) + view.QuotaWindows = quotaWindows[value.ID] + views = append(views, view) + } + return views, total, nil +} + +func (s *Service) buildBotFlaggedAccountIDs(ctx context.Context) ([]uint64, error) { + if s.buildBotFlagCache == nil { + return s.loadBuildBotFlaggedAccountIDs(ctx) + } + return s.buildBotFlagCache.Load(ctx, buildBotFlagCacheKey, s.now(), func() ([]uint64, error) { + return s.loadBuildBotFlaggedAccountIDs(ctx) + }) +} + +func (s *Service) loadBuildBotFlaggedAccountIDs(ctx context.Context) ([]uint64, error) { + const batchSize = 500 + result := make([]uint64, 0) + var afterID uint64 + for { + values, _, err := s.accounts.ListProviderAccountBatch(ctx, accountdomain.ProviderBuild, afterID, batchSize) + if err != nil { + return nil, err + } + for _, value := range values { + if s.credentialMetadata(value).BuildBotFlagged { + result = append(result, value.ID) + } + } + if len(values) < batchSize { + return result, nil + } + afterID = values[len(values)-1].ID + } +} + +func (s *Service) invalidateBuildBotFlagCache() { + if s.buildBotFlagCache != nil { + s.buildBotFlagCache.Delete(buildBotFlagCacheKey) + } +} + +func oneOf(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +// validAssociationFilter validates association filters against the selected provider. +// Web keeps its six Build/Console/combined values; Build and Console filter only by Web links. +func validAssociationFilter(providerValue, association string) bool { + if association == "" { + return true + } + switch providerValue { + case string(accountdomain.ProviderWeb): + return oneOf(association, "buildLinked", "buildUnlinked", "consoleLinked", "consoleUnlinked", "allLinked", "allUnlinked") + case string(accountdomain.ProviderBuild), string(accountdomain.ProviderConsole): + return oneOf(association, "webLinked", "webUnlinked") + default: + return false + } +} + +// BatchUpdate 对同一号池的一组账号应用相同路由参数。 +func (s *Service) BatchUpdate(ctx context.Context, providerValue accountdomain.Provider, ids []uint64, input UpdateInput) (int64, error) { + ids, err := normalizeIDs(ids, maxBatchUpdateAccounts) + if err != nil { + return 0, err + } + if !providerValue.IsValid() { + return 0, invalidInput("账号来源无效") + } + slices.Sort(ids) + if input.MaxConcurrent != nil && (*input.MaxConcurrent < 1 || *input.MaxConcurrent > accountdomain.MaxConcurrent) { + return 0, invalidInput("maxConcurrent 必须在 1 到 256 之间") + } + if input.MinimumRemaining != nil && *input.MinimumRemaining < 0 { + return 0, invalidInput("minimumRemaining 不能小于零") + } + if input.Name != nil { + return 0, invalidInput("批量更新不支持修改账号名称") + } + updated, err := s.accounts.UpdateMany(ctx, providerValue, ids, repository.AccountUpdates{Enabled: input.Enabled, Priority: input.Priority, MaxConcurrent: input.MaxConcurrent, MinimumRemaining: input.MinimumRemaining}) + if err != nil { + return 0, mapRepositoryError(err) + } + if input.Enabled != nil && !*input.Enabled && s.sticky != nil { + if batchDeleter, ok := s.sticky.(repository.StickySessionBatchDeleter); ok { + _ = batchDeleter.DeleteByAccounts(ctx, ids) + } else { + for _, id := range ids { + _ = s.sticky.DeleteByAccount(ctx, id) + } + } + } + return updated, nil +} + +// AccountDeleteResult summarizes a single/batch delete with optional linked peers. +type AccountDeleteResult struct { + Deleted int64 + RootsDeleted int64 + LinkedDeleted int64 + Skipped int64 + DeletedByProvider map[accountdomain.Provider]int64 +} + +// accountDeleteResultFromOutcome converts repository results using rows actually deleted. +func accountDeleteResultFromOutcome(providerValue accountdomain.Provider, outcome repository.LinkedDeleteOutcome) AccountDeleteResult { + out := AccountDeleteResult{ + Deleted: outcome.Deleted, + RootsDeleted: outcome.RootsDeleted, + LinkedDeleted: outcome.Deleted - outcome.RootsDeleted, + Skipped: int64(len(outcome.SkippedRoots)), + DeletedByProvider: map[accountdomain.Provider]int64{}, + } + if providerValue.IsValid() && outcome.RootsDeleted > 0 { + out.DeletedByProvider[providerValue] = outcome.RootsDeleted + } + for provider, count := range outcome.LinkedDeletedByProvider { + out.DeletedByProvider[provider] += count + } + return out +} + +// deleteStickyAccounts uses the optional batch capability and falls back for custom stores. +func (s *Service) deleteStickyAccounts(ctx context.Context, accountIDs []uint64) (int, error) { + if s.sticky == nil || len(accountIDs) == 0 { + return 0, nil + } + if batchDeleter, ok := s.sticky.(repository.StickySessionBatchDeleter); ok { + if err := batchDeleter.DeleteByAccounts(ctx, accountIDs); err != nil { + return len(accountIDs), err + } + return 0, nil + } + failures := 0 + var firstErr error + for _, id := range accountIDs { + if err := s.sticky.DeleteByAccount(ctx, id); err != nil { + failures++ + if firstErr == nil { + firstErr = err + } + } + } + return failures, firstErr +} + +// finishLinkedDelete clears runtime state after the database transaction commits. +func (s *Service) finishLinkedDelete(ctx context.Context, deletedIDs []uint64) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), linkedDeleteRuntimeCleanupLimit) + defer cancel() + if failures, err := s.deleteStickyAccounts(cleanupCtx, deletedIDs); err != nil && s.logger != nil { + s.logger.Warn("linked_account_runtime_cleanup_failed", "accounts", len(deletedIDs), "failures", failures, "error", err) + } + for _, id := range deletedIDs { + s.clearRefreshState(id) + } +} + +// BatchDelete atomically removes roots and quota state without expanding linked accounts. +func (s *Service) BatchDelete(ctx context.Context, ids []uint64) (int64, error) { + result, err := s.batchDeleteWithLinkedMode(ctx, accountdomain.Provider(""), ids, nil, true) + return result.Deleted, err +} + +// BatchDeleteWithLinked deletes root accounts and optional linked peers resolved from binding tables. +// Roots with active video jobs are skipped together with their linked group; other groups are deleted. +func (s *Service) BatchDeleteWithLinked(ctx context.Context, providerValue accountdomain.Provider, ids []uint64, targets []accountdomain.Provider) (AccountDeleteResult, error) { + return s.batchDeleteWithLinkedMode(ctx, providerValue, ids, targets, true) +} + +// batchDeleteWithLinkedMode is the shared atomic path; skipMedia selects reject-all or skip-group behavior. +func (s *Service) batchDeleteWithLinkedMode(ctx context.Context, providerValue accountdomain.Provider, ids []uint64, targets []accountdomain.Provider, skipMedia bool) (AccountDeleteResult, error) { + var out AccountDeleteResult + ids, err := normalizeBatchIDs(ids) + if err != nil { + return out, err + } + if len(ids) == 0 { + return out, nil + } + if len(targets) > 0 && !providerValue.IsValid() { + return out, invalidInput("账号来源无效") + } + // Atomic path: lock roots → expand links → lock final → media handling → delete. + outcome, err := s.accounts.DeleteManyWithLinked(ctx, providerValue, ids, targets, skipMedia) + if err != nil { + return out, mapLinkedDeleteError(err) + } + s.finishLinkedDelete(ctx, outcome.DeletedIDs) + if outcome.Deleted > 0 { + s.invalidateBuildBotFlagCache() + } + return accountDeleteResultFromOutcome(providerValue, outcome), nil +} + +// AccountsBelongToProvider 校验批量账号是否全部属于指定号池。 +// 该校验只读取账号主表,避免详情页的额度、审计或关联查询影响批量操作。 +func (s *Service) AccountsBelongToProvider(ctx context.Context, ids []uint64, providerValue accountdomain.Provider) (bool, error) { + if !providerValue.IsValid() { + return false, invalidInput("账号来源无效") + } + values, err := normalizeBatchIDs(ids) + if err != nil { + return false, err + } + count, err := s.accounts.CountProviderAccountsByIDs(ctx, providerValue, values) + if err != nil { + return false, err + } + return count == int64(len(values)), nil +} + +// CleanupResult summarizes rows deleted and root groups skipped by one cleanup operation. +type CleanupResult struct { + Deleted int64 + RootsDeleted int64 + LinkedDeleted int64 + Skipped int64 + DeletedByProvider map[accountdomain.Provider]int64 +} + +// validateCleanupSelection validates cleanup states and linked target providers. +func validateCleanupSelection(providerValue accountdomain.Provider, statuses []CleanupStatus, targets []accountdomain.Provider) (map[CleanupStatus]struct{}, error) { + if !providerValue.IsValid() { + return nil, invalidInput("账号来源无效") + } + selected := make(map[CleanupStatus]struct{}, len(statuses)) + for _, status := range statuses { + switch status { + case CleanupStatusCooldown, CleanupStatusDisabled, CleanupStatusReauthRequired: + selected[status] = struct{}{} + default: + return nil, invalidInput("账号清理状态无效") + } + } + if len(selected) == 0 { + return nil, invalidInput("至少选择一种账号状态") + } + for _, target := range targets { + if !target.IsValid() { + return nil, invalidInput("关联删除目标无效") + } + if target == providerValue { + return nil, invalidInput("关联删除目标不能包含当前号池") + } + } + return selected, nil +} + +// CleanupAccounts deletes accounts in selected admin states; healthy, waiting-reset, and probing accounts are excluded. +// Linked targets are resolved from binding tables regardless of peer state, and active-media groups are skipped whole. +// The ID cursor always advances, so skipped groups cannot stall a cleanup batch. +func (s *Service) CleanupAccounts(ctx context.Context, providerValue accountdomain.Provider, statuses []CleanupStatus, targets []accountdomain.Provider) (CleanupResult, error) { + out := CleanupResult{DeletedByProvider: map[accountdomain.Provider]int64{}} + selected, err := validateCleanupSelection(providerValue, statuses, targets) + if err != nil { + return out, err + } + + const cleanupBatchSize = 500 + now := s.now() + for _, status := range []CleanupStatus{CleanupStatusDisabled, CleanupStatusReauthRequired, CleanupStatusCooldown} { + if _, ok := selected[status]; !ok { + continue + } + var afterID uint64 + for { + outcome, candidates, maxID, err := s.accounts.DeleteAccountStatusBatchWithLinked(ctx, providerValue, string(status), now, afterID, cleanupBatchSize, targets) + if err != nil { + return out, mapLinkedDeleteError(err) + } + s.finishLinkedDelete(ctx, outcome.DeletedIDs) + out.Deleted += outcome.Deleted + out.RootsDeleted += outcome.RootsDeleted + out.LinkedDeleted += outcome.Deleted - outcome.RootsDeleted + out.Skipped += int64(len(outcome.SkippedRoots)) + if outcome.RootsDeleted > 0 { + out.DeletedByProvider[providerValue] += outcome.RootsDeleted + } + for provider, count := range outcome.LinkedDeletedByProvider { + out.DeletedByProvider[provider] += count + } + if candidates < cleanupBatchSize { + break + } + afterID = maxID + } + } + if out.Deleted > 0 { + s.invalidateBuildBotFlagCache() + } + return out, nil +} + +// PreviewCleanup returns root and linked-peer counts for the cleanup confirmation dialog. +// The preview is informational; deletion revalidates state inside each transaction. +func (s *Service) PreviewCleanup(ctx context.Context, providerValue accountdomain.Provider, statuses []CleanupStatus, targets []accountdomain.Provider) (repository.CleanupPreview, error) { + selected, err := validateCleanupSelection(providerValue, statuses, targets) + if err != nil { + return repository.CleanupPreview{}, err + } + raw := make([]string, 0, len(selected)) + for _, status := range []CleanupStatus{CleanupStatusDisabled, CleanupStatusReauthRequired, CleanupStatusCooldown} { + if _, ok := selected[status]; ok { + raw = append(raw, string(status)) + } + } + preview, err := s.accounts.CountCleanupWithLinked(ctx, providerValue, raw, s.now(), targets) + if err != nil { + return repository.CleanupPreview{}, mapLinkedDeleteError(err) + } + return preview, nil +} + +func (s *Service) Get(ctx context.Context, id uint64) (View, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return View{}, mapRepositoryError(err) + } + metadata := s.credentialMetadata(value) + view := View{Credential: value, BuildBotFlagged: metadata.BuildBotFlagged} + if billing, err := s.accounts.GetBilling(ctx, id); err == nil { + view.Billing = &billing + } else if !errors.Is(err, repository.ErrNotFound) { + return View{}, err + } + observedTokens, err := s.audits.SumTokensByAccountsSince(ctx, []uint64{id}, time.Now().UTC().Add(-freeUsageWindow)) + if err != nil { + return View{}, err + } + var recovery *accountdomain.QuotaRecovery + if recoveryValue, err := s.accounts.GetQuotaRecovery(ctx, id); err == nil { + recovery = &recoveryValue + } else if !errors.Is(err, repository.ErrNotFound) { + return View{}, err + } + view.Quota = newQuotaView(view.Billing, observedTokens[id], recovery, value.ObservedModel, value.BuildSuperEntitled && value.Provider == accountdomain.ProviderBuild) + if windows, err := s.accounts.GetQuotaWindows(ctx, []uint64{id}); err == nil { + view.QuotaWindows = windows[id] + } else { + return View{}, err + } + return view, nil +} + +func (s *Service) credentialMetadata(value accountdomain.Credential) provider.CredentialMetadata { + if s.providers == nil { + return provider.CredentialMetadata{} + } + return s.providers.CredentialMetadata(value) +} + +func (s *Service) ObserveResponseModel(ctx context.Context, id uint64, model string) error { + model = strings.TrimSpace(model) + if model == "" { + return nil + } + _, err, _ := s.observedModelWrites.Do(strconv.FormatUint(id, 10)+"\x00"+model, func() (any, error) { + now := s.now() + shard := s.observedModelShard(id) + shard.Lock() + if shard.values == nil { + shard.values = make(map[uint64]observedModelState) + } + if shard.lastCleanupAt.IsZero() || now.Sub(shard.lastCleanupAt) >= observedModelPersistInterval { + for accountID, state := range shard.values { + if now.Sub(state.persistedAt) >= observedModelPersistInterval { + delete(shard.values, accountID) + } + } + shard.lastCleanupAt = now + } + state, exists := shard.values[id] + localFresh := exists && state.model == model && observedModelStateIsFresh(now, state.persistedAt) + if localFresh && (s.observedModelStore == nil || now.Sub(state.persistedAt) < observedModelLocalCacheTTL) { + shard.Unlock() + return nil, nil + } + shard.Unlock() + if s.observedModelStore != nil { + shared, ok, sharedErr := s.observedModelStore.GetObservedModelState(ctx, id) + if sharedErr == nil && ok && shared.Model == model && observedModelStateIsFresh(now, shared.ObservedAt) { + shard.Lock() + shard.values[id] = observedModelState{model: model, persistedAt: now} + shard.Unlock() + return nil, nil + } + } + updated := true + if writer, ok := s.accounts.(repository.ObservedModelWriter); ok { + var err error + updated, err = writer.UpdateObservedModelIfNewer(ctx, id, model, now) + if err != nil { + return nil, err + } + } else if err := s.accounts.UpdateObservedModel(ctx, id, model, now); err != nil { + return nil, err + } + if updated && s.observedModelStore != nil { + _ = s.observedModelStore.SetObservedModelState(ctx, id, repository.ObservedModelState{Model: model, ObservedAt: now}, observedModelPersistInterval) + } + shard.Lock() + current, exists := shard.values[id] + if !exists || !current.persistedAt.After(now) { + shard.values[id] = observedModelState{model: model, persistedAt: now} + } + shard.Unlock() + return nil, nil + }) + return err +} + +func (s *Service) observedModelShard(id uint64) *observedModelShard { + return &s.observedModelShards[id%observedModelLockShards] +} + +func observedModelStateIsFresh(now, persistedAt time.Time) bool { + elapsed := now.Sub(persistedAt) + return elapsed >= 0 && elapsed < observedModelPersistInterval +} + +func newQuotaView(billing *accountdomain.Billing, observedTokens int64, recovery *accountdomain.QuotaRecovery, observedModel string, buildSuperEntitled bool) QuotaView { + // Upstream paid billing takes precedence and preserves reported quota values. + if billing != nil && billing.IsPaid() { + periodStart, periodEnd := billing.BillingPeriodStart, billing.BillingPeriodEnd + if billing.UsagePeriodType != "" { + periodStart, periodEnd = billing.UsagePeriodStart, billing.UsagePeriodEnd + } + result := QuotaView{Type: QuotaTypePaid, Source: "upstreamBilling", Confidence: "observed", Unit: "credits", UsagePercent: billing.CreditUsagePercent, Status: QuotaStatusActive, PeriodStart: periodStart, PeriodEnd: periodEnd} + if recovery != nil && recovery.Kind == accountdomain.QuotaRecoveryKindPaid { + result.Status = QuotaStatusWaitingReset + if recovery.Status == accountdomain.QuotaRecoveryStatusProbing { + result.Status = QuotaStatusProbing + } + result.ExhaustedAt = recovery.ExhaustedAt + result.NextProbeAt = recovery.NextProbeAt + result.LastConfirmedAt = recovery.LastConfirmedAt + } + switch { + case billing.MonthlyLimit > 0: + result.Used = billing.Used + result.Limit = billing.MonthlyLimit + result.Remaining = billing.Remaining() + result.UsagePercent = billing.Used / billing.MonthlyLimit * 100 + result.LimitKnown = true + case billing.OnDemandCap > 0: + result.Limit = billing.OnDemandCap + result.Used = billing.OnDemandUsed + if result.Used == 0 && billing.CreditUsagePercent > 0 { + result.Used = billing.OnDemandCap * billing.CreditUsagePercent / 100 + } + result.Remaining = billing.OnDemandCap - result.Used + result.LimitKnown = true + if result.Remaining < 0 { + result.Remaining = 0 + } + case billing.PrepaidBalance > 0: + result.Remaining = billing.PrepaidBalance + case billing.UsagePeriodType != "": + result.Unit = "percent" + result.Used = billing.CreditUsagePercent + result.Limit = 100 + result.Remaining = max(0, 100-billing.CreditUsagePercent) + result.LimitKnown = true + } + return result + } + // 管理员确认的 Build Super entitlement:覆盖 Free recovery / profile / observed free 等弱信号。 + // 不伪造额度、余额、使用率或账期;Billing 数值保持未知/零。 + if buildSuperEntitled { + return QuotaView{ + Type: QuotaTypePaid, Source: "buildSuperEntitlement", Confidence: "confirmed", + Confirmed: true, Status: QuotaStatusActive, + } + } + if recovery != nil && recovery.Status != accountdomain.QuotaRecoveryStatusActive && (recovery.Kind == "" || recovery.Kind == accountdomain.QuotaRecoveryKindFree) { + limit := recovery.ConfirmedLimit + used := recovery.ConfirmedUsed + if used <= 0 { + used = observedTokens + } + status := QuotaStatusWaitingReset + if recovery.Status == accountdomain.QuotaRecoveryStatusProbing { + status = QuotaStatusProbing + } + remaining := int64(0) + usagePercent := 0.0 + if limit > 0 { + remaining = limit - used + if remaining < 0 { + remaining = 0 + } + usagePercent = float64(used) / float64(limit) * 100 + } + return QuotaView{ + Type: QuotaTypeFree, Source: "upstreamExhaustion", Confidence: "confirmed", Unit: "tokens", Used: float64(used), Limit: float64(limit), LimitKnown: limit > 0, + Remaining: float64(remaining), UsagePercent: usagePercent, + WindowHours: int(freeUsageWindow / time.Hour), Confirmed: true, Status: status, + ExhaustedAt: recovery.ExhaustedAt, NextProbeAt: recovery.NextProbeAt, LastConfirmedAt: recovery.LastConfirmedAt, + } + } + freeSource := "" + confidence := "" + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(observedModel)), "-build-free") { + freeSource = "responseModel" + confidence = "observed" + } else if isEstimatedFreeBillingProfile(billing) { + freeSource = "billingProfile" + confidence = "estimated" + } + if freeSource == "" { + return QuotaView{Type: QuotaTypeUnknown, Source: "unknown", Status: QuotaStatusActive} + } + if observedTokens < 0 { + observedTokens = 0 + } + remaining := estimatedFreeTokenLimit - observedTokens + if remaining < 0 { + remaining = 0 + } + return QuotaView{ + Type: QuotaTypeFree, + Source: freeSource, + Confidence: confidence, + Unit: "tokens", + Used: float64(observedTokens), + Limit: float64(estimatedFreeTokenLimit), + Remaining: float64(remaining), + UsagePercent: float64(observedTokens) / float64(estimatedFreeTokenLimit) * 100, + LimitKnown: false, + WindowHours: int(freeUsageWindow / time.Hour), + Observed: true, + Status: QuotaStatusActive, + } +} + +func isEstimatedFreeBillingProfile(billing *accountdomain.Billing) bool { + return billing != nil && (billing.HasFreeProfileSignal() || billing.HasInferredFreeProfileSignal()) +} + +// StartDeviceLogin 启动短期 Device OAuth,会话只保存在有界运行态存储中。 +func (s *Service) StartDeviceLogin(ctx context.Context) (DeviceStartResult, error) { + adapter, ok := s.providers.DeviceOAuth(accountdomain.ProviderBuild) + if !ok { + return DeviceStartResult{}, fmt.Errorf("CLI Provider 未注册") + } + authorization, err := adapter.StartDeviceAuthorization(ctx) + if err != nil { + return DeviceStartResult{}, err + } + sessionID, err := security.NewOpaqueToken(18) + if err != nil { + return DeviceStartResult{}, err + } + now := time.Now().UTC() + session := accountdomain.DeviceSession{ID: sessionID, DeviceCode: authorization.DeviceCode, UserCode: authorization.UserCode, VerificationURI: authorization.VerificationURI, VerificationURIComplete: authorization.VerificationURIComplete, Interval: authorization.Interval, NextPollAt: now.Add(authorization.Interval), ExpiresAt: now.Add(authorization.ExpiresIn)} + if err := s.deviceSessions.Create(ctx, session); err != nil { + return DeviceStartResult{}, err + } + return DeviceStartResult{SessionID: sessionID, UserCode: session.UserCode, VerificationURI: session.VerificationURI, VerificationURIComplete: session.VerificationURIComplete, Interval: session.Interval, ExpiresAt: session.ExpiresAt}, nil +} + +// PollDeviceLogin 执行一次上游轮询,成功后立即加密并写入账号仓储。 +func (s *Service) PollDeviceLogin(ctx context.Context, sessionID string) (View, error) { + now := time.Now().UTC() + session, err := s.deviceSessions.Get(ctx, sessionID, now) + if err != nil { + return View{}, ErrDeviceDenied + } + if now.Before(session.NextPollAt) { + return View{}, ErrDeviceSlowDown + } + adapter, ok := s.providers.DeviceOAuth(accountdomain.ProviderBuild) + if !ok { + return View{}, fmt.Errorf("CLI Provider 未注册") + } + seed, err := adapter.PollDeviceAuthorization(ctx, session.DeviceCode) + session.NextPollAt = now.Add(session.Interval) + _ = s.deviceSessions.Update(ctx, session) + if errors.Is(err, provider.ErrAuthorizationPending) { + return View{}, ErrDevicePending + } + if errors.Is(err, provider.ErrSlowDown) { + session.Interval += 5 * time.Second + session.NextPollAt = now.Add(session.Interval) + _ = s.deviceSessions.Update(ctx, session) + return View{}, ErrDeviceSlowDown + } + if errors.Is(err, provider.ErrAuthorizationDenied) { + _ = s.deviceSessions.Delete(ctx, sessionID) + return View{}, ErrDeviceDenied + } + if err != nil { + return View{}, err + } + value, _, err := s.persistSeed(ctx, seed) + if err != nil { + return View{}, err + } + s.reconcileProviderLinksBestEffort(ctx, value.ID) + _ = s.deviceSessions.Delete(ctx, sessionID) + return s.Get(ctx, value.ID) +} + +// ImportCredentials 导入用户上传的 OAuth 账号凭据。 +func (s *Service) ImportCredentials(ctx context.Context, data []byte) (ImportResult, error) { + return s.ImportCredentialsWithObserver(ctx, data, nil) +} + +func (s *Service) ImportCredentialsWithObserver(ctx context.Context, data []byte, observer ImportedAccountObserver) (ImportResult, error) { + return s.ImportCredentialsWithProgress(ctx, data, observer, nil) +} + +// ImportCredentialsWithProgress 导入 Build 凭据并报告已写入流水线的账号数。 +func (s *Service) ImportCredentialsWithProgress(ctx context.Context, data []byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + return s.ImportCredentialDocumentsWithProgress(ctx, [][]byte{data}, observer, progress) +} + +// ImportCredentialDocumentsWithProgress 合并解析多个 Build 凭据文件,并作为一个批次写入和同步。 +func (s *Service) ImportCredentialDocumentsWithProgress(ctx context.Context, documents [][]byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + adapter, ok := s.providers.CredentialCodec(accountdomain.ProviderBuild) + if !ok { + return ImportResult{}, fmt.Errorf("CLI Provider 未注册") + } + return s.importCredentialDocumentsWithProgress(ctx, adapter, documents, observer, progress) +} + +// ImportWebCredentials 导入版本化或旧号池格式的 Grok Web SSO 凭据。 +func (s *Service) ImportWebCredentials(ctx context.Context, data []byte) (ImportResult, error) { + return s.ImportWebCredentialsWithObserver(ctx, data, nil) +} + +func (s *Service) ImportWebCredentialsWithObserver(ctx context.Context, data []byte, observer ImportedAccountObserver) (ImportResult, error) { + return s.ImportWebCredentialsWithProgress(ctx, data, observer, nil) +} + +// ImportWebCredentialsWithProgress 导入 Web 凭据并报告已写入流水线的账号数。 +func (s *Service) ImportWebCredentialsWithProgress(ctx context.Context, data []byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + return s.ImportWebCredentialDocumentsWithProgress(ctx, [][]byte{data}, observer, progress) +} + +// ImportWebCredentialDocumentsWithProgress 合并解析多个 Web JSON 或 SSO 文本文件,并作为一个批次写入和同步。 +func (s *Service) ImportWebCredentialDocumentsWithProgress(ctx context.Context, documents [][]byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + adapter, ok := s.providers.CredentialCodec(accountdomain.ProviderWeb) + if !ok { + return ImportResult{}, fmt.Errorf("Grok Web Provider 未注册") + } + return s.importCredentialDocumentsWithProgress(ctx, adapter, documents, observer, progress) +} + +func (s *Service) ImportConsoleCredentials(ctx context.Context, data []byte) (ImportResult, error) { + return s.ImportConsoleCredentialsWithObserver(ctx, data, nil) +} + +func (s *Service) ImportConsoleCredentialsWithObserver(ctx context.Context, data []byte, observer ImportedAccountObserver) (ImportResult, error) { + return s.ImportConsoleCredentialsWithProgress(ctx, data, observer, nil) +} + +func (s *Service) ImportConsoleCredentialsWithProgress(ctx context.Context, data []byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + return s.ImportConsoleCredentialDocumentsWithProgress(ctx, [][]byte{data}, observer, progress) +} + +func (s *Service) ImportConsoleCredentialDocumentsWithProgress(ctx context.Context, documents [][]byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + adapter, ok := s.providers.CredentialCodec(accountdomain.ProviderConsole) + if !ok { + return ImportResult{}, fmt.Errorf("Grok Console Provider 未注册") + } + return s.importCredentialDocumentsWithProgress(ctx, adapter, documents, observer, progress) +} + +func (s *Service) importCredentialDocumentsWithProgress(ctx context.Context, adapter provider.CredentialCodecAdapter, documents [][]byte, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + if len(documents) == 0 { + return ImportResult{}, fmt.Errorf("%w: 没有可导入的账号文件", ErrInvalidImport) + } + seeds := make([]provider.CredentialSeed, 0) + seen := make(map[string]struct{}) + parsedAccounts := 0 + for index, document := range documents { + values, err := adapter.ParseImportedCredentials(document) + if err != nil { + if errors.Is(err, provider.ErrCredentialLimit) { + return ImportResult{}, fmt.Errorf("%w: 单次最多导入 %d 个账号", ErrImportLimit, maxCredentialImportAccounts) + } + return ImportResult{}, fmt.Errorf("%w: 第 %d 个文件: %v", ErrInvalidImport, index+1, err) + } + parsedAccounts += len(values) + if parsedAccounts > maxCredentialImportAccounts { + return ImportResult{}, fmt.Errorf("%w: 单次最多导入 %d 个账号", ErrImportLimit, maxCredentialImportAccounts) + } + for _, value := range values { + if value.SourceKey != "" { + key := string(value.Provider) + "\x00" + value.SourceKey + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + } + seeds = append(seeds, value) + } + } + return s.persistImportedSeeds(ctx, seeds, observer, progress) +} + +func (s *Service) persistImportedSeeds(ctx context.Context, seeds []provider.CredentialSeed, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + result := ImportResult{AccountIDs: make([]uint64, 0, len(seeds))} + if progress != nil { + if err := progress(0, len(seeds)); err != nil { + return ImportResult{}, err + } + } + completed := 0 + for start := 0; start < len(seeds); start += credentialImportChunkSize { + end := min(start+credentialImportChunkSize, len(seeds)) + values := make([]accountdomain.Credential, 0, end-start) + for _, seed := range seeds[start:end] { + value, err := s.credentialFromSeed(seed) + if err != nil { + return ImportResult{}, err + } + values = append(values, value) + } + stored, err := s.accounts.UpsertManyByIdentity(ctx, values) + if err != nil { + return ImportResult{}, err + } + for _, value := range stored { + result.AccountIDs = append(result.AccountIDs, value.ID) + s.reconcileProviderLinksBestEffort(ctx, value.ID) + if observer != nil { + if err := observer(value.ID); err != nil { + return ImportResult{}, err + } + } + completed++ + if progress != nil { + if err := progress(completed, len(seeds)); err != nil { + return ImportResult{}, err + } + } + if value.Created { + result.Created++ + } else { + result.Updated++ + } + } + } + s.WakeCredentialRefresh() + return result, nil +} + +// SyncWebAccountsToConsoleWithProgress 使用 Web 账号的同一份 SSO 创建或更新 Console 账号。 +func (s *Service) SyncWebAccountsToConsoleWithProgress(ctx context.Context, ids []uint64, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + return s.SyncWebAccountsToConsoleWithStrategy(ctx, ids, WebConsoleSyncAll, observer, progress) +} + +func (s *Service) SyncWebAccountsToConsoleWithStrategy(ctx context.Context, ids []uint64, strategy WebConsoleSyncStrategy, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + if strategy != WebConsoleSyncAll && strategy != WebConsoleSyncMissing { + return ImportResult{}, invalidInput("Grok Web 到 Console 同步策略无效") + } + ids, err := normalizeIDs(ids, maxWebConsoleSyncAccounts) + if err != nil { + return ImportResult{}, err + } + if strategy == WebConsoleSyncMissing { + values, err := s.accounts.ListMissingConsoleSyncAccounts(ctx, ids) + if err != nil { + return ImportResult{}, mapRepositoryError(err) + } + result, err := s.syncWebCredentialsToConsole(ctx, values, observer, progress) + result.Skipped = len(ids) - len(values) + return result, err + } + values := make([]accountdomain.Credential, 0, len(ids)) + for _, id := range ids { + value, getErr := s.accounts.Get(ctx, id) + if getErr != nil { + return ImportResult{}, mapRepositoryError(getErr) + } + values = append(values, value) + } + return s.syncWebCredentialsToConsole(ctx, values, observer, progress) +} + +// SyncAllWebAccountsToConsoleWithProgress 同步完整 Web 号池,避免前端分页遗漏账号。 +func (s *Service) SyncAllWebAccountsToConsoleWithProgress(ctx context.Context, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + return s.SyncAllWebAccountsToConsoleWithStrategy(ctx, WebConsoleSyncAll, observer, progress) +} + +func (s *Service) SyncAllWebAccountsToConsoleWithStrategy(ctx context.Context, strategy WebConsoleSyncStrategy, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + if strategy != WebConsoleSyncAll && strategy != WebConsoleSyncMissing { + return ImportResult{}, invalidInput("Grok Web 到 Console 同步策略无效") + } + batchSize := accountTaskBatchSize + result := ImportResult{AccountIDs: make([]uint64, 0)} + var afterID uint64 + completed := 0 + total := 0 + initialized := false + for { + var ( + values []accountdomain.Credential + count int64 + skipped int64 + err error + ) + if strategy == WebConsoleSyncMissing { + values, count, skipped, err = s.accounts.ListMissingConsoleSyncBatch(ctx, afterID, batchSize) + } else { + values, count, err = s.accounts.ListProviderAccountBatch(ctx, accountdomain.ProviderWeb, afterID, batchSize) + } + if err != nil { + return result, err + } + if !initialized { + total = int(count) + result.Skipped = int(skipped) + initialized = true + if progress != nil { + if err := progress(0, total); err != nil { + return result, err + } + } + } + if len(values) == 0 { + return result, nil + } + current, err := s.syncWebCredentialsToConsole(ctx, values, observer, offsetBatchProgress(progress, completed, total)) + result.Created += current.Created + result.Updated += current.Updated + result.AccountIDs = append(result.AccountIDs, current.AccountIDs...) + if err != nil { + return result, err + } + completed += len(values) + afterID = values[len(values)-1].ID + if len(values) < batchSize { + return result, nil + } + } +} + +func (s *Service) syncWebCredentialsToConsole(ctx context.Context, values []accountdomain.Credential, observer ImportedAccountObserver, progress BatchProgressObserver) (ImportResult, error) { + adapter, ok := s.providers.CredentialCodec(accountdomain.ProviderConsole) + if !ok { + return ImportResult{}, fmt.Errorf("Grok Console Provider 未注册") + } + seeds := make([]provider.CredentialSeed, 0, len(values)) + for _, value := range values { + if value.Provider != accountdomain.ProviderWeb || value.AuthType != accountdomain.AuthTypeSSO { + return ImportResult{}, fmt.Errorf("%w: 仅 Grok Web SSO 账号支持同步到 Console", ErrUnsupported) + } + token, err := s.cipher.Decrypt(value.EncryptedAccessToken) + if err != nil { + return ImportResult{}, fmt.Errorf("解密 Grok Web SSO: %w", err) + } + parsed, err := adapter.ParseImportedCredentials([]byte(token)) + if err != nil { + return ImportResult{}, fmt.Errorf("生成 Grok Console SSO 凭据: %w", err) + } + if len(parsed) != 1 { + return ImportResult{}, fmt.Errorf("生成 Grok Console SSO 凭据: 预期 1 个账号,实际 %d 个", len(parsed)) + } + seed := parsed[0] + seed.Provider = accountdomain.ProviderConsole + seed.AuthType = accountdomain.AuthTypeSSO + seed.Name = webConsoleAccountName(value.Name, seed.Name) + if strings.TrimSpace(value.EncryptedCloudflareCookie) != "" { + cookies, decryptErr := s.cipher.Decrypt(value.EncryptedCloudflareCookie) + if decryptErr != nil { + return ImportResult{}, fmt.Errorf("解密 Grok Web Cloudflare Cookie: %w", decryptErr) + } + seed.CloudflareCookies = cookies + } + seeds = append(seeds, seed) + } + return s.persistImportedSeeds(ctx, seeds, observer, progress) +} + +func webConsoleAccountName(webName, fallback string) string { + name := strings.TrimSpace(webName) + if name == "" { + return fallback + } + if suffix, ok := strings.CutPrefix(name, "Grok Web "); ok { + return "Grok Console " + suffix + } + return name +} + +// ConvertWebAccountsToBuild 使用 Web SSO 自动完成 xAI Device Flow,并建立唯一的 Web/Build 账号关联。 +func (s *Service) ConvertWebAccountsToBuild(ctx context.Context, ids []uint64) (BuildConversionResult, error) { + return s.ConvertWebAccountsToBuildWithStrategy(ctx, ids, BuildConversionMissing, nil, nil) +} + +func (s *Service) ConvertWebAccountsToBuildWithObserver(ctx context.Context, ids []uint64, observer ImportedAccountObserver) (BuildConversionResult, error) { + return s.ConvertWebAccountsToBuildWithStrategy(ctx, ids, BuildConversionMissing, observer, nil) +} + +// ConvertWebAccountsToBuildWithProgress 转换指定账号,并向调用方报告真实完成数。 +func (s *Service) ConvertWebAccountsToBuildWithProgress(ctx context.Context, ids []uint64, observer ImportedAccountObserver, progress BatchProgressObserver) (BuildConversionResult, error) { + return s.ConvertWebAccountsToBuildWithStrategy(ctx, ids, BuildConversionMissing, observer, progress) +} + +func (s *Service) ConvertWebAccountsToBuildWithStrategy(ctx context.Context, ids []uint64, strategy BuildConversionStrategy, observer ImportedAccountObserver, progress BatchProgressObserver) (BuildConversionResult, error) { + if strategy != BuildConversionAll && strategy != BuildConversionMissing { + return BuildConversionResult{}, invalidInput("Grok Web 到 Build 转换策略无效") + } + ids, err := normalizeIDs(ids, maxBuildConversionAccounts) + if err != nil { + return BuildConversionResult{}, err + } + prefilteredSkipped := 0 + if strategy == BuildConversionMissing { + candidates, err := s.accounts.FilterMissingBuildConversionIDs(ctx, ids) + if err != nil { + return BuildConversionResult{}, mapRepositoryError(err) + } + prefilteredSkipped = len(ids) - len(candidates) + ids = candidates + } + result, err := s.convertWebAccountsToBuild(ctx, ids, strategy, observer, progress) + result.Skipped += prefilteredSkipped + return result, err +} + +// ConvertAllWebAccountsToBuild 转换全部尚未建立 Build 关联的 Grok Web 账号。 +func (s *Service) ConvertAllWebAccountsToBuild(ctx context.Context) (BuildConversionResult, error) { + return s.ConvertAllWebAccountsToBuildWithStrategy(ctx, BuildConversionMissing, nil, nil) +} + +func (s *Service) ConvertAllWebAccountsToBuildWithObserver(ctx context.Context, observer ImportedAccountObserver) (BuildConversionResult, error) { + return s.ConvertAllWebAccountsToBuildWithStrategy(ctx, BuildConversionMissing, observer, nil) +} + +// ConvertAllWebAccountsToBuildWithProgress 转换完整未关联号池,并向调用方报告真实完成数。 +func (s *Service) ConvertAllWebAccountsToBuildWithProgress(ctx context.Context, observer ImportedAccountObserver, progress BatchProgressObserver) (BuildConversionResult, error) { + return s.ConvertAllWebAccountsToBuildWithStrategy(ctx, BuildConversionMissing, observer, progress) +} + +func (s *Service) ConvertAllWebAccountsToBuildWithStrategy(ctx context.Context, strategy BuildConversionStrategy, observer ImportedAccountObserver, progress BatchProgressObserver) (BuildConversionResult, error) { + if strategy != BuildConversionAll && strategy != BuildConversionMissing { + return BuildConversionResult{}, invalidInput("Grok Web 到 Build 转换策略无效") + } + batchSize := accountTaskBatchSize + result := BuildConversionResult{BuildAccountIDs: make([]uint64, 0)} + seenBuildIDs := make(map[uint64]struct{}) + var observed sync.Map + batchObserver := observer + if observer != nil { + batchObserver = func(accountID uint64) error { + if _, loaded := observed.LoadOrStore(accountID, struct{}{}); loaded { + return nil + } + return observer(accountID) + } + } + var afterID uint64 + completed := 0 + total := 0 + initialized := false + for { + var ( + ids []uint64 + count int64 + err error + ) + if strategy == BuildConversionMissing { + ids, count, err = s.accounts.ListUnlinkedWebAccountIDs(ctx, afterID, batchSize) + } else { + var values []accountdomain.Credential + values, count, err = s.accounts.ListProviderAccountBatch(ctx, accountdomain.ProviderWeb, afterID, batchSize) + ids = make([]uint64, 0, len(values)) + for _, value := range values { + ids = append(ids, value.ID) + } + } + if err != nil { + return result, err + } + if !initialized { + total = int(count) + initialized = true + if progress != nil { + if err := progress(0, total); err != nil { + return result, err + } + } + } + if len(ids) == 0 { + return result, nil + } + current, err := s.convertWebAccountsToBuild(ctx, ids, strategy, batchObserver, offsetBatchProgress(progress, completed, total)) + result.Created += current.Created + result.Linked += current.Linked + result.Skipped += current.Skipped + result.Failed += current.Failed + for _, buildID := range current.BuildAccountIDs { + if _, exists := seenBuildIDs[buildID]; exists { + continue + } + seenBuildIDs[buildID] = struct{}{} + result.BuildAccountIDs = append(result.BuildAccountIDs, buildID) + } + if err != nil { + return result, err + } + completed += len(ids) + afterID = ids[len(ids)-1] + if len(ids) < batchSize { + return result, nil + } + } +} + +func offsetBatchProgress(progress BatchProgressObserver, offset, total int) BatchProgressObserver { + if progress == nil { + return nil + } + return func(completed, _ int) error { + if completed == 0 { + return nil + } + return progress(offset+completed, total) + } +} + +func (s *Service) convertWebAccountsToBuild(ctx context.Context, ids []uint64, strategy BuildConversionStrategy, observer ImportedAccountObserver, progress BatchProgressObserver) (BuildConversionResult, error) { + if progress != nil { + if err := progress(0, len(ids)); err != nil { + return BuildConversionResult{}, err + } + } + type outcome struct { + accountID uint64 + buildID uint64 + created bool + skipped bool + err error + } + var observed sync.Map + var observerMu sync.Mutex + var observerErr error + completed := 0 + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + results, summary, runErr := batch.MapObserved(runCtx, ids, batch.Options{Workers: s.conversionPool.Limit(), Pool: s.conversionPool}, func(workCtx context.Context, id uint64) (outcome, error) { + buildID, created, skipped, convertErr := s.convertWebAccountToBuild(workCtx, id, strategy) + return outcome{accountID: id, buildID: buildID, created: created, skipped: skipped, err: convertErr}, nil + }, func(_ int, execution batch.Result[outcome]) { + observerMu.Lock() + defer observerMu.Unlock() + defer func() { + completed++ + if progress != nil { + if err := progress(completed, len(ids)); err != nil && observerErr == nil { + observerErr = err + cancel() + } + } + }() + item := execution.Value + if execution.Err != nil || item.err != nil || item.skipped || observer == nil { + return + } + if _, loaded := observed.LoadOrStore(item.buildID, struct{}{}); loaded { + return + } + if err := observer(item.buildID); err != nil { + if observerErr == nil { + observerErr = err + cancel() + } + } + }) + s.logBatchSummary("web_to_build", s.conversionPool, summary, runErr) + result := BuildConversionResult{BuildAccountIDs: make([]uint64, 0, len(ids))} + seen := make(map[uint64]struct{}, len(ids)) + for index, execution := range results { + item := execution.Value + if execution.Err != nil { + item.accountID = ids[index] + item.err = execution.Err + } + if item.err != nil { + result.Failed++ + s.logger.Warn("web_account_build_conversion_failed", "account_id", item.accountID, "error", item.err) + continue + } + if item.skipped { + result.Skipped++ + continue + } + if item.created { + result.Created++ + } else { + result.Linked++ + } + if _, ok := seen[item.buildID]; !ok { + seen[item.buildID] = struct{}{} + result.BuildAccountIDs = append(result.BuildAccountIDs, item.buildID) + } + } + if runErr != nil { + return result, runErr + } + if observerErr != nil { + return result, observerErr + } + return result, nil +} + +func (s *Service) convertWebAccountToBuild(ctx context.Context, id uint64, strategy BuildConversionStrategy) (uint64, bool, bool, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return 0, false, false, mapRepositoryError(err) + } + if value.Provider != accountdomain.ProviderWeb || value.AuthType != accountdomain.AuthTypeSSO { + return 0, false, false, ErrUnsupported + } + if value.LinkedAccountID != 0 && strategy == BuildConversionMissing { + return value.LinkedAccountID, false, true, nil + } + release, acquired, err := s.refreshLock.Acquire(ctx, "web-build-conversion:"+strconv.FormatUint(id, 10), 2*time.Minute) + if err != nil { + return 0, false, false, err + } + if !acquired { + return 0, false, false, ErrConversionBusy + } + defer release() + value, err = s.accounts.Get(ctx, id) + if err != nil { + return 0, false, false, mapRepositoryError(err) + } + if value.LinkedAccountID != 0 && strategy == BuildConversionMissing { + return value.LinkedAccountID, false, true, nil + } + linkedBuildSourceKey := "" + if value.LinkedAccountID != 0 { + linkedBuild, getErr := s.accounts.Get(ctx, value.LinkedAccountID) + if getErr != nil { + return 0, false, false, mapRepositoryError(getErr) + } + if linkedBuild.Provider != accountdomain.ProviderBuild || strings.TrimSpace(linkedBuild.SourceKey) == "" { + return 0, false, false, fmt.Errorf("已关联 Grok Build 账号身份无效") + } + linkedBuildSourceKey = linkedBuild.SourceKey + } + converter, ok := s.providers.BuildConverter(accountdomain.ProviderWeb) + if !ok { + return 0, false, false, fmt.Errorf("Grok Web SSO 转换能力未注册") + } + seed, err := converter.ConvertToBuild(ctx, value) + if err != nil { + if errors.Is(err, provider.ErrUnauthorized) { + err = errors.Join(err, s.markSSOCredentialRejected(ctx, value, "Grok Web SSO credential rejected")) + } + return 0, false, false, err + } + seed.Provider = accountdomain.ProviderBuild + seed.AuthType = accountdomain.AuthTypeOAuth + if linkedBuildSourceKey != "" { + seed.SourceKey = linkedBuildSourceKey + } + buildAccount, created, err := s.persistSeed(ctx, seed) + if err != nil { + return 0, false, false, err + } + if value.LinkedAccountID != 0 && buildAccount.ID != value.LinkedAccountID { + return 0, false, false, fmt.Errorf("重新转换后的 Grok Build 账号身份不一致") + } + if err := s.accounts.LinkWebToBuild(ctx, id, buildAccount.ID); err != nil { + return 0, false, false, mapRepositoryError(err) + } + return buildAccount.ID, created, false, nil +} + +// ExportCredentials 保留 Grok Build 默认导出语义,供旧调用方兼容。 +func (s *Service) ExportCredentials(ctx context.Context) (ExportResult, error) { + return s.ExportProviderCredentials(ctx, accountdomain.ProviderBuild) +} + +// ExportProviderCredentials 导出可由对应 Provider 导入接口重新读取的凭据文档。 +func (s *Service) ExportProviderCredentials(ctx context.Context, providerValue accountdomain.Provider) (ExportResult, error) { + return s.exportProviderCredentials(ctx, providerValue, repository.AccountListQuery{ + Page: repository.PageQuery{Limit: maxCredentialExportAccounts + 1}, + Filter: repository.AccountListFilter{Provider: string(providerValue), Now: s.now()}, + }, true, 0) +} + +// ExportProviderCredentialsCursor exports a stable provider batch bounded by +// the maximum account ID captured by the first request. +func (s *Service) ExportProviderCredentialsCursor(ctx context.Context, providerValue accountdomain.Provider, afterID, snapshotMaxID uint64, limit int) (ExportPageResult, error) { + if limit < 1 || limit > maxCredentialExportAccounts { + return ExportPageResult{}, invalidInput("单批导出数量必须在 1 到 10000 之间") + } + if afterID > 0 && snapshotMaxID == 0 { + return ExportPageResult{}, invalidInput("继续导出时必须提供快照上界") + } + if snapshotMaxID > 0 && afterID > snapshotMaxID { + return ExportPageResult{}, invalidInput("导出游标不能超过快照上界") + } + if !providerValue.IsValid() { + return ExportPageResult{}, invalidInput("账号来源无效") + } + if snapshotMaxID == 0 { + values, _, err := s.accounts.List(ctx, repository.AccountListQuery{ + Page: repository.PageQuery{Limit: 1, Sort: repository.SortQuery{Field: "id", Direction: repository.SortDescending}}, + Filter: repository.AccountListFilter{Provider: string(providerValue), Now: s.now()}, + }) + if err != nil { + return ExportPageResult{}, err + } + if len(values) == 0 { + result, exportErr := s.marshalProviderCredentials(providerValue, nil) + return ExportPageResult{ExportResult: result}, exportErr + } + snapshotMaxID = values[0].ID + } + values, total, err := s.accounts.List(ctx, repository.AccountListQuery{ + Page: repository.PageQuery{Limit: limit, Sort: repository.SortQuery{Field: "id", Direction: repository.SortAscending}}, + Filter: repository.AccountListFilter{ + Provider: string(providerValue), AfterID: afterID, ThroughID: snapshotMaxID, Now: s.now(), + }, + }) + if err != nil { + return ExportPageResult{}, err + } + result, err := s.marshalProviderCredentials(providerValue, values) + if err != nil { + return ExportPageResult{}, err + } + nextID := afterID + if len(values) > 0 { + nextID = values[len(values)-1].ID + } + return ExportPageResult{ + ExportResult: result, NextID: nextID, SnapshotMaxID: snapshotMaxID, HasMore: total > int64(len(values)), + }, nil +} + +// ExportProviderCredentialsByIDs 只导出管理端明确选择且属于指定 Provider 的账号。 +func (s *Service) ExportProviderCredentialsByIDs(ctx context.Context, providerValue accountdomain.Provider, ids []uint64) (ExportResult, error) { + values, err := normalizeIDs(ids, maxCredentialExportAccounts) + if err != nil { + return ExportResult{}, err + } + return s.exportProviderCredentials(ctx, providerValue, repository.AccountListQuery{ + Page: repository.PageQuery{Limit: len(values)}, + Filter: repository.AccountListFilter{ + Provider: string(providerValue), AccountIDs: values, RestrictIDs: true, Now: s.now(), + }, + }, false, len(values)) +} + +func (s *Service) exportProviderCredentials(ctx context.Context, providerValue accountdomain.Provider, query repository.AccountListQuery, enforceTotalLimit bool, expectedCount int) (ExportResult, error) { + if !providerValue.IsValid() { + return ExportResult{}, invalidInput("账号来源无效") + } + values, total, err := s.accounts.List(ctx, query) + if err != nil { + return ExportResult{}, err + } + if enforceTotalLimit && total > maxCredentialExportAccounts { + return ExportResult{}, fmt.Errorf("%w: 单次最多导出 10000 个账号", ErrExportLimit) + } + if err := validateCredentialExportCount(expectedCount, total, len(values)); err != nil { + return ExportResult{}, err + } + return s.marshalProviderCredentials(providerValue, values) +} + +func validateCredentialExportCount(expected int, total int64, actual int) error { + if expected > 0 && (total != int64(expected) || actual != expected) { + return invalidInput("所选账号包含不存在或不属于当前号池的账号") + } + return nil +} + +func (s *Service) marshalProviderCredentials(providerValue accountdomain.Provider, values []accountdomain.Credential) (ExportResult, error) { + if !providerValue.IsValid() { + return ExportResult{}, invalidInput("账号来源无效") + } + if s.providers == nil { + return ExportResult{}, fmt.Errorf("Provider 注册表未初始化") + } + adapter, ok := s.providers.CredentialCodec(providerValue) + if !ok { + return ExportResult{}, fmt.Errorf("Provider %s 不支持凭据导出", providerValue) + } + var err error + seeds := make([]provider.CredentialSeed, 0, len(values)) + for _, value := range values { + if value.Provider != providerValue { + continue + } + accessToken := "" + if value.EncryptedAccessToken != "" { + accessToken, err = s.cipher.Decrypt(value.EncryptedAccessToken) + if err != nil { + return ExportResult{}, fmt.Errorf("解密账号 %d access token: %w", value.ID, err) + } + } + refreshToken := "" + if value.EncryptedRefreshToken != "" { + refreshToken, err = s.cipher.Decrypt(value.EncryptedRefreshToken) + if err != nil { + return ExportResult{}, fmt.Errorf("解密账号 %d refresh token: %w", value.ID, err) + } + } + cloudflareCookies := "" + if value.EncryptedCloudflareCookie != "" { + cloudflareCookies, err = s.cipher.Decrypt(value.EncryptedCloudflareCookie) + if err != nil { + return ExportResult{}, fmt.Errorf("解密账号 %d Cloudflare Cookie: %w", value.ID, err) + } + } + if accessToken == "" && refreshToken == "" { + return ExportResult{}, fmt.Errorf("账号 %d 没有可导出的凭据", value.ID) + } + seeds = append(seeds, provider.CredentialSeed{ + Provider: value.Provider, AuthType: value.AuthType, WebTier: value.WebTier, + Name: value.Name, Email: value.Email, UserID: value.UserID, TeamID: value.TeamID, + OIDCClientID: value.OIDCClientID, AccessToken: accessToken, RefreshToken: refreshToken, + CloudflareCookies: cloudflareCookies, ExpiresAt: value.ExpiresAt, + WebNSFWEnabledAt: value.WebNSFWEnabledAt, WebTermsAcceptedAt: value.WebTermsAcceptedAt, + WebTermsAcceptedVersion: value.WebTermsAcceptedVersion, WebBirthDateSetAt: value.WebBirthDateSetAt, + }) + } + data, err := adapter.MarshalCredentials(seeds) + if err != nil { + return ExportResult{}, err + } + return ExportResult{Data: data, Count: len(seeds)}, nil +} + +func (s *Service) Update(ctx context.Context, id uint64, input UpdateInput) (View, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return View{}, mapRepositoryError(err) + } + if input.Name != nil { + value.Name = strings.TrimSpace(*input.Name) + if value.Name == "" { + return View{}, invalidInput("账号名称不能为空") + } + } + if input.Enabled != nil { + value.Enabled = *input.Enabled + } + if input.Priority != nil { + value.Priority = *input.Priority + } + if input.MaxConcurrent != nil { + if *input.MaxConcurrent < 1 || *input.MaxConcurrent > accountdomain.MaxConcurrent { + return View{}, invalidInput("maxConcurrent 必须在 1 到 256 之间") + } + value.MaxConcurrent = *input.MaxConcurrent + } + if input.MinimumRemaining != nil { + if *input.MinimumRemaining < 0 { + return View{}, invalidInput("minimumRemaining 不能小于零") + } + value.MinimumRemaining = *input.MinimumRemaining + } + if input.ClearCloudflareCookies { + value.EncryptedCloudflareCookie = "" + } else if input.CloudflareCookies != nil { + if value.Provider == accountdomain.ProviderBuild { + return View{}, invalidInput("Grok Build 账号不使用 Cloudflare Cookie") + } + if len(*input.CloudflareCookies) > 16<<10 { + return View{}, invalidInput("Cloudflare Cookie 不能超过 16 KiB") + } + if strings.TrimSpace(*input.CloudflareCookies) != "" { + cookies := egressapp.SanitizeCloudflareCookies(*input.CloudflareCookies) + if cookies == "" { + return View{}, invalidInput("Cloudflare Cookie 中没有有效字段") + } + encrypted, encryptErr := s.cipher.Encrypt(cookies) + if encryptErr != nil { + return View{}, encryptErr + } + value.EncryptedCloudflareCookie = encrypted + } + } + if input.BuildSuperEntitled != nil { + if value.Provider != accountdomain.ProviderBuild { + return View{}, invalidInput("仅 Grok Build 账号支持设置 Build Super entitlement") + } + value.BuildSuperEntitled = *input.BuildSuperEntitled + } + if input.BuildRouteMode != nil { + if value.Provider != accountdomain.ProviderBuild { + return View{}, invalidInput("仅 Grok Build 账号支持设置上游地址") + } + if !input.BuildRouteMode.IsValid() { + return View{}, invalidInput("Build 上游地址必须是 auto、build 或 xai") + } + value.BuildRouteMode = *input.BuildRouteMode + } + updated, err := s.accounts.Update(ctx, value) + if err != nil { + return View{}, mapRepositoryError(err) + } + if !updated.Enabled && s.sticky != nil { + _ = s.sticky.DeleteByAccount(ctx, updated.ID) + } else if updated.Enabled && s.providers != nil && s.providers.SupportsCredentialRefresh(updated.Provider) { + s.WakeCredentialRefresh() + } + return s.Get(ctx, updated.ID) +} + +// MarkBuildAPIFallback 幂等写入 Build 账号 XAI 推理回退标记;失败不吞掉,调用方可重试。 +func (s *Service) MarkBuildAPIFallback(ctx context.Context, id uint64, enabled bool) error { + return mapRepositoryError(s.accounts.MarkBuildAPIFallback(ctx, id, enabled)) +} + +func (s *Service) Delete(ctx context.Context, id uint64) error { + // Single-account delete must preserve ErrNotFound when the root row is gone + // (BatchDeleteWithLinked/DeleteMany return deleted=0, nil for missing IDs). + result, err := s.DeleteWithLinked(ctx, accountdomain.Provider(""), id, nil) + if err != nil { + return err + } + if result.Deleted == 0 { + return ErrNotFound + } + return nil +} + +// DeleteWithLinked deletes one account and optional linked peers. +// A single delete is rejected if any account in the final group has an active video job. +func (s *Service) DeleteWithLinked(ctx context.Context, providerValue accountdomain.Provider, id uint64, targets []accountdomain.Provider) (AccountDeleteResult, error) { + if id == 0 { + return AccountDeleteResult{}, invalidInput("账号 ID 无效") + } + result, err := s.batchDeleteWithLinkedMode(ctx, providerValue, []uint64{id}, targets, false) + if err != nil { + return result, err + } + // Fail closed for the single-root API: missing root must not report success. + if result.Deleted == 0 { + return result, ErrNotFound + } + return result, nil +} + +// PreviewLinkedDelete returns root/linked counts for the delete confirmation UI. +func (s *Service) PreviewLinkedDelete(ctx context.Context, providerValue accountdomain.Provider, ids []uint64, targets []accountdomain.Provider) (repository.LinkedDeleteResolution, error) { + ids, err := normalizeBatchIDs(ids) + if err != nil { + return repository.LinkedDeleteResolution{}, err + } + if !providerValue.IsValid() { + return repository.LinkedDeleteResolution{}, invalidInput("账号来源无效") + } + resolution, err := s.accounts.ResolveLinkedDeleteIDs(ctx, providerValue, ids, targets) + if err != nil { + return repository.LinkedDeleteResolution{}, mapLinkedDeleteError(err) + } + return resolution, nil +} + +func (s *Service) MarkReauthRequired(ctx context.Context, id uint64, reason string) error { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + value.AuthStatus = accountdomain.AuthStatusReauthRequired + value.LastError = reason + if len(value.LastError) > 512 { + value.LastError = value.LastError[:512] + } + if _, err := s.accounts.Update(ctx, value); err != nil { + return mapRepositoryError(err) + } + if s.sticky != nil { + _ = s.sticky.DeleteByAccount(ctx, id) + } + return nil +} + +// markSSOCredentialRejected 在上游明确返回 401 后可靠持久化失效状态。 +// 状态写入不继承客户端取消,避免已经确认失效的账号因请求断开继续留在号池。 +func (s *Service) markSSOCredentialRejected(ctx context.Context, value accountdomain.Credential, reason string) error { + if value.AuthType != accountdomain.AuthTypeSSO { + return nil + } + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), credentialStateWriteTimeout) + defer cancel() + if err := s.MarkReauthRequired(writeCtx, value.ID, reason); err != nil { + s.logger.Error("account_reauth_required_write_failed", "account_id", value.ID, "provider", value.Provider, "error", err) + return err + } + return nil +} + +// EnsureCredential 在即将过期时刷新 token,同一账号并发请求只执行一次刷新。 +func (s *Service) EnsureCredential(ctx context.Context, value accountdomain.Credential, force bool) (accountdomain.Credential, error) { + return s.ensureCredential(ctx, value, ensureCredentialOptions{force: force}) +} + +type ensureCredentialOptions struct { + force bool + bypassCooldown bool + respectSchedule bool + retryPermanentOnce bool +} + +func (s *Service) ensureCredential(ctx context.Context, value accountdomain.Credential, options ensureCredentialOptions) (accountdomain.Credential, error) { + if s.providers == nil || !s.providers.SupportsCredentialRefresh(value.Provider) { + if options.force { + return accountdomain.Credential{}, ErrUnsupported + } + return value, nil + } + now := s.now() + if credential, err, handled := s.resolvePermanentRefreshFailure(ctx, value, now, options.force, options.retryPermanentOnce); handled { + return credential, err + } + if !options.force && value.ExpiresAt.IsZero() && value.EncryptedAccessToken != "" { + return value, nil + } + if !options.force && value.EncryptedAccessToken != "" && !value.ExpiresAt.IsZero() && now.Add(credentialRefreshAdvance).Before(value.ExpiresAt) { + return value, nil + } + refreshKey := strconv.FormatUint(value.ID, 10) + if options.respectSchedule { + refreshKey += ":scheduled" + } + if options.retryPermanentOnce { + refreshKey += ":manual-retry" + } + result, err, _ := s.refreshes.Do(refreshKey, func() (any, error) { + latest, err := s.accounts.Get(ctx, value.ID) + if err != nil { + return nil, err + } + currentTime := s.now() + if credential, err, handled := s.resolvePermanentRefreshFailure(ctx, latest, currentTime, options.force, options.retryPermanentOnce); handled { + if err != nil { + return nil, err + } + return credential, nil + } + if options.respectSchedule && latest.RefreshDueAt != nil && latest.RefreshDueAt.After(currentTime) { + return latest, nil + } + if options.force && latest.EncryptedAccessToken != "" && latest.EncryptedAccessToken != value.EncryptedAccessToken { + return latest, nil + } + if !options.force && latest.EncryptedAccessToken != "" && !latest.ExpiresAt.IsZero() && currentTime.Add(credentialRefreshAdvance).Before(latest.ExpiresAt) { + return latest, nil + } + if options.force && !options.bypassCooldown && s.credentialRefreshCoolingDown(latest, currentTime) { + return latest, nil + } + release, err := s.acquireRefreshLock(ctx, latest.ID) + if err != nil { + return nil, err + } + if release != nil { + defer release() + latest, err = s.accounts.Get(ctx, value.ID) + if err != nil { + return nil, err + } + currentTime = s.now() + if credential, err, handled := s.resolvePermanentRefreshFailure(ctx, latest, currentTime, options.force, options.retryPermanentOnce); handled { + if err != nil { + return nil, err + } + return credential, nil + } + if options.respectSchedule && latest.RefreshDueAt != nil && latest.RefreshDueAt.After(currentTime) { + return latest, nil + } + if options.force && !options.bypassCooldown && s.credentialRefreshCoolingDown(latest, currentTime) { + return latest, nil + } + if latest.EncryptedAccessToken != "" && latest.EncryptedAccessToken != value.EncryptedAccessToken { + return latest, nil + } + if !options.force && latest.EncryptedAccessToken != "" && !latest.ExpiresAt.IsZero() && currentTime.Add(credentialRefreshAdvance).Before(latest.ExpiresAt) { + return latest, nil + } + } + adapter, ok := s.providers.CredentialRefresh(latest.Provider) + if !ok { + return nil, fmt.Errorf("Provider %s 未注册", latest.Provider) + } + refreshed, err := adapter.RefreshCredential(ctx, latest) + if err != nil { + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), credentialRefreshStateTTL) + s.recordCredentialRefreshFailure(persistCtx, latest, err, !options.retryPermanentOnce) + cancel() + return nil, err + } + updated, err := s.accounts.UpdateTokens(ctx, latest.ID, refreshed.EncryptedAccessToken, refreshed.EncryptedRefreshToken, refreshed.ExpiresAt) + if err != nil { + return nil, err + } + s.invalidateBuildBotFlagCache() + s.markRefreshSuccess(latest.ID, currentTime) + s.WakeCredentialRefresh() + return updated, nil + }) + if err != nil { + return accountdomain.Credential{}, err + } + credential, ok := result.(accountdomain.Credential) + if !ok { + return accountdomain.Credential{}, fmt.Errorf("账号凭据刷新返回类型无效") + } + return credential, nil +} + +// acquireRefreshLock 在 Redis 模式下等待其他实例完成刷新,锁租约过期后可自动接管。 +func (s *Service) acquireRefreshLock(ctx context.Context, accountID uint64) (func(), error) { + if s.refreshLock == nil { + return nil, nil + } + key := "credential-refresh:" + strconv.FormatUint(accountID, 10) + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + release, acquired, err := s.refreshLock.Acquire(ctx, key, 2*time.Minute) + if err != nil { + return nil, err + } + if acquired { + return release, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +func (s *Service) RefreshToken(ctx context.Context, id uint64) (View, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return View{}, mapRepositoryError(err) + } + if _, err := s.ensureCredential(ctx, value, ensureCredentialOptions{force: true, bypassCooldown: true, retryPermanentOnce: true}); err != nil { + return View{}, err + } + return s.Get(ctx, id) +} + +func (s *Service) refreshCoolingDown(accountID uint64, now time.Time) bool { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + last := s.lastRefreshAt[accountID] + return !last.IsZero() && now.Sub(last) < forcedRefreshMinInterval +} + +func (s *Service) credentialRefreshCoolingDown(credential accountdomain.Credential, now time.Time) bool { + if credential.LastRefreshAt != nil { + age := now.Sub(*credential.LastRefreshAt) + if age >= 0 && age < forcedRefreshMinInterval { + return true + } + } + return s.refreshCoolingDown(credential.ID, now) +} + +func (s *Service) markRefreshSuccess(accountID uint64, now time.Time) { + s.refreshMu.Lock() + s.lastRefreshAt[accountID] = now + s.refreshMu.Unlock() +} + +func (s *Service) clearRefreshState(accountID uint64) { + s.refreshMu.Lock() + delete(s.lastRefreshAt, accountID) + s.refreshMu.Unlock() +} + +func (s *Service) recordCredentialRefreshFailure(ctx context.Context, credential accountdomain.Credential, refreshErr error, preservePermanent bool) { + if errors.Is(refreshErr, context.Canceled) || errors.Is(refreshErr, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.Canceled) { + return + } + failureCount := credential.RefreshFailureCount + 1 + errorCode := "oauth_transport_error" + errorMessage := "OAuth request failed" + errorStatus := 0 + errorResponse := "" + permanent := false + retryAfter := time.Duration(0) + var typed *provider.CredentialRefreshError + if errors.As(refreshErr, &typed) { + errorCode = strings.TrimSpace(typed.Code) + if errorCode == "" { + errorCode = "oauth_refresh_error" + } + errorStatus = typed.Status + permanent = typed.Permanent + retryAfter = typed.RetryAfter + if message := normalizeCredentialRefreshErrorMessage(typed.Message); message != "" { + errorMessage = message + } + errorResponse = normalizeCredentialRefreshErrorResponse(typed.Response) + } else if errors.Is(refreshErr, context.DeadlineExceeded) { + errorCode = "oauth_timeout" + errorMessage = "OAuth request timed out" + } + // 真正的 OAuth 永久失败(invalid_grant 等)只能由成功换 token 清除。 + // credential_decrypt_failed 是可恢复本地错误:不得被旧 permanent 粘住,也不得把本次可恢复失败抬升为永久。 + if permanent && isRecoverableRefreshErrorCode(errorCode) { + permanent = false + } + if preservePermanent && credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) && !isRecoverableRefreshErrorCode(errorCode) { + permanent = true + } + now := s.now() + retryAt := now.Add(credentialRefreshBackoff(credential.ID, failureCount, retryAfter)) + accessTokenAlive := credential.EncryptedAccessToken != "" && !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(now) + if permanent && accessTokenAlive { + // refresh token 已永久失效时,提前重试没有意义;到 access token 到期时再完成失效收敛。 + retryAt = credential.ExpiresAt + } else if permanent { + retryAt = now + } + if err := s.accounts.UpdateCredentialRefreshFailure(ctx, credential.ID, repository.CredentialRefreshFailure{ + Count: failureCount, RetryAt: retryAt, Status: errorStatus, Code: errorCode, + Message: errorMessage, Response: errorResponse, Permanent: permanent, + }); err != nil { + s.logger.Warn("credential_refresh_state_write_failed", "account_id", credential.ID, "error", err) + } + if permanent && accessTokenAlive { + s.logger.Warn("credential_refresh_permanent_but_token_alive", "account_id", credential.ID, "error_code", errorCode, "expires_at", credential.ExpiresAt, "retry_at", retryAt) + s.WakeCredentialRefresh() + return + } + if permanent { + if err := s.MarkReauthRequired(ctx, credential.ID, "OAuth refresh failed: "+errorCode); err != nil { + s.logger.Warn("credential_refresh_reauth_mark_failed", "account_id", credential.ID, "error", err) + } + return + } + s.logger.Warn("credential_refresh_deferred", "account_id", credential.ID, "failure_count", failureCount, "retry_at", retryAt, "error_code", errorCode) + s.WakeCredentialRefresh() +} + +func normalizeCredentialRefreshErrorMessage(value string) string { + value = strings.Map(func(char rune) rune { + switch char { + case '\r', '\n', '\t': + return ' ' + } + if char < 0x20 || char == 0x7f { + return -1 + } + return char + }, strings.TrimSpace(value)) + value = strings.Join(strings.Fields(value), " ") + runes := []rune(value) + if len(runes) > 512 { + value = string(runes[:511]) + "…" + } + return value +} + +func normalizeCredentialRefreshErrorResponse(value string) string { + value = strings.Map(func(char rune) rune { + if char < 0x20 || char == 0x7f { + return ' ' + } + return char + }, strings.TrimSpace(value)) + runes := []rune(value) + if len(runes) > 4096 { + value = string(runes[:4095]) + "…" + } + return value +} + +// resolvePermanentRefreshFailure 阻止自动链路再次请求已确认失效的 refresh token, +// 并在 access token 到期后收敛账号状态。管理员显式刷新可通过 +// retryPermanentOnce 绕过一次;credential_decrypt_failed 等可恢复本地错误不受阻断。 +func (s *Service) resolvePermanentRefreshFailure(ctx context.Context, credential accountdomain.Credential, now time.Time, force, retryPermanentOnce bool) (accountdomain.Credential, error, bool) { + if !credential.RefreshPermanent { + return accountdomain.Credential{}, nil, false + } + if isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) { + // 允许 force 或到期调度再次尝试解密/刷新;成功后会 clear permanent 标记。 + return accountdomain.Credential{}, nil, false + } + if retryPermanentOnce { + return accountdomain.Credential{}, nil, false + } + accessTokenAlive := credential.EncryptedAccessToken != "" && !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(now) + if accessTokenAlive && !force { + return credential, nil, true + } + if !accessTokenAlive { + if err := s.MarkReauthRequired(ctx, credential.ID, permanentRefreshExpiredReason); err != nil { + return accountdomain.Credential{}, err, true + } + } + if credential.LastRefreshErrorCode == "" { + return accountdomain.Credential{}, ErrCredentialRefreshPermanent, true + } + return accountdomain.Credential{}, fmt.Errorf("%w: %s", ErrCredentialRefreshPermanent, credential.LastRefreshErrorCode), true +} + +// isRecoverableRefreshErrorCode 标识“永久标记可被后续成功刷新清除”的本地/临时错误。 +func isRecoverableRefreshErrorCode(code string) bool { + switch strings.TrimSpace(code) { + case "credential_decrypt_failed": + return true + default: + return false + } +} + +func credentialRefreshBackoff(accountID uint64, failureCount int, retryAfter time.Duration) time.Duration { + delays := [...]time.Duration{30 * time.Second, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute} + index := max(0, min(failureCount-1, len(delays)-1)) + delay := delays[index] + if retryAfter > delay { + delay = min(retryAfter, 30*time.Minute) + } + return delay + time.Duration((accountID*37)%16)*time.Second +} + +func (s *Service) RefreshBilling(ctx context.Context, id uint64) (accountdomain.Billing, error) { + result, err, _ := s.billingSyncs.Do(strconv.FormatUint(id, 10), func() (any, error) { + return s.refreshBilling(ctx, id) + }) + if err != nil { + return accountdomain.Billing{}, err + } + billing, ok := result.(accountdomain.Billing) + if !ok { + return accountdomain.Billing{}, fmt.Errorf("额度同步返回类型无效") + } + return billing, nil +} + +func (s *Service) refreshBilling(ctx context.Context, id uint64) (accountdomain.Billing, error) { + value, billing, err := s.fetchAndSaveBilling(ctx, id) + if err != nil { + return accountdomain.Billing{}, err + } + if err := s.reconcilePaidQuotaRecovery(ctx, value, billing, false); err != nil { + return accountdomain.Billing{}, err + } + return billing, nil +} + +func (s *Service) fetchAndSaveBilling(ctx context.Context, id uint64) (accountdomain.Credential, accountdomain.Billing, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return accountdomain.Credential{}, accountdomain.Billing{}, mapRepositoryError(err) + } + value, err = s.EnsureCredential(ctx, value, false) + if err != nil { + return accountdomain.Credential{}, accountdomain.Billing{}, err + } + adapter, ok := s.providers.Billing(value.Provider) + if !ok { + return accountdomain.Credential{}, accountdomain.Billing{}, fmt.Errorf("Provider %s 未注册", value.Provider) + } + billing, err := adapter.GetBilling(ctx, value) + if err != nil { + return accountdomain.Credential{}, accountdomain.Billing{}, err + } + billing.AccountID = id + if err := s.accounts.SaveBilling(ctx, billing); err != nil { + return accountdomain.Credential{}, accountdomain.Billing{}, err + } + return value, billing, nil +} + +// ProbePaidQuota 在真实账期到期后执行一次 Billing 探测,不消耗模型额度。 +func (s *Service) ProbePaidQuota(ctx context.Context, value accountdomain.Credential) (bool, error) { + latest, billing, err := s.fetchAndSaveBilling(ctx, value.ID) + if err != nil { + now := time.Now().UTC() + next := now.Add(paidProbeRetryInterval) + _ = s.accounts.SaveQuotaRecovery(ctx, accountdomain.QuotaRecovery{AccountID: value.ID, Kind: accountdomain.QuotaRecoveryKindPaid, Status: accountdomain.QuotaRecoveryStatusExhausted, NextProbeAt: &next, UpdatedAt: now}) + return false, err + } + if err := s.reconcilePaidQuotaRecovery(ctx, latest, billing, true); err != nil { + return false, err + } + return !billing.IsExhausted(latest.MinimumRemaining), nil +} + +func (s *Service) reconcilePaidQuotaRecovery(ctx context.Context, credential accountdomain.Credential, billing accountdomain.Billing, afterProbe bool) error { + if !billing.IsPaid() || !billing.IsExhausted(credential.MinimumRemaining) { + recovery, err := s.accounts.GetQuotaRecovery(ctx, credential.ID) + if errors.Is(err, repository.ErrNotFound) || (err == nil && recovery.Kind != accountdomain.QuotaRecoveryKindPaid) { + return nil + } + if err != nil { + return err + } + return s.accounts.ClearQuotaRecovery(ctx, credential.ID) + } + periodEnd, ok := billing.PeriodEnd() + if !ok { + return nil + } + now := time.Now().UTC() + next := periodEnd + if !next.After(now) && afterProbe { + next = now.Add(paidProbeRetryInterval) + } + exhaustedAt := now + return s.accounts.SaveQuotaRecovery(ctx, accountdomain.QuotaRecovery{ + AccountID: credential.ID, Kind: accountdomain.QuotaRecoveryKindPaid, Status: accountdomain.QuotaRecoveryStatusExhausted, + ExhaustedAt: &exhaustedAt, NextProbeAt: &next, LastConfirmedAt: &now, UpdatedAt: now, + }) +} + +// HasBillingSnapshot 判断账号是否已经完成过一次额度同步,不触发任何上游请求。 +func (s *Service) HasBillingSnapshot(ctx context.Context, id uint64) (bool, error) { + _, err := s.accounts.GetBilling(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return false, nil + } + return err == nil, err +} + +func (s *Service) HasQuotaWindows(ctx context.Context, id uint64) (bool, error) { + return s.accounts.HasQuotaWindows(ctx, id) +} + +func (s *Service) DecrementQuota(ctx context.Context, id uint64, mode string, amount int) (bool, error) { + if amount <= 0 { + amount = 1 + } + if repository, ok := s.accounts.(interface { + DecrementQuotaWindowBy(context.Context, uint64, string, int, time.Time) (bool, error) + }); ok { + return repository.DecrementQuotaWindowBy(ctx, id, mode, amount, s.now()) + } + updated := false + for range amount { + decremented, err := s.accounts.DecrementQuotaWindow(ctx, id, mode, s.now()) + if err != nil { + return updated, err + } + if !decremented { + break + } + updated = true + } + return updated, nil +} + +func (s *Service) DecrementWebQuota(ctx context.Context, id uint64, mode string, amount int) (bool, error) { + return s.DecrementQuota(ctx, id, mode, amount) +} + +func (s *Service) ExhaustQuota(ctx context.Context, id uint64, mode string, resetAt *time.Time) error { + if resetAt == nil { + windows, err := s.accounts.GetQuotaWindows(ctx, []uint64{id}) + if err == nil { + for _, window := range windows[id] { + if window.Mode != mode { + continue + } + if window.ResetAt != nil && window.ResetAt.After(s.now()) { + value := *window.ResetAt + resetAt = &value + } else if window.WindowSeconds > 0 { + value := s.now().Add(time.Duration(window.WindowSeconds) * time.Second) + resetAt = &value + } + break + } + } + } + if err := s.accounts.ExhaustQuotaWindow(ctx, id, mode, resetAt, s.now()); err != nil { + return err + } + if resetAt != nil && s.quotaQueue != nil { + return s.quotaQueue.ScheduleQuotaRecovery(ctx, accountdomain.QuotaRecoveryEvent{AccountID: id, Mode: mode, DueAt: *resetAt}) + } + return nil +} + +func (s *Service) ExhaustWebQuota(ctx context.Context, id uint64, mode string, resetAt *time.Time) error { + return s.ExhaustQuota(ctx, id, mode, resetAt) +} + +func (s *Service) RefreshQuota(ctx context.Context, id uint64) ([]accountdomain.QuotaWindow, error) { + result, err, _ := s.quotaSyncs.Do("all:"+strconv.FormatUint(id, 10), func() (any, error) { + return s.refreshQuota(ctx, id) + }) + if err != nil { + return nil, err + } + windows, ok := result.([]accountdomain.QuotaWindow) + if !ok { + return nil, fmt.Errorf("Provider 额度同步返回类型无效") + } + return windows, nil +} + +func (s *Service) RefreshWebQuota(ctx context.Context, id uint64) ([]accountdomain.QuotaWindow, error) { + return s.RefreshQuota(ctx, id) +} + +func (s *Service) refreshQuota(ctx context.Context, id uint64) ([]accountdomain.QuotaWindow, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + adapter, ok := s.providers.Quota(value.Provider) + if !ok { + return nil, fmt.Errorf("%s Quota Provider 未注册", value.Provider) + } + snapshot, err := adapter.SyncQuota(ctx, value) + if err != nil { + if errors.Is(err, provider.ErrUnauthorized) { + err = errors.Join(err, s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider))) + } + return nil, err + } + quotaKind, _ := s.providers.QuotaKind(value.Provider) + if quotaKind == provider.QuotaLocalWindow { + existing, loadErr := s.accounts.GetQuotaWindows(ctx, []uint64{id}) + if loadErr != nil { + return nil, loadErr + } + snapshot.Windows = preserveActiveQuotaWindows(existing[id], snapshot.Windows, s.now()) + } + if err := s.accounts.ReplaceQuotaWindows(ctx, id, snapshot.Tier, snapshot.SyncedAt, snapshot.Windows); err != nil { + return nil, err + } + for _, window := range snapshot.Windows { + if window.Remaining == 0 && window.ResetAt != nil && s.quotaQueue != nil { + if err := s.quotaQueue.ScheduleQuotaRecovery(ctx, accountdomain.QuotaRecoveryEvent{AccountID: id, Mode: window.Mode, DueAt: *window.ResetAt}); err != nil { + return snapshot.Windows, fmt.Errorf("安排额度恢复事件: %w", err) + } + } + } + // 身份补全是非关键操作:只在额度落库和恢复任务调度完成后执行, + // 并沿用调用方取消语义,不能反向影响额度同步结果。 + if (value.Provider == accountdomain.ProviderWeb || value.Provider == accountdomain.ProviderConsole) && ctx.Err() == nil { + if strings.TrimSpace(value.UserID) == "" && strings.TrimSpace(value.Email) == "" { + if identityErr := s.syncAccountIdentityBestEffort(ctx, id); errors.Is(identityErr, provider.ErrUnauthorized) { + return snapshot.Windows, identityErr + } + } else { + // 已有 Session 身份时只做本地增量关联,不再访问上游。 + s.reconcileProviderLinksBestEffort(ctx, id) + } + } + return snapshot.Windows, nil +} + +func preserveActiveQuotaWindows(existing, incoming []accountdomain.QuotaWindow, now time.Time) []accountdomain.QuotaWindow { + byMode := make(map[string]accountdomain.QuotaWindow, len(existing)) + for _, window := range existing { + byMode[window.Mode] = window + } + result := append([]accountdomain.QuotaWindow(nil), incoming...) + for index, window := range result { + current, ok := byMode[window.Mode] + if !ok || current.ResetAt == nil || !current.ResetAt.After(now) { + continue + } + result[index] = current + } + return result +} + +// ReconcileRateLimit 根据额度模式核实 429;Web 周池继续以上游快照为准。 +func (s *Service) ReconcileRateLimit(ctx context.Context, id uint64, mode string, retryAfter time.Duration) (bool, error) { + if mode == "weekly" { + window, err := s.RefreshQuotaMode(ctx, id, mode) + if err != nil { + return false, err + } + return window.Remaining == 0 || window.UsagePercent >= 100, nil + } + var resetAt *time.Time + if retryAfter > 0 { + value := s.now().Add(retryAfter) + resetAt = &value + } + if err := s.ExhaustQuota(ctx, id, mode, resetAt); err != nil { + return false, err + } + return true, nil +} + +func (s *Service) ReconcileWebRateLimit(ctx context.Context, id uint64, mode string, retryAfter time.Duration) (bool, error) { + return s.ReconcileRateLimit(ctx, id, mode, retryAfter) +} + +func (s *Service) RefreshQuotaMode(ctx context.Context, id uint64, mode string) (accountdomain.QuotaWindow, error) { + key := strings.TrimSpace(mode) + ":" + strconv.FormatUint(id, 10) + result, err, _ := s.quotaSyncs.Do(key, func() (any, error) { + return s.refreshQuotaMode(ctx, id, mode) + }) + if err != nil { + return accountdomain.QuotaWindow{}, err + } + window, ok := result.(accountdomain.QuotaWindow) + if !ok { + return accountdomain.QuotaWindow{}, fmt.Errorf("Provider 模式额度同步返回类型无效") + } + return window, nil +} + +func (s *Service) RefreshWebQuotaMode(ctx context.Context, id uint64, mode string) (accountdomain.QuotaWindow, error) { + return s.RefreshQuotaMode(ctx, id, mode) +} + +func (s *Service) refreshQuotaMode(ctx context.Context, id uint64, mode string) (accountdomain.QuotaWindow, error) { + value, err := s.accounts.Get(ctx, id) + if err != nil { + return accountdomain.QuotaWindow{}, mapRepositoryError(err) + } + adapter, ok := s.providers.Quota(value.Provider) + if !ok { + return accountdomain.QuotaWindow{}, fmt.Errorf("%s Quota Provider 未注册", value.Provider) + } + window, err := adapter.SyncQuotaMode(ctx, value, mode) + if err != nil { + if errors.Is(err, provider.ErrUnauthorized) { + err = errors.Join(err, s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider))) + } + return accountdomain.QuotaWindow{}, err + } + var tier accountdomain.WebTier + quotaKind, _ := s.providers.QuotaKind(value.Provider) + if quotaKind == provider.QuotaRemoteWindow { + // A single-mode reconciliation updates only the window associated with this 429. + // Full quota synchronization remains responsible for tier detection and other modes. + tier = value.WebTier + } + now := s.now() + if err := s.accounts.SaveQuotaWindows(ctx, id, tier, now, []accountdomain.QuotaWindow{window}); err != nil { + return accountdomain.QuotaWindow{}, err + } + if window.Remaining == 0 && window.ResetAt != nil && s.quotaQueue != nil { + if err := s.quotaQueue.ScheduleQuotaRecovery(ctx, accountdomain.QuotaRecoveryEvent{AccountID: id, Mode: mode, DueAt: *window.ResetAt}); err != nil { + return window, fmt.Errorf("安排额度恢复事件: %w", err) + } + } + return window, nil +} + +// QueueQuotaRefresh asynchronously refreshes the remote quota window after a successful request. +func (s *Service) QueueQuotaRefresh(id uint64, mode string) { + mode = strings.TrimSpace(mode) + if id == 0 || (mode != "weekly" && !isWebChatQuotaMode(mode)) { + return + } + key := strconv.FormatUint(id, 10) + ":" + mode + s.quotaRefreshMu.Lock() + state := s.quotaRefreshes[key] + if state == nil { + state = &webQuotaRefreshState{} + s.quotaRefreshes[key] = state + } + state.generation++ + state.nextAttemptAt = time.Time{} + enqueued := state.queued || state.running || s.enqueueQuotaRefreshLocked(webQuotaRefreshRequest{key: key, accountID: id, mode: mode}, state) + s.quotaRefreshMu.Unlock() + if !enqueued { + perfmetrics.Default.Add("quota_refresh_events", perfmetrics.Labels{Subsystem: "quota", Stage: "enqueue", Outcome: "queue_full"}, 1) + s.logger.Warn("web_quota_refresh_queue_full", "account_id", id, "mode", mode) + s.wakeQuotaRefreshRecovery() + } +} + +func (s *Service) enqueueQuotaRefreshLocked(request webQuotaRefreshRequest, state *webQuotaRefreshState) bool { + if state == nil || state.queued || state.running { + return state != nil + } + select { + case s.quotaRefreshQueue <- request: + state.queued = true + return true + default: + return false + } +} + +func (s *Service) wakeQuotaRefreshRecovery() { + select { + case s.quotaRefreshWake <- struct{}{}: + default: + } +} + +// QueueWebQuotaRefresh preserves the existing internal API while delegating to QueueQuotaRefresh. +func (s *Service) QueueWebQuotaRefresh(id uint64, mode string) { + s.QueueQuotaRefresh(id, mode) +} + +// RunWebQuotaRefresh uses a fixed worker set to avoid unbounded goroutine creation. +func (s *Service) RunWebQuotaRefresh(ctx context.Context) { + var workers sync.WaitGroup + workers.Add(managedTaskWorkerCeiling + 1) + for range managedTaskWorkerCeiling { + go func() { + defer workers.Done() + for { + select { + case <-ctx.Done(): + return + case request := <-s.quotaRefreshQueue: + s.quotaRefreshMu.Lock() + state := s.quotaRefreshes[request.key] + if state == nil || state.running { + s.quotaRefreshMu.Unlock() + continue + } + state.queued = false + state.running = true + s.quotaRefreshMu.Unlock() + if err := batch.Do(ctx, func(workCtx context.Context) error { + s.runWebQuotaRefresh(workCtx, request) + return nil + }); err != nil { + s.quotaRefreshMu.Lock() + if state := s.quotaRefreshes[request.key]; state != nil { + state.running = false + state.nextAttemptAt = time.Now().UTC().Add(webQuotaRefreshRetryInterval) + } + s.quotaRefreshMu.Unlock() + s.wakeQuotaRefreshRecovery() + if ctx.Err() == nil { + var panicErr *batch.PanicError + if errors.As(err, &panicErr) { + s.logger.Error("web_quota_refresh_worker_panicked", "account_id", request.accountID, "mode", request.mode, "error", panicErr, "stack", string(panicErr.Stack)) + } else { + s.logger.Error("web_quota_refresh_worker_failed", "account_id", request.accountID, "mode", request.mode, "error", err) + } + } + } + } + } + }() + } + go func() { + defer workers.Done() + s.runQuotaRefreshRecovery(ctx) + }() + workers.Wait() +} + +func (s *Service) runWebQuotaRefresh(parent context.Context, request webQuotaRefreshRequest) { + for { + s.quotaRefreshMu.Lock() + state := s.quotaRefreshes[request.key] + if state == nil { + s.quotaRefreshMu.Unlock() + return + } + localGeneration := state.generation + publishedGeneration := state.publishedGeneration + sharedGeneration := state.sharedGeneration + s.quotaRefreshMu.Unlock() + + ctx, cancel := context.WithTimeout(parent, webQuotaRefreshTimeout) + if s.quotaRefreshState != nil && publishedGeneration < localGeneration { + generation, err := s.quotaRefreshState.MarkQuotaRefreshDirty(ctx, request.accountID, request.mode, webQuotaRefreshDirtyTTL) + if err != nil { + cancel() + s.deferQuotaRefresh(request.key) + perfmetrics.Default.Add("quota_refresh_events", perfmetrics.Labels{Subsystem: "quota", Stage: "publish", Outcome: "failed"}, 1) + s.logger.Warn("web_quota_refresh_dirty_publish_failed", "account_id", request.accountID, "mode", request.mode, "error", err) + return + } + sharedGeneration = generation + s.quotaRefreshMu.Lock() + if current := s.quotaRefreshes[request.key]; current != nil && current.publishedGeneration < localGeneration { + current.publishedGeneration = localGeneration + current.sharedGeneration = generation + } + s.quotaRefreshMu.Unlock() + } + if s.quotaRefreshState != nil && publishedGeneration >= localGeneration && sharedGeneration > 0 { + generation, dirty, err := s.quotaRefreshState.QuotaRefreshGeneration(ctx, request.accountID, request.mode) + if err != nil { + cancel() + s.deferQuotaRefresh(request.key) + return + } + if generation > sharedGeneration { + sharedGeneration = generation + s.quotaRefreshMu.Lock() + if current := s.quotaRefreshes[request.key]; current != nil { + current.sharedGeneration = generation + } + s.quotaRefreshMu.Unlock() + } + if !dirty && generation == sharedGeneration { + cancel() + s.quotaRefreshMu.Lock() + if current := s.quotaRefreshes[request.key]; current != nil && current.generation == localGeneration { + delete(s.quotaRefreshes, request.key) + } + s.quotaRefreshMu.Unlock() + return + } + } + refreshMode := request.mode + if windows, err := s.accounts.GetQuotaWindows(ctx, []uint64{request.accountID}); err == nil { + for _, window := range windows[request.accountID] { + if window.Mode == "weekly" { + refreshMode = "weekly" + break + } + } + } + var refreshErr error + acquired := true + var release func() + if s.refreshLock != nil { + effectiveKey := strconv.FormatUint(request.accountID, 10) + ":" + refreshMode + release, acquired, refreshErr = s.refreshLock.Acquire(ctx, "quota-refresh:"+effectiveKey, webQuotaRefreshTimeout) + } + if refreshErr == nil && acquired { + if err := s.syncPool.Do(ctx, func(workCtx context.Context) error { + _, refreshErr = s.RefreshWebQuotaMode(workCtx, request.accountID, refreshMode) + return refreshErr + }); err != nil { + refreshErr = err + } + } + if release != nil { + release() + } + cancel() + if refreshErr != nil || !acquired { + if refreshErr != nil && !errors.Is(refreshErr, context.Canceled) { + s.logger.Warn("web_quota_refresh_failed", "account_id", request.accountID, "mode", refreshMode, "error", refreshErr) + } + s.deferQuotaRefresh(request.key) + perfmetrics.Default.Add("quota_refresh_events", perfmetrics.Labels{Subsystem: "quota", Stage: "refresh", Outcome: "retry"}, 1) + return + } + + currentShared := sharedGeneration + sharedDirty := s.quotaRefreshState != nil + if s.quotaRefreshState != nil { + generationCtx, generationCancel := context.WithTimeout(context.WithoutCancel(parent), 3*time.Second) + var generationErr error + currentShared, sharedDirty, generationErr = s.quotaRefreshState.QuotaRefreshGeneration(generationCtx, request.accountID, request.mode) + generationCancel() + if generationErr != nil { + s.deferQuotaRefresh(request.key) + return + } + } + s.quotaRefreshMu.Lock() + state = s.quotaRefreshes[request.key] + localChanged := state != nil && state.generation != localGeneration + s.quotaRefreshMu.Unlock() + if localChanged || (s.quotaRefreshState != nil && currentShared != sharedGeneration) { + perfmetrics.Default.Add("quota_refresh_events", perfmetrics.Labels{Subsystem: "quota", Stage: "refresh", Outcome: "trailing"}, 1) + continue + } + if s.quotaRefreshState != nil && sharedDirty { + clearCtx, clearCancel := context.WithTimeout(context.WithoutCancel(parent), 3*time.Second) + cleared, clearErr := s.quotaRefreshState.ClearQuotaRefreshDirty(clearCtx, request.accountID, request.mode, sharedGeneration) + clearCancel() + if clearErr != nil || !cleared { + if clearErr != nil { + s.logger.Warn("web_quota_refresh_dirty_clear_failed", "account_id", request.accountID, "mode", request.mode, "error", clearErr) + } + continue + } + } + s.quotaRefreshMu.Lock() + state = s.quotaRefreshes[request.key] + if state != nil && state.generation == localGeneration { + delete(s.quotaRefreshes, request.key) + s.quotaRefreshMu.Unlock() + perfmetrics.Default.Add("quota_refresh_events", perfmetrics.Labels{Subsystem: "quota", Stage: "refresh", Outcome: "success"}, 1) + return + } + s.quotaRefreshMu.Unlock() + } +} + +func (s *Service) deferQuotaRefresh(key string) { + s.quotaRefreshMu.Lock() + if state := s.quotaRefreshes[key]; state != nil { + state.running = false + state.nextAttemptAt = time.Now().UTC().Add(webQuotaRefreshRetryInterval) + } + s.quotaRefreshMu.Unlock() + s.wakeQuotaRefreshRecovery() +} + +func (s *Service) runQuotaRefreshRecovery(ctx context.Context) { + retryTicker := time.NewTicker(webQuotaRefreshRetryInterval) + sharedTicker := time.NewTicker(webQuotaRefreshSharedPoll) + defer retryTicker.Stop() + defer sharedTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-s.quotaRefreshWake: + s.requeueQuotaRefreshes() + case <-retryTicker.C: + s.requeueQuotaRefreshes() + case now := <-sharedTicker.C: + s.recoverSharedQuotaRefreshes(ctx, now.UTC()) + s.requeueQuotaRefreshes() + } + } +} + +func (s *Service) requeueQuotaRefreshes() { + now := time.Now().UTC() + s.quotaRefreshMu.Lock() + for key, state := range s.quotaRefreshes { + if state == nil || state.queued || state.running || now.Before(state.nextAttemptAt) { + continue + } + separator := strings.IndexByte(key, ':') + if separator <= 0 || separator == len(key)-1 { + continue + } + accountID, err := strconv.ParseUint(key[:separator], 10, 64) + if err != nil { + continue + } + if !s.enqueueQuotaRefreshLocked(webQuotaRefreshRequest{key: key, accountID: accountID, mode: key[separator+1:]}, state) { + break + } + } + s.quotaRefreshMu.Unlock() +} + +func (s *Service) recoverSharedQuotaRefreshes(parent context.Context, now time.Time) { + if s.quotaRefreshState == nil { + return + } + ctx, cancel := context.WithTimeout(parent, 3*time.Second) + values, err := s.quotaRefreshState.ListQuotaRefreshDirty(ctx, now, 100) + cancel() + if err != nil { + s.logger.Warn("web_quota_refresh_dirty_list_failed", "error", err) + return + } + s.quotaRefreshMu.Lock() + for _, value := range values { + key := strconv.FormatUint(value.AccountID, 10) + ":" + value.Mode + state := s.quotaRefreshes[key] + if state == nil { + state = &webQuotaRefreshState{generation: 1, publishedGeneration: 1, sharedGeneration: value.Generation} + s.quotaRefreshes[key] = state + } else if value.Generation > state.sharedGeneration { + state.sharedGeneration = value.Generation + } + if !state.queued && !state.running && !s.enqueueQuotaRefreshLocked(webQuotaRefreshRequest{key: key, accountID: value.AccountID, mode: value.Mode}, state) { + break + } + } + s.quotaRefreshMu.Unlock() +} + +func (s *Service) ListDueWebQuotaWindows(ctx context.Context, now time.Time, limit int) ([]accountdomain.QuotaWindow, error) { + windows, err := s.ListDueQuotaWindows(ctx, now, limit) + if err != nil { + return nil, err + } + result := make([]accountdomain.QuotaWindow, 0, len(windows)) + for _, window := range windows { + credential, getErr := s.accounts.Get(ctx, window.AccountID) + if errors.Is(getErr, repository.ErrNotFound) { + continue + } + if getErr != nil { + return nil, getErr + } + if credential.Provider == accountdomain.ProviderWeb { + result = append(result, window) + } + } + return result, nil +} + +func (s *Service) ListDueQuotaWindows(ctx context.Context, now time.Time, limit int) ([]accountdomain.QuotaWindow, error) { + return s.accounts.ListDueQuotaWindows(ctx, now, limit) +} + +func isWebChatQuotaMode(mode string) bool { + switch mode { + case "auto", "fast", "expert", "heavy": + return true + default: + return false + } +} + +// SyncAllBilling 尽力刷新全部启用账号,单个账号失败不阻断其他账号。 +func (s *Service) SyncAllBilling(ctx context.Context) (int, int, error) { + return s.SyncAllBillingWithProgress(ctx, nil) +} + +func (s *Service) SyncAllBillingWithProgress(ctx context.Context, progress BatchProgressObserver) (int, int, error) { + if s.providers == nil { + return 0, 0, fmt.Errorf("Provider 注册表未初始化") + } + ids := make([]uint64, 0) + for _, providerValue := range s.providers.Providers() { + quotaKind, ok := s.providers.QuotaKind(providerValue) + if !ok || quotaKind != provider.QuotaBilling { + continue + } + providerIDs, err := s.accounts.ListEnabledAccountIDs(ctx, providerValue, false) + if err != nil { + return 0, 0, err + } + ids = append(ids, providerIDs...) + } + return s.refreshBillings(ctx, ids, progress) +} + +// SyncAllWebQuotas 尽力同步全部启用 Grok Web 账号的分模式额度。 +func (s *Service) SyncAllWebQuotas(ctx context.Context) (int, int, error) { + return s.SyncAllWebQuotasWithProgress(ctx, nil) +} + +func (s *Service) SyncAllWebQuotasWithProgress(ctx context.Context, progress BatchProgressObserver) (int, int, error) { + return s.syncAllQuotasWithProgress(ctx, accountdomain.ProviderWeb, "web_quota_sync", progress) +} + +func (s *Service) SyncAllConsoleQuotas(ctx context.Context) (int, int, error) { + return s.SyncAllConsoleQuotasWithProgress(ctx, nil) +} + +func (s *Service) SyncAllConsoleQuotasWithProgress(ctx context.Context, progress BatchProgressObserver) (int, int, error) { + return s.syncAllQuotasWithProgress(ctx, accountdomain.ProviderConsole, "console_quota_sync", progress) +} + +func (s *Service) syncAllQuotasWithProgress(ctx context.Context, providerValue accountdomain.Provider, operation string, progress BatchProgressObserver) (int, int, error) { + ids, err := s.accounts.ListEnabledAccountIDs(ctx, providerValue, false) + if err != nil { + return 0, 0, err + } + return s.runAccountBatch(ctx, operation, ids, s.syncPool, progress, func(workCtx context.Context, id uint64) error { + _, err := s.RefreshQuota(workCtx, id) + return err + }) +} + +// SyncWebQuotaAccounts 同步指定 Web 账号集合,供启动追赶任务复用共享并发池。 +func (s *Service) SyncWebQuotaAccounts(ctx context.Context, ids []uint64) (int, int, error) { + return s.runAccountBatch(ctx, "web_quota_startup_catchup", ids, s.syncPool, nil, func(workCtx context.Context, id uint64) error { + _, err := s.RefreshWebQuota(workCtx, id) + return err + }) +} + +// RefreshAllTokens 续期所有声明支持刷新的 Provider 凭据,不可续期账号会被跳过。 +func (s *Service) RefreshAllTokens(ctx context.Context) (int, int, int, error) { + return s.RefreshAllTokensWithProgress(ctx, nil) +} + +func (s *Service) RefreshAllTokensWithProgress(ctx context.Context, progress BatchProgressObserver) (int, int, int, error) { + if s.providers == nil { + return 0, 0, 0, fmt.Errorf("Provider 注册表未初始化") + } + allIDs := make([]uint64, 0) + ids := make([]uint64, 0) + for _, providerValue := range s.providers.Providers() { + if !s.providers.SupportsCredentialRefresh(providerValue) { + continue + } + providerIDs, err := s.accounts.ListEnabledCredentialRefreshAccountIDs(ctx, providerValue, false) + if err != nil { + return 0, 0, 0, err + } + refreshableIDs, err := s.accounts.ListEnabledCredentialRefreshAccountIDs(ctx, providerValue, true) + if err != nil { + return 0, 0, 0, err + } + allIDs = append(allIDs, providerIDs...) + ids = append(ids, refreshableIDs...) + } + skipped := max(0, len(allIDs)-len(ids)) + succeeded, failed, err := s.refreshTokens(ctx, ids, progress) + return succeeded, failed, skipped, err +} + +func (s *Service) refreshTokens(ctx context.Context, ids []uint64, progress BatchProgressObserver) (int, int, error) { + return s.runAccountBatch(ctx, "credential_refresh", ids, s.refreshPool, progress, func(workCtx context.Context, id uint64) error { + value, err := s.accounts.Get(workCtx, id) + if err == nil { + _, err = s.ensureCredential(workCtx, value, ensureCredentialOptions{force: true, bypassCooldown: true, retryPermanentOnce: true}) + } + return err + }) +} + +// BatchRefreshTokens 续期指定账号的凭据;失效账号会强制向上游重试一次, +// 停用、Provider 不支持或缺少刷新凭据的账号会被跳过。 +func (s *Service) BatchRefreshTokens(ctx context.Context, ids []uint64) (int, int, int, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, 0, 0, err + } + if s.providers == nil { + return 0, 0, 0, fmt.Errorf("Provider 注册表未初始化") + } + refreshableIDs := make([]uint64, 0, len(values)) + for _, id := range values { + value, getErr := s.accounts.Get(ctx, id) + if getErr != nil { + return 0, 0, 0, getErr + } + if !s.providers.SupportsCredentialRefresh(value.Provider) || !value.Enabled || value.EncryptedRefreshToken == "" { + continue + } + refreshableIDs = append(refreshableIDs, id) + } + skipped := len(values) - len(refreshableIDs) + succeeded, failed, err := s.refreshTokens(ctx, refreshableIDs, nil) + return succeeded, failed, skipped, err +} + +// BatchRefreshBilling 使用有限并发刷新选中账号,避免大量账号同步时串行阻塞或无界创建 goroutine。 +func (s *Service) BatchRefreshBilling(ctx context.Context, ids []uint64) (int, int, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, 0, err + } + return s.refreshBillings(ctx, values, nil) +} + +// BatchResetQuotaState clears local Build quota recovery state without changing +// upstream billing snapshots or historical audit usage. +func (s *Service) BatchResetQuotaState(ctx context.Context, ids []uint64) (int, error) { + values, err := normalizeIDs(ids, maxQuotaResetAccounts) + if err != nil { + return 0, err + } + for start := 0; start < len(values); start += quotaResetChunkSize { + end := min(start+quotaResetChunkSize, len(values)) + count, countErr := s.accounts.CountProviderAccountsByIDs(ctx, accountdomain.ProviderBuild, values[start:end]) + if countErr != nil { + return 0, countErr + } + if count != int64(end-start) { + return 0, invalidInput("仅 Grok Build 账号支持手动重置额度状态") + } + } + reset := 0 + for start := 0; start < len(values); start += quotaResetChunkSize { + if err := ctx.Err(); err != nil { + return reset, err + } + end := min(start+quotaResetChunkSize, len(values)) + if err := s.accounts.ResetQuotaState(ctx, accountdomain.ProviderBuild, values[start:end]); err != nil { + return reset, err + } + reset += end - start + } + return reset, nil +} + +// ResetAllBuildQuotaState clears local quota state for every enabled Build +// account without materializing the complete account ID set in memory. +func (s *Service) ResetAllBuildQuotaState(ctx context.Context) (int64, error) { + return s.accounts.ResetProviderQuotaState(ctx, accountdomain.ProviderBuild, true) +} + +// BatchRefreshQuota 使用有限并发同步选中 Web 或 Console 账号的额度窗口。 +func (s *Service) BatchRefreshQuota(ctx context.Context, ids []uint64) (int, int, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, 0, err + } + return s.runAccountBatch(ctx, "quota_sync", values, s.syncPool, nil, func(workCtx context.Context, id uint64) error { + _, err := s.RefreshQuota(workCtx, id) + return err + }) +} + +func (s *Service) refreshBillings(ctx context.Context, ids []uint64, progress BatchProgressObserver) (int, int, error) { + return s.runAccountBatch(ctx, "billing_sync", ids, s.syncPool, progress, func(workCtx context.Context, id uint64) error { + _, err := s.RefreshBilling(workCtx, id) + return err + }) +} + +func (s *Service) runAccountBatch(ctx context.Context, operation string, ids []uint64, pool *batch.Pool, progress BatchProgressObserver, work func(context.Context, uint64) error) (int, int, error) { + if progress != nil { + if err := progress(0, len(ids)); err != nil { + return 0, 0, err + } + } + var progressMu sync.Mutex + var progressErr error + completed := 0 + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + results, summary, err := batch.MapObserved(runCtx, ids, batch.Options{Workers: pool.Limit(), Pool: pool}, func(workCtx context.Context, id uint64) (struct{}, error) { + return struct{}{}, work(workCtx, id) + }, func(_ int, _ batch.Result[struct{}]) { + progressMu.Lock() + defer progressMu.Unlock() + completed++ + if progress != nil { + if notifyErr := progress(completed, len(ids)); notifyErr != nil && progressErr == nil { + progressErr = notifyErr + cancel() + } + } + }) + for index, result := range results { + var panicErr *batch.PanicError + if errors.As(result.Err, &panicErr) { + s.logger.Error("account_bulk_task_panicked", "operation", operation, "account_id", ids[index], "error", panicErr, "stack", string(panicErr.Stack)) + } + } + s.logBatchSummary(operation, pool, summary, err) + return summary.Succeeded, summary.Failed, errors.Join(err, progressErr) +} + +func (s *Service) logBatchSummary(operation string, pool *batch.Pool, summary batch.Summary, err error) { + snapshot := pool.Snapshot() + s.logger.Info("account_bulk_completed", "operation", operation, "total", summary.Total, "submitted", summary.Submitted, "succeeded", summary.Succeeded, "failed", summary.Failed, "panicked", summary.Panicked, "duration_ms", summary.Duration.Milliseconds(), "canceled", summary.Canceled, "pool_limit", snapshot.Limit, "pool_active", snapshot.Active, "pool_queued", snapshot.Queued, "pool_peak", snapshot.Peak, "error", err) +} + +func (s *Service) persistSeed(ctx context.Context, seed provider.CredentialSeed) (accountdomain.Credential, bool, error) { + value, err := s.credentialFromSeed(seed) + if err != nil { + return accountdomain.Credential{}, false, err + } + stored, created, err := s.accounts.UpsertByIdentity(ctx, value) + if err == nil { + s.invalidateBuildBotFlagCache() + s.WakeCredentialRefresh() + } + return stored, created, err +} + +func (s *Service) credentialFromSeed(seed provider.CredentialSeed) (accountdomain.Credential, error) { + accessEncrypted, err := s.cipher.Encrypt(seed.AccessToken) + if err != nil { + return accountdomain.Credential{}, err + } + refreshEncrypted, err := s.cipher.Encrypt(seed.RefreshToken) + if err != nil { + return accountdomain.Credential{}, err + } + cloudflareEncrypted := "" + if strings.TrimSpace(seed.CloudflareCookies) != "" { + cookies := egressapp.SanitizeCloudflareCookies(seed.CloudflareCookies) + if cookies == "" { + return accountdomain.Credential{}, invalidInput("Cloudflare Cookie 中没有有效字段") + } + cloudflareEncrypted, err = s.cipher.Encrypt(cookies) + if err != nil { + return accountdomain.Credential{}, err + } + } + sourceKey := seed.SourceKey + if sourceKey == "" { + sourceKey = "device:" + security.HashToken(seed.AccessToken) + } + providerValue := seed.Provider + if providerValue == "" { + providerValue = accountdomain.ProviderBuild + } + authType := seed.AuthType + if authType == "" { + if s.providers == nil { + return accountdomain.Credential{}, fmt.Errorf("Provider 注册表未初始化") + } + definition, ok := s.providers.Definition(providerValue) + if !ok { + return accountdomain.Credential{}, fmt.Errorf("Provider %s 未注册", providerValue) + } + authType = definition.Credential.AuthType + } + value := accountdomain.Credential{Provider: providerValue, AuthType: authType, WebTier: seed.WebTier, Name: seed.Name, Email: seed.Email, UserID: seed.UserID, TeamID: seed.TeamID, SourceKey: sourceKey, OIDCClientID: seed.OIDCClientID, EncryptedAccessToken: accessEncrypted, EncryptedRefreshToken: refreshEncrypted, EncryptedCloudflareCookie: cloudflareEncrypted, ExpiresAt: seed.ExpiresAt, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: accountdomain.DefaultPriority, MaxConcurrent: accountdomain.DefaultMaxConcurrent, MinimumRemaining: accountdomain.DefaultMinimumRemaining, WebNSFWEnabledAt: seed.WebNSFWEnabledAt, WebTermsAcceptedAt: seed.WebTermsAcceptedAt, WebTermsAcceptedVersion: seed.WebTermsAcceptedVersion, WebBirthDateSetAt: seed.WebBirthDateSetAt} + if providerValue == accountdomain.ProviderWeb && strings.TrimSpace(seed.AccessToken) != "" { + value.EgressIdentity = "sso_" + security.HashToken(seed.AccessToken)[:32] + } + return value, nil +} + +func normalizePage(page, pageSize int) (int, int) { + return repository.NormalizePage(page, pageSize, repository.DefaultPageSize) +} + +func normalizeBatchIDs(ids []uint64) ([]uint64, error) { + return normalizeIDs(ids, repository.MaxPageSize) +} + +func normalizeIDs(ids []uint64, limit int) ([]uint64, error) { + if len(ids) == 0 { + return nil, invalidInput("至少选择一个账号") + } + if len(ids) > limit { + return nil, invalidInput(fmt.Sprintf("单次最多处理 %d 个账号", limit)) + } + seen := make(map[uint64]struct{}, len(ids)) + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + return nil, invalidInput("账号 ID 无效") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result, nil +} + +// invalidInput 为可安全返回给管理端的账号参数错误附加稳定语义。 +func invalidInput(message string) error { + return fmt.Errorf("%w: %s", ErrInvalidInput, message) +} + +// mapRepositoryError 隔离持久化层错误,避免 transport 依赖仓储实现语义。 +func mapLinkedDeleteError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if strings.Contains(msg, "关联删除目标") || strings.Contains(msg, "账号来源无效") || strings.Contains(msg, "不支持清理账号状态") { + return invalidInput(msg) + } + return mapRepositoryError(err) +} + +func mapRepositoryError(err error) error { + if errors.Is(err, repository.ErrAccountPoolMismatch) { + return ErrAccountPoolMismatch + } + if errors.Is(err, repository.ErrNotFound) { + return ErrNotFound + } + if errors.Is(err, repository.ErrConflict) { + return fmt.Errorf("%w: %s", ErrConflict, strings.TrimPrefix(err.Error(), repository.ErrConflict.Error()+": ")) + } + return err +} diff --git a/backend/internal/application/account/web_account_scripts.go b/backend/internal/application/account/web_account_scripts.go new file mode 100644 index 0000000000000000000000000000000000000000..2fc8d3cdcf3cd050bb76065b62e6de6a8ec29b12 --- /dev/null +++ b/backend/internal/application/account/web_account_scripts.go @@ -0,0 +1,144 @@ +package account + +import ( + "context" + "errors" + "strconv" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +const ( + maxWebAccountScriptAccounts = 1000 + webAccountScriptLockTTL = 5 * time.Minute +) + +var ErrWebAccountScriptBusy = errors.New("Grok Web 账号脚本正在执行") + +// WebAccountScriptOptions 定义 Grok Web 账号脚本需要执行的步骤。 +// EnableNSFW 会隐式启用 SetBirthDate,保证上游年龄前置条件成立。 +type WebAccountScriptOptions struct { + AcceptTerms bool + SetBirthDate bool + EnableNSFW bool +} + +// RunWebAccountScriptsWithProgress 为指定 Web 账号并发执行所选脚本,并按账号报告进度。 +func (s *Service) RunWebAccountScriptsWithProgress(ctx context.Context, ids []uint64, options WebAccountScriptOptions, progress BatchProgressObserver) (int, int, error) { + options, err := normalizeWebAccountScriptOptions(options) + if err != nil { + return 0, 0, err + } + ids, err = normalizeIDs(ids, maxWebAccountScriptAccounts) + if err != nil { + return 0, 0, err + } + return s.runWebAccountScriptBatch(ctx, ids, options, progress) +} + +// RunAllWebAccountScriptsWithProgress 分页处理完整 Web 号池,避免一次性加载全部账号。 +func (s *Service) RunAllWebAccountScriptsWithProgress(ctx context.Context, options WebAccountScriptOptions, progress BatchProgressObserver) (int, int, error) { + options, err := normalizeWebAccountScriptOptions(options) + if err != nil { + return 0, 0, err + } + var ( + afterID uint64 + completed int + total int + succeeded int + failed int + started bool + ) + for { + values, count, err := s.accounts.ListProviderAccountBatch(ctx, accountdomain.ProviderWeb, afterID, accountTaskBatchSize) + if err != nil { + return succeeded, failed, mapRepositoryError(err) + } + if !started { + total = int(count) + started = true + if progress != nil { + if err := progress(0, total); err != nil { + return succeeded, failed, err + } + } + } + if len(values) == 0 { + return succeeded, failed, nil + } + remaining := total - completed + if remaining <= 0 { + return succeeded, failed, nil + } + if len(values) > remaining { + values = values[:remaining] + } + ids := make([]uint64, 0, len(values)) + for _, value := range values { + ids = append(ids, value.ID) + } + batchSucceeded, batchFailed, err := s.runWebAccountScriptBatch(ctx, ids, options, offsetBatchProgress(progress, completed, total)) + succeeded += batchSucceeded + failed += batchFailed + if err != nil { + return succeeded, failed, err + } + completed += len(ids) + afterID = ids[len(ids)-1] + if completed >= total || len(ids) < accountTaskBatchSize { + return succeeded, failed, nil + } + } +} + +func normalizeWebAccountScriptOptions(options WebAccountScriptOptions) (WebAccountScriptOptions, error) { + if !options.AcceptTerms && !options.SetBirthDate && !options.EnableNSFW { + return WebAccountScriptOptions{}, invalidInput("至少选择一个账号脚本") + } + if options.EnableNSFW { + options.SetBirthDate = true + } + return options, nil +} + +func (s *Service) runSingleWebAccountScript(ctx context.Context, id uint64, options WebAccountScriptOptions) error { + if s.syncPool == nil { + return s.runWebAccountScript(ctx, id, options) + } + return s.syncPool.Do(ctx, func(workCtx context.Context) error { + return s.runWebAccountScript(workCtx, id, options) + }) +} + +func (s *Service) acquireWebAccountScriptLock(ctx context.Context, id uint64) (func(), error) { + if s.refreshLock == nil { + return func() {}, nil + } + if err := ctx.Err(); err != nil { + return nil, err + } + key := "web-account-script:" + strconv.FormatUint(id, 10) + release, acquired, err := s.refreshLock.Acquire(ctx, key, webAccountScriptLockTTL) + if err != nil { + return nil, err + } + if !acquired { + return nil, ErrWebAccountScriptBusy + } + if release == nil { + return func() {}, nil + } + return release, nil +} + +func (s *Service) runWebAccountScriptBatch(ctx context.Context, ids []uint64, options WebAccountScriptOptions, progress BatchProgressObserver) (int, int, error) { + return s.runAccountBatch(ctx, "web_account_scripts", ids, s.syncPool, progress, func(workCtx context.Context, id uint64) error { + err := s.runWebAccountScript(workCtx, id, options) + if err != nil && !errors.Is(err, context.Canceled) { + s.logger.Warn("web_account_script_failed", "account_id", id, "error", err) + } + return err + }) +} diff --git a/backend/internal/application/account/web_account_scripts_test.go b/backend/internal/application/account/web_account_scripts_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6fa65f7a87889fb93187c2f2e53380b26e2a52cf --- /dev/null +++ b/backend/internal/application/account/web_account_scripts_test.go @@ -0,0 +1,277 @@ +package account + +import ( + "context" + "errors" + "reflect" + "sync/atomic" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" +) + +func TestRunWebAccountScriptsReportsProgressAndIsolatesFailures(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + first := createWebAccountForScriptTest(t, ctx, repo, "first") + second := createWebAccountForScriptTest(t, ctx, repo, "second") + adapter.failures = map[uint64]map[string]error{ + second.ID: {"setBirthDate": errors.New("birth rejected")}, + } + + progress := make([][2]int, 0, 3) + succeeded, failed, err := service.RunWebAccountScriptsWithProgress(ctx, []uint64{first.ID, second.ID}, WebAccountScriptOptions{ + AcceptTerms: true, + EnableNSFW: true, + }, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if succeeded != 1 || failed != 1 { + t.Fatalf("succeeded=%d failed=%d", succeeded, failed) + } + if want := [][2]int{{0, 2}, {1, 2}, {2, 2}}; !reflect.DeepEqual(progress, want) { + t.Fatalf("progress = %#v, want %#v", progress, want) + } + if calls := adapter.accountCalls(first.ID); !reflect.DeepEqual(calls, []string{"acceptTerms", "setBirthDate", "enableNSFW"}) { + t.Fatalf("first calls = %#v", calls) + } + if calls := adapter.accountCalls(second.ID); !reflect.DeepEqual(calls, []string{"acceptTerms", "setBirthDate"}) { + t.Fatalf("second calls = %#v", calls) + } + firstStored, err := repo.Get(ctx, first.ID) + if err != nil { + t.Fatal(err) + } + secondStored, err := repo.Get(ctx, second.ID) + if err != nil { + t.Fatal(err) + } + if firstStored.WebNSFWEnabledAt == nil || secondStored.WebNSFWEnabledAt != nil { + t.Fatalf("markers first=%v second=%v", firstStored.WebNSFWEnabledAt, secondStored.WebNSFWEnabledAt) + } +} + +func TestEnableWebNSFWAlwaysSetsBirthDateFirst(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + credential := createWebAccountForScriptTest(t, ctx, repo, "nsfw") + + if err := service.EnableWebNSFW(ctx, credential.ID); err != nil { + t.Fatal(err) + } + if calls := adapter.accountCalls(credential.ID); !reflect.DeepEqual(calls, []string{"setBirthDate", "enableNSFW"}) { + t.Fatalf("calls = %#v", calls) + } + stored, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.WebNSFWEnabledAt == nil || !stored.WebNSFWEnabledAt.Equal(service.now()) { + t.Fatalf("NSFW marker = %v, want %s", stored.WebNSFWEnabledAt, service.now()) + } +} + +func TestEnableWebNSFWContinuesWhenBirthDateIsAlreadySet(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + credential := createWebAccountForScriptTest(t, ctx, repo, "nsfw-existing-birth-date") + adapter.failures = map[uint64]map[string]error{ + credential.ID: {"setBirthDate": provider.ErrBirthDateAlreadySet}, + } + + if err := service.EnableWebNSFW(ctx, credential.ID); err != nil { + t.Fatal(err) + } + if calls := adapter.accountCalls(credential.ID); !reflect.DeepEqual(calls, []string{"setBirthDate", "enableNSFW"}) { + t.Fatalf("calls = %#v", calls) + } + stored, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.WebNSFWEnabledAt == nil { + t.Fatal("successful NSFW was not marked after an already-set birth date") + } +} + +func TestEnableWebNSFWDoesNotMarkUpstreamFailure(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + credential := createWebAccountForScriptTest(t, ctx, repo, "nsfw-failed") + adapter.failures = map[uint64]map[string]error{ + credential.ID: {"enableNSFW": errors.New("nsfw rejected")}, + } + + if err := service.EnableWebNSFW(ctx, credential.ID); err == nil { + t.Fatal("expected NSFW failure") + } + stored, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.WebNSFWEnabledAt != nil { + t.Fatalf("failed NSFW was marked at %s", stored.WebNSFWEnabledAt) + } +} + +func TestEnableWebNSFWPersistsMarkerAfterClientCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + service, repo, adapter := newWebAccountSettingsTestService(t) + credential := createWebAccountForScriptTest(t, ctx, repo, "nsfw-canceled") + adapter.afterCall = func(action string) { + if action == "enableNSFW" { + cancel() + } + } + + if err := service.EnableWebNSFW(ctx, credential.ID); err != nil { + t.Fatal(err) + } + stored, err := repo.Get(context.Background(), credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.WebNSFWEnabledAt == nil { + t.Fatal("successful upstream NSFW was not marked after client cancellation") + } +} + +func TestRunAllWebAccountScriptsOnlyProcessesWebAccounts(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + first := createWebAccountForScriptTest(t, ctx, repo, "first") + second := createWebAccountForScriptTest(t, ctx, repo, "second") + if _, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "build", SourceKey: "build", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }); err != nil { + t.Fatal(err) + } + + progress := make([][2]int, 0, 3) + succeeded, failed, err := service.RunAllWebAccountScriptsWithProgress(ctx, WebAccountScriptOptions{AcceptTerms: true}, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if succeeded != 2 || failed != 0 { + t.Fatalf("succeeded=%d failed=%d", succeeded, failed) + } + if want := [][2]int{{0, 2}, {1, 2}, {2, 2}}; !reflect.DeepEqual(progress, want) { + t.Fatalf("progress = %#v, want %#v", progress, want) + } + if calls := adapter.accountCalls(first.ID); !reflect.DeepEqual(calls, []string{"acceptTerms"}) { + t.Fatalf("first calls = %#v", calls) + } + if calls := adapter.accountCalls(second.ID); !reflect.DeepEqual(calls, []string{"acceptTerms"}) { + t.Fatalf("second calls = %#v", calls) + } +} + +func TestRunWebAccountScriptsRejectsEmptyPlan(t *testing.T) { + t.Parallel() + service, _, _ := newWebAccountSettingsTestService(t) + if _, _, err := service.RunWebAccountScriptsWithProgress(context.Background(), []uint64{1}, WebAccountScriptOptions{}, nil); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v", err) + } +} + +func TestWebAccountScriptsRejectConcurrentWorkForTheSameAccount(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + service, repo, _ := newWebAccountSettingsTestService(t) + credential := createWebAccountForScriptTest(t, ctx, repo, "serialized") + adapter := &blockingWebAccountSettingsAdapter{ + entered: make(chan struct{}, 2), + release: make(chan struct{}), + } + service.providers = provider.NewRegistry(adapter) + service.refreshLock = memory.NewLockStore() + + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- service.AcceptWebTerms(ctx, credential.ID) }() + select { + case <-adapter.entered: + case <-time.After(time.Second): + t.Fatal("first script did not reach the adapter") + } + if err := service.AcceptWebTerms(ctx, credential.ID); !errors.Is(err, ErrWebAccountScriptBusy) { + close(adapter.release) + t.Fatalf("concurrent err = %v", err) + } + close(adapter.release) + if err := <-errorsChannel; err != nil { + t.Fatal(err) + } + if got := adapter.maxActive.Load(); got != 1 { + t.Fatalf("max active = %d", got) + } +} + +type blockingWebAccountSettingsAdapter struct { + active atomic.Int32 + maxActive atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (*blockingWebAccountSettingsAdapter) Provider() accountdomain.Provider { + return accountdomain.ProviderWeb +} + +func (a *blockingWebAccountSettingsAdapter) AcceptTerms(ctx context.Context, _ accountdomain.Credential) error { + active := a.active.Add(1) + defer a.active.Add(-1) + for { + current := a.maxActive.Load() + if active <= current || a.maxActive.CompareAndSwap(current, active) { + break + } + } + a.entered <- struct{}{} + select { + case <-ctx.Done(): + return ctx.Err() + case <-a.release: + return nil + } +} + +func (*blockingWebAccountSettingsAdapter) SetBirthDate(context.Context, accountdomain.Credential, time.Time) error { + return nil +} + +func (*blockingWebAccountSettingsAdapter) EnableNSFW(context.Context, accountdomain.Credential) error { + return nil +} + +func createWebAccountForScriptTest(t *testing.T, ctx context.Context, repo interface { + UpsertByIdentity(context.Context, accountdomain.Credential) (accountdomain.Credential, bool, error) +}, sourceKey string) accountdomain.Credential { + t.Helper() + credential, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: sourceKey, SourceKey: sourceKey, EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + return credential +} diff --git a/backend/internal/application/account/web_account_settings.go b/backend/internal/application/account/web_account_settings.go new file mode 100644 index 0000000000000000000000000000000000000000..117e1c42960491062544d126dfedd4c99c2ef425 --- /dev/null +++ b/backend/internal/application/account/web_account_settings.go @@ -0,0 +1,198 @@ +package account + +import ( + "context" + cryptorand "crypto/rand" + "errors" + "fmt" + "io" + "math/big" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const ( + webMinimumAge = 20 + webMaximumAge = 40 + webAccountStateWriteTimeout = 5 * time.Second +) + +// AcceptWebTerms 接受指定 Grok Web SSO 账号的上游服务协议。 +func (s *Service) AcceptWebTerms(ctx context.Context, id uint64) error { + return s.runSingleWebAccountScript(ctx, id, WebAccountScriptOptions{AcceptTerms: true}) +} + +// SetWebBirthDate 为指定 Grok Web SSO 账号生成并设置 20–40 岁的随机生日。 +func (s *Service) SetWebBirthDate(ctx context.Context, id uint64) error { + return s.runSingleWebAccountScript(ctx, id, WebAccountScriptOptions{SetBirthDate: true}) +} + +// randomWebBirthDate 在所有可形成 20–40 周岁年龄的自然日中均匀取值。 +func randomWebBirthDate(now time.Time, source io.Reader) (time.Time, error) { + if source == nil { + return time.Time{}, fmt.Errorf("随机源不能为空") + } + earliest, latest := webBirthDateRange(now) + dayCount := int64(latest.Sub(earliest)/(24*time.Hour)) + 1 + if dayCount <= 0 { + return time.Time{}, fmt.Errorf("随机生日范围无效") + } + offset, err := cryptorand.Int(source, big.NewInt(dayCount)) + if err != nil { + return time.Time{}, err + } + return earliest.AddDate(0, 0, int(offset.Int64())), nil +} + +func webBirthDateRange(now time.Time) (time.Time, time.Time) { + // 生日是纯日期:以调用方所在时区取“今天”,再用 UTC 承载该日历值,避免 DST 干扰天数计算。 + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + // 20 岁生日当天仍属于范围;41 岁生日当天已超出范围,因此最早日期需后移一天。 + earliest := webDateYearsAgo(today, webMaximumAge+1).AddDate(0, 0, 1) + latest := webDateYearsAgo(today, webMinimumAge) + return earliest, latest +} + +// webDateYearsAgo 将闰日夹紧到目标年份当月的最后一天,避免 AddDate 的跨月归一化。 +func webDateYearsAgo(today time.Time, years int) time.Time { + year := today.Year() - years + day := today.Day() + lastDay := time.Date(year, today.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() + if day > lastDay { + day = lastDay + } + return time.Date(year, today.Month(), day, 0, 0, 0, 0, time.UTC) +} + +// EnableWebNSFW 为指定 Grok Web SSO 账号设置随机生日后开启成人内容显示偏好。 +func (s *Service) EnableWebNSFW(ctx context.Context, id uint64) error { + return s.runSingleWebAccountScript(ctx, id, WebAccountScriptOptions{EnableNSFW: true}) +} + +func (s *Service) runWebAccountScript(ctx context.Context, id uint64, options WebAccountScriptOptions) error { + options, err := normalizeWebAccountScriptOptions(options) + if err != nil { + return err + } + release, err := s.acquireWebAccountScriptLock(ctx, id) + if err != nil { + return err + } + defer release() + credential, adapter, err := s.webAccountSettings(ctx, id) + if err != nil { + return err + } + options = pendingWebAccountScriptOptions(credential, options) + if !options.AcceptTerms && !options.SetBirthDate && !options.EnableNSFW { + return nil + } + if options.AcceptTerms { + if err := s.runWebAccountSetting(ctx, credential, "接受服务协议", func() error { + return adapter.AcceptTerms(ctx, credential) + }); err != nil { + return err + } + if err := s.recordWebTermsAccepted(ctx, credential.ID); err != nil { + return err + } + } + if options.SetBirthDate { + if err := s.setRandomWebBirthDate(ctx, credential, adapter); err != nil { + return err + } + } + if options.EnableNSFW { + if err := s.runWebAccountSetting(ctx, credential, "开启 NSFW", func() error { + return adapter.EnableNSFW(ctx, credential) + }); err != nil { + return err + } + if err := s.recordWebAccountState(ctx, credential.ID, "NSFW", s.accounts.MarkWebNSFWEnabled); err != nil { + return err + } + } + return nil +} + +func pendingWebAccountScriptOptions(credential accountdomain.Credential, options WebAccountScriptOptions) WebAccountScriptOptions { + if credential.WebTermsAcceptedAt != nil && credential.WebTermsAcceptedVersion >= accountdomain.CurrentWebTermsVersion { + options.AcceptTerms = false + } + birthDateRecorded := credential.WebBirthDateSetAt != nil || credential.WebNSFWEnabledAt != nil + if birthDateRecorded { + options.SetBirthDate = false + } + if credential.WebNSFWEnabledAt != nil { + options.EnableNSFW = false + } + if options.EnableNSFW && !birthDateRecorded { + options.SetBirthDate = true + } + return options +} + +func (s *Service) recordWebTermsAccepted(ctx context.Context, id uint64) error { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), webAccountStateWriteTimeout) + err := s.accounts.MarkWebTermsAccepted(writeCtx, id, accountdomain.CurrentWebTermsVersion, s.now().UTC()) + cancel() + if err != nil { + return fmt.Errorf("记录服务协议状态: %w", mapRepositoryError(err)) + } + return nil +} + +func (s *Service) setRandomWebBirthDate(ctx context.Context, credential accountdomain.Credential, adapter provider.WebAccountSettingsAdapter) error { + birthDate, err := randomWebBirthDate(s.now().In(time.Local), cryptorand.Reader) + if err != nil { + return fmt.Errorf("生成随机生日: %w", err) + } + err = s.runWebAccountSetting(ctx, credential, "设置生日", func() error { + return adapter.SetBirthDate(ctx, credential, birthDate) + }) + if err != nil && !errors.Is(err, provider.ErrBirthDateAlreadySet) { + return err + } + return s.recordWebAccountState(ctx, credential.ID, "生日", s.accounts.MarkWebBirthDateSet) +} + +func (s *Service) recordWebAccountState(ctx context.Context, id uint64, state string, mark func(context.Context, uint64, time.Time) error) error { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), webAccountStateWriteTimeout) + err := mark(writeCtx, id, s.now().UTC()) + cancel() + if err != nil { + return fmt.Errorf("记录%s状态: %w", state, mapRepositoryError(err)) + } + return nil +} + +func (s *Service) webAccountSettings(ctx context.Context, id uint64) (accountdomain.Credential, provider.WebAccountSettingsAdapter, error) { + credential, err := s.accounts.Get(ctx, id) + if err != nil { + return accountdomain.Credential{}, nil, mapRepositoryError(err) + } + if credential.Provider != accountdomain.ProviderWeb || credential.AuthType != accountdomain.AuthTypeSSO { + return accountdomain.Credential{}, nil, fmt.Errorf("%w: 仅 Grok Web SSO 账号支持该操作", ErrUnsupported) + } + if s.providers == nil { + return accountdomain.Credential{}, nil, fmt.Errorf("Grok Web Provider 未注册") + } + adapter, ok := s.providers.WebAccountSettings() + if !ok { + return accountdomain.Credential{}, nil, fmt.Errorf("%w: Grok Web Provider 不支持账号资料设置", ErrUnsupported) + } + return credential, adapter, nil +} + +func (s *Service) runWebAccountSetting(ctx context.Context, credential accountdomain.Credential, operation string, run func() error) error { + err := run() + if err == nil { + return nil + } + if errors.Is(err, provider.ErrUnauthorized) { + err = errors.Join(err, s.markSSOCredentialRejected(ctx, credential, "Grok Web SSO credential rejected")) + } + return fmt.Errorf("%s: %w", operation, err) +} diff --git a/backend/internal/application/account/web_account_settings_test.go b/backend/internal/application/account/web_account_settings_test.go new file mode 100644 index 0000000000000000000000000000000000000000..19507845a179fcd3c046f1e5bc122eed5d1713bc --- /dev/null +++ b/backend/internal/application/account/web_account_settings_test.go @@ -0,0 +1,253 @@ +package account + +import ( + "bytes" + "context" + "errors" + "io" + "path/filepath" + "sync" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestWebAccountSettingsAreWebOnlyAndGenerateBirthDate(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + webAccount, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "web", SourceKey: "web", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + buildAccount, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "build", SourceKey: "build", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + + if err := service.AcceptWebTerms(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if err := service.SetWebBirthDate(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if err := service.EnableWebNSFW(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + earliest, latest := webBirthDateRange(service.now().In(time.Local)) + if adapter.terms != 1 || adapter.birthDate.Before(earliest) || adapter.birthDate.After(latest) || adapter.nsfw != 1 { + t.Fatalf("adapter terms=%d birth=%v nsfw=%d", adapter.terms, adapter.birthDate, adapter.nsfw) + } + updatedWeb, err := repo.Get(ctx, webAccount.ID) + if err != nil { + t.Fatal(err) + } + if updatedWeb.WebTermsAcceptedAt == nil || updatedWeb.WebTermsAcceptedVersion != accountdomain.CurrentWebTermsVersion || updatedWeb.WebBirthDateSetAt == nil || updatedWeb.WebNSFWEnabledAt == nil || !updatedWeb.WebTermsAcceptedAt.Equal(service.now()) || !updatedWeb.WebBirthDateSetAt.Equal(service.now()) || !updatedWeb.WebNSFWEnabledAt.Equal(service.now()) { + t.Fatalf("profile markers terms=%v version=%d birth=%v nsfw=%v", updatedWeb.WebTermsAcceptedAt, updatedWeb.WebTermsAcceptedVersion, updatedWeb.WebBirthDateSetAt, updatedWeb.WebNSFWEnabledAt) + } + if err := service.AcceptWebTerms(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if err := service.SetWebBirthDate(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if err := service.EnableWebNSFW(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if adapter.terms != 1 || adapter.birthCalls != 1 || adapter.nsfw != 1 { + t.Fatalf("recorded steps repeated: terms=%d birth=%d nsfw=%d", adapter.terms, adapter.birthCalls, adapter.nsfw) + } + if err := service.AcceptWebTerms(ctx, buildAccount.ID); !errors.Is(err, ErrUnsupported) { + t.Fatalf("build err = %v", err) + } + if err := service.SetWebBirthDate(ctx, buildAccount.ID); !errors.Is(err, ErrUnsupported) { + t.Fatalf("build birth err = %v", err) + } +} + +func TestRandomWebBirthDateUsesInclusiveAgeRange(t *testing.T) { + t.Parallel() + // 当地已跨日但 UTC 仍是前一天时,必须按当地日历日期计算年龄。 + now := time.Date(2026, 7, 18, 0, 30, 0, 0, time.FixedZone("test", 8*60*60)) + value, err := randomWebBirthDate(now, bytes.NewReader(make([]byte, 16))) + if err != nil { + t.Fatal(err) + } + earliest := time.Date(1985, 7, 19, 0, 0, 0, 0, time.UTC) + latest := time.Date(2006, 7, 18, 0, 0, 0, 0, time.UTC) + if value.Before(earliest) || value.After(latest) { + t.Fatalf("birth date %s is outside [%s, %s]", value, earliest, latest) + } +} + +func TestPendingWebAccountScriptOptionsSkipsRecordedSteps(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + all := WebAccountScriptOptions{AcceptTerms: true, SetBirthDate: true, EnableNSFW: true} + tests := []struct { + name string + credential accountdomain.Credential + want WebAccountScriptOptions + }{ + {name: "none recorded", want: all}, + {name: "legacy terms marker requires current version", credential: accountdomain.Credential{WebTermsAcceptedAt: &now}, want: all}, + {name: "current terms recorded", credential: accountdomain.Credential{WebTermsAcceptedAt: &now, WebTermsAcceptedVersion: accountdomain.CurrentWebTermsVersion}, want: WebAccountScriptOptions{SetBirthDate: true, EnableNSFW: true}}, + {name: "birth recorded", credential: accountdomain.Credential{WebBirthDateSetAt: &now}, want: WebAccountScriptOptions{AcceptTerms: true, EnableNSFW: true}}, + {name: "nsfw implies birth", credential: accountdomain.Credential{WebNSFWEnabledAt: &now}, want: WebAccountScriptOptions{AcceptTerms: true}}, + {name: "all recorded", credential: accountdomain.Credential{WebTermsAcceptedAt: &now, WebTermsAcceptedVersion: accountdomain.CurrentWebTermsVersion, WebBirthDateSetAt: &now, WebNSFWEnabledAt: &now}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := pendingWebAccountScriptOptions(test.credential, all); got != test.want { + t.Fatalf("options = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestWebBirthDateRangeHandlesLeapDay(t *testing.T) { + t.Parallel() + earliest, latest := webBirthDateRange(time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC)) + if want := time.Date(1983, 3, 1, 0, 0, 0, 0, time.UTC); !earliest.Equal(want) { + t.Fatalf("earliest = %s, want %s", earliest, want) + } + if want := time.Date(2004, 2, 29, 0, 0, 0, 0, time.UTC); !latest.Equal(want) { + t.Fatalf("latest = %s, want %s", latest, want) + } +} + +func TestRandomWebBirthDateRejectsUnavailableRandomSource(t *testing.T) { + t.Parallel() + if _, err := randomWebBirthDate(time.Now(), nil); err == nil { + t.Fatal("expected nil random source error") + } + if _, err := randomWebBirthDate(time.Now(), errorReader{}); err == nil { + t.Fatal("expected random source read error") + } +} + +type errorReader struct{} + +func (errorReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestWebAccountSettingsUnauthorizedMarksReauth(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + credential, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "expired", SourceKey: "expired", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter.err = provider.ErrUnauthorized + if err := service.AcceptWebTerms(ctx, credential.ID); !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } + updated, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if updated.AuthStatus != accountdomain.AuthStatusReauthRequired { + t.Fatalf("auth status = %s", updated.AuthStatus) + } +} + +func newWebAccountSettingsTestService(t *testing.T) (*Service, *relational.AccountRepository, *webAccountSettingsAdapterStub) { + t.Helper() + database, err := relational.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "web-account-settings.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(context.Background()); err != nil { + t.Fatal(err) + } + repo := relational.NewAccountRepository(database) + adapter := &webAccountSettingsAdapterStub{} + service := NewService(repo, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + service.now = func() time.Time { return time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) } + return service, repo, adapter +} + +type webAccountSettingsAdapterStub struct { + mu sync.Mutex + terms int + birthDate time.Time + birthCalls int + nsfw int + err error + calls map[uint64][]string + failures map[uint64]map[string]error + afterCall func(string) + identity provider.AccountIdentity + identityErr error + identityCalls int +} + +func (a *webAccountSettingsAdapterStub) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) { + a.mu.Lock() + a.identityCalls++ + a.mu.Unlock() + return a.identity, a.identityErr +} + +func (*webAccountSettingsAdapterStub) Provider() accountdomain.Provider { + return accountdomain.ProviderWeb +} + +func (a *webAccountSettingsAdapterStub) AcceptTerms(_ context.Context, credential accountdomain.Credential) error { + a.mu.Lock() + defer a.mu.Unlock() + a.terms++ + return a.recordLocked(credential.ID, "acceptTerms") +} + +func (a *webAccountSettingsAdapterStub) SetBirthDate(_ context.Context, credential accountdomain.Credential, value time.Time) error { + a.mu.Lock() + defer a.mu.Unlock() + a.birthDate = value + a.birthCalls++ + return a.recordLocked(credential.ID, "setBirthDate") +} + +func (a *webAccountSettingsAdapterStub) EnableNSFW(_ context.Context, credential accountdomain.Credential) error { + a.mu.Lock() + defer a.mu.Unlock() + a.nsfw++ + return a.recordLocked(credential.ID, "enableNSFW") +} + +func (a *webAccountSettingsAdapterStub) recordLocked(accountID uint64, action string) error { + if a.calls == nil { + a.calls = make(map[uint64][]string) + } + a.calls[accountID] = append(a.calls[accountID], action) + var err error + if failure := a.failures[accountID]; failure != nil { + err = failure[action] + } + if err == nil { + err = a.err + } + if a.afterCall != nil { + a.afterCall(action) + } + return err +} + +func (a *webAccountSettingsAdapterStub) accountCalls(accountID uint64) []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.calls[accountID]...) +} diff --git a/backend/internal/application/account/web_console_sync_test.go b/backend/internal/application/account/web_console_sync_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b3e81432dc49efcc34dcb94f690416828c1e05c8 --- /dev/null +++ b/backend/internal/application/account/web_console_sync_test.go @@ -0,0 +1,248 @@ +package account + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestSyncWebAccountsToConsoleIsIdempotentAndPreservesBuildLink(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-console-sync.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypt := func(value string) string { + encrypted, encryptErr := cipher.Encrypt(value) + if encryptErr != nil { + t.Fatal(encryptErr) + } + return encrypted + } + + accounts := relational.NewAccountRepository(database) + token := "shared-sso-token" + cloudflareCookie := "cf_clearance=shared-clearance; __cf_bm=shared-bm" + webAccount, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "Grok Web primary", SourceKey: "sso:" + security.HashToken(token), + EncryptedAccessToken: encrypt(token), EncryptedCloudflareCookie: encrypt(cloudflareCookie), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + buildAccount, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "build", SourceKey: "build-source", EncryptedAccessToken: encrypt("build-access"), + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := accounts.LinkWebToBuild(ctx, webAccount.ID, buildAccount.ID); err != nil { + t.Fatal(err) + } + var parseCalls atomic.Int64 + service := NewService(accounts, nil, nil, nil, provider.NewRegistry(consoleSSOCodecAdapter{parseCalls: &parseCalls}), cipher, memory.NewLockStore()) + var observed []uint64 + var progress [][2]int + first, err := service.SyncWebAccountsToConsoleWithProgress(ctx, []uint64{webAccount.ID}, func(accountID uint64) error { + observed = append(observed, accountID) + return nil + }, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if first.Created != 1 || first.Updated != 0 || len(first.AccountIDs) != 1 || len(observed) != 1 || observed[0] != first.AccountIDs[0] { + t.Fatalf("first sync = %#v, observed = %#v", first, observed) + } + if len(progress) != 2 || progress[0] != [2]int{0, 1} || progress[1] != [2]int{1, 1} { + t.Fatalf("progress = %#v", progress) + } + consoleAccount, err := accounts.Get(ctx, first.AccountIDs[0]) + if err != nil { + t.Fatal(err) + } + decrypted, err := cipher.Decrypt(consoleAccount.EncryptedAccessToken) + if err != nil { + t.Fatal(err) + } + if consoleAccount.Provider != accountdomain.ProviderConsole || consoleAccount.Name != "Grok Console primary" || decrypted != token { + t.Fatalf("console account = %#v, token = %q", consoleAccount, decrypted) + } + consoleCookie, err := cipher.Decrypt(consoleAccount.EncryptedCloudflareCookie) + if err != nil { + t.Fatal(err) + } + if consoleCookie != cloudflareCookie { + t.Fatalf("console Cloudflare cookie = %q, want %q", consoleCookie, cloudflareCookie) + } + + second, err := service.SyncAllWebAccountsToConsoleWithProgress(ctx, nil, nil) + if err != nil { + t.Fatal(err) + } + if second.Created != 0 || second.Updated != 1 || len(second.AccountIDs) != 1 || second.AccountIDs[0] != consoleAccount.ID { + t.Fatalf("second sync = %#v", second) + } + secondToken := "missing-sso-token" + missingWeb, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "Grok Web missing", SourceKey: "sso:" + security.HashToken(secondToken), + EncryptedAccessToken: encrypt(secondToken), Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + var missingProgress [][2]int + missing, err := service.SyncAllWebAccountsToConsoleWithStrategy(ctx, WebConsoleSyncMissing, nil, func(completed, total int) error { + missingProgress = append(missingProgress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if missing.Created != 1 || missing.Updated != 0 || missing.Skipped != 1 || len(missing.AccountIDs) != 1 || parseCalls.Load() != 3 { + t.Fatalf("missing-only sync = %#v, parse calls = %d", missing, parseCalls.Load()) + } + if len(missingProgress) != 2 || missingProgress[0] != [2]int{0, 1} || missingProgress[1] != [2]int{1, 1} { + t.Fatalf("missing-only progress = %#v", missingProgress) + } + selectedMissing, err := service.SyncWebAccountsToConsoleWithStrategy(ctx, []uint64{missingWeb.ID}, WebConsoleSyncMissing, nil, nil) + if err != nil { + t.Fatal(err) + } + if selectedMissing.Created != 0 || selectedMissing.Updated != 0 || selectedMissing.Skipped != 1 || parseCalls.Load() != 3 { + t.Fatalf("selected missing-only sync = %#v, parse calls = %d", selectedMissing, parseCalls.Load()) + } + updatedWeb, err := accounts.Get(ctx, webAccount.ID) + if err != nil { + t.Fatal(err) + } + if updatedWeb.LinkedAccountID != buildAccount.ID || updatedWeb.LinkedProvider != accountdomain.ProviderBuild { + t.Fatalf("updated web account = %#v", updatedWeb) + } + if len(updatedWeb.LinkedAccounts) != 2 || updatedWeb.LinkedAccounts[0].Provider != accountdomain.ProviderBuild || updatedWeb.LinkedAccounts[1].Provider != accountdomain.ProviderConsole || updatedWeb.LinkedAccounts[1].ID != consoleAccount.ID { + t.Fatalf("updated Web links = %#v", updatedWeb.LinkedAccounts) + } + _, total, err := accounts.List(ctx, repository.AccountListQuery{ + Page: repository.PageQuery{Limit: 10}, Filter: repository.AccountListFilter{Provider: string(accountdomain.ProviderConsole)}, + }) + if err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("console account count = %d", total) + } +} + +func TestSyncAllWebAccountsToConsoleProcessesMoreThanLegacyLimitInBatches(t *testing.T) { + const totalAccounts = maxWebConsoleSyncAccounts + 1 + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + values := make([]accountdomain.Credential, 0, totalAccounts) + for index := 1; index <= totalAccounts; index++ { + token := fmt.Sprintf("sso-token-%d", index) + encrypted, encryptErr := cipher.Encrypt(token) + if encryptErr != nil { + t.Fatal(encryptErr) + } + values = append(values, accountdomain.Credential{ + ID: uint64(index), Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: fmt.Sprintf("Grok Web %d", index), SourceKey: "sso:" + security.HashToken(token), + EncryptedAccessToken: encrypted, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + } + repository := &webConsoleBatchRepository{values: values} + service := NewService(repository, nil, nil, nil, provider.NewRegistry(consoleSSOCodecAdapter{}), cipher, memory.NewLockStore()) + progress := make([][2]int, 0, totalAccounts+1) + result, err := service.SyncAllWebAccountsToConsoleWithProgress(context.Background(), nil, func(completed, total int) error { + progress = append(progress, [2]int{completed, total}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Created != totalAccounts || result.Updated != 0 || len(result.AccountIDs) != totalAccounts { + t.Fatalf("sync result = %#v", result) + } + if repository.listCalls != 2 { + t.Fatalf("repository batches = %d", repository.listCalls) + } + if len(progress) != totalAccounts+1 || progress[0] != [2]int{0, totalAccounts} || progress[len(progress)-1] != [2]int{totalAccounts, totalAccounts} { + t.Fatalf("progress first=%v last=%v count=%d", progress[0], progress[len(progress)-1], len(progress)) + } +} + +type webConsoleBatchRepository struct { + repository.AccountRepository + values []accountdomain.Credential + listCalls int + nextID atomic.Uint64 +} + +func (r *webConsoleBatchRepository) ListProviderAccountBatch(_ context.Context, providerValue accountdomain.Provider, afterID uint64, limit int) ([]accountdomain.Credential, int64, error) { + r.listCalls++ + values := make([]accountdomain.Credential, 0, limit) + for _, value := range r.values { + if value.Provider == providerValue && value.ID > afterID { + values = append(values, value) + if len(values) == limit { + break + } + } + } + return values, int64(len(r.values)), nil +} + +func (r *webConsoleBatchRepository) UpsertManyByIdentity(_ context.Context, values []accountdomain.Credential) ([]repository.AccountUpsertResult, error) { + results := make([]repository.AccountUpsertResult, len(values)) + for index := range values { + results[index] = repository.AccountUpsertResult{ID: 10_000 + r.nextID.Add(1), Created: true} + } + return results, nil +} + +type consoleSSOCodecAdapter struct{ parseCalls *atomic.Int64 } + +func (consoleSSOCodecAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderConsole } + +func (a consoleSSOCodecAdapter) ParseImportedCredentials(data []byte) ([]provider.CredentialSeed, error) { + if a.parseCalls != nil { + a.parseCalls.Add(1) + } + token := strings.TrimSpace(string(data)) + return []provider.CredentialSeed{{ + Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, + Name: "Grok Console " + security.HashToken(token)[:8], SourceKey: "console-sso:" + security.HashToken(token), AccessToken: token, + }}, nil +} + +func (consoleSSOCodecAdapter) MarshalCredentials([]provider.CredentialSeed) ([]byte, error) { + return nil, nil +} diff --git a/backend/internal/application/accountsync/service.go b/backend/internal/application/accountsync/service.go new file mode 100644 index 0000000000000000000000000000000000000000..ee82fee8345ece0a1e2002ed91f3c2aab462adbd --- /dev/null +++ b/backend/internal/application/accountsync/service.go @@ -0,0 +1,270 @@ +package accountsync + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "sync" + "sync/atomic" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" + "golang.org/x/sync/singleflight" +) + +const ( + defaultWorkerCount = 25 + operationTimeout = 2 * time.Minute +) + +type billingSynchronizer interface { + HasBillingSnapshot(ctx context.Context, accountID uint64) (bool, error) + RefreshBilling(ctx context.Context, accountID uint64) (accountdomain.Billing, error) +} + +type modelSynchronizer interface { + HasSuccessfulAccountSync(ctx context.Context, accountID uint64) (bool, error) + SyncAccount(ctx context.Context, accountID uint64) (int, error) +} + +type accountReader interface { + Get(ctx context.Context, id uint64) (accountapp.View, error) +} + +type providerPolicy interface { + ProviderDefinition(value accountdomain.Provider) (provider.Definition, bool) +} + +type quotaSynchronizer interface { + HasQuotaWindows(ctx context.Context, accountID uint64) (bool, error) + RefreshQuota(ctx context.Context, accountID uint64) ([]accountdomain.QuotaWindow, error) +} + +type identitySynchronizer interface { + SyncAccountIdentity(ctx context.Context, accountID uint64) error +} + +// Service 对新接入账号执行一次性额度与模型补齐,并限制批量同步并发。 +type Service struct { + logger *slog.Logger + accounts accountReader + billing billingSynchronizer + quota quotaSynchronizer + models modelSynchronizer + syncs singleflight.Group + workers atomic.Int64 + bulkPool *batch.Pool +} + +func NewService(logger *slog.Logger, accounts accountReader, billing billingSynchronizer, quota quotaSynchronizer, models modelSynchronizer) *Service { + service := &Service{logger: logger, accounts: accounts, billing: billing, quota: quota, models: models, bulkPool: batch.NewPool(defaultWorkerCount)} + service.workers.Store(defaultWorkerCount) + return service +} + +func (s *Service) SetBulkPool(pool *batch.Pool) { + if pool != nil { + s.bulkPool = pool + } +} + +func (s *Service) UpdateConcurrency(value int) { + if value < 1 { + value = defaultWorkerCount + } + s.workers.Store(int64(value)) + s.bulkPool.UpdateLimit(value) +} + +// SyncModels 强制刷新指定账号的模型能力,不受初始同步快照跳过规则影响。 +func (s *Service) SyncModels(ctx context.Context, accountID uint64) error { + if accountID == 0 { + return errors.New("账号 ID 无效") + } + if s.models == nil { + return errors.New("模型同步器未初始化") + } + operationCtx, cancel := context.WithTimeout(ctx, operationTimeout) + defer cancel() + _, err := s.models.SyncAccount(operationCtx, accountID) + if err != nil { + s.logger.Warn("account_model_sync_failed", "account_id", accountID, "error", err) + return fmt.Errorf("同步模型: %w", err) + } + return nil +} + +// Result 汇总本次初始同步成功与失败的账号数。 +type Result struct { + Succeeded int + Failed int +} + +// Sync 等待本次涉及的账号完成额度与模型补齐;已同步数据会跳过,同账号并发请求会合并。 +func (s *Service) Sync(ctx context.Context, accountIDs ...uint64) Result { + input := make(chan uint64, len(accountIDs)) + for _, accountID := range accountIDs { + input <- accountID + } + close(input) + return s.SyncStream(ctx, input) +} + +// SyncStream 以固定 Worker 数消费持续到达的账号,使导入与上游同步形成有界流水线。 +func (s *Service) SyncStream(ctx context.Context, accountIDs <-chan uint64) Result { + return s.syncStream(ctx, accountIDs, nil) +} + +// SyncStreamObserved 在每个去重账号完成初始同步后报告进度。 +func (s *Service) SyncStreamObserved(ctx context.Context, accountIDs <-chan uint64, observer func(completed, total int)) Result { + return s.syncStream(ctx, accountIDs, observer) +} + +func (s *Service) syncStream(ctx context.Context, accountIDs <-chan uint64, observer func(completed, total int)) Result { + jobs := make(chan uint64) + var workers sync.WaitGroup + var succeeded atomic.Int64 + var failed atomic.Int64 + var total atomic.Int64 + var progressMu sync.Mutex + completed := 0 + count := max(1, int(s.workers.Load())) + workers.Add(count) + for range count { + go func() { + defer workers.Done() + for accountID := range jobs { + err := s.bulkPool.Do(ctx, func(workCtx context.Context) error { + _, syncErr, _ := s.syncs.Do(strconv.FormatUint(accountID, 10), func() (any, error) { + return nil, s.syncAccount(workCtx, accountID) + }) + return syncErr + }) + if err != nil { + var panicErr *batch.PanicError + if errors.As(err, &panicErr) { + s.logger.Error("account_initial_sync_panicked", "account_id", accountID, "error", panicErr, "stack", string(panicErr.Stack)) + } + failed.Add(1) + } else { + succeeded.Add(1) + } + if observer != nil { + progressMu.Lock() + completed++ + observer(completed, int(total.Load())) + progressMu.Unlock() + } + } + }() + } + seen := make(map[uint64]struct{}) +sendLoop: + for { + select { + case <-ctx.Done(): + break sendLoop + case accountID, ok := <-accountIDs: + if !ok { + break sendLoop + } + if accountID == 0 { + continue + } + if _, exists := seen[accountID]; exists { + continue + } + seen[accountID] = struct{}{} + total.Add(1) + select { + case jobs <- accountID: + case <-ctx.Done(): + break sendLoop + } + } + } + close(jobs) + workers.Wait() + return Result{Succeeded: int(succeeded.Load()), Failed: int(failed.Load())} +} + +func (s *Service) syncAccount(ctx context.Context, accountID uint64) error { + var syncErr error + billingSnapshotCreated := false + view, err := s.accounts.Get(ctx, accountID) + if err != nil { + return fmt.Errorf("读取账号: %w", err) + } + policy, ok := s.accounts.(providerPolicy) + if !ok { + return fmt.Errorf("账号读取器未提供 Provider 生命周期策略") + } + definition, ok := policy.ProviderDefinition(view.Credential.Provider) + if !ok { + return fmt.Errorf("Provider %s 未注册生命周期策略", view.Credential.Provider) + } + if view.Credential.Provider == accountdomain.ProviderWeb || view.Credential.Provider == accountdomain.ProviderConsole { + if identity, ok := s.accounts.(identitySynchronizer); ok { + operationCtx, cancel := context.WithTimeout(ctx, operationTimeout) + identityErr := identity.SyncAccountIdentity(operationCtx, accountID) + if identityErr != nil { + s.logger.Warn("account_initial_identity_sync_failed", "account_id", accountID, "error", identityErr) + } + cancel() + if errors.Is(identityErr, provider.ErrUnauthorized) { + return fmt.Errorf("同步账号身份: %w", identityErr) + } + } + } + if definition.Quota == provider.QuotaRemoteWindow || definition.Quota == provider.QuotaLocalWindow { + hasQuota, quotaErr := s.quota.HasQuotaWindows(ctx, accountID) + if quotaErr != nil { + syncErr = errors.Join(syncErr, fmt.Errorf("检查 Provider 额度快照: %w", quotaErr)) + } else if !hasQuota { + operationCtx, cancel := context.WithTimeout(ctx, operationTimeout) + _, quotaErr = s.quota.RefreshQuota(operationCtx, accountID) + cancel() + if quotaErr != nil { + syncErr = errors.Join(syncErr, fmt.Errorf("同步 Provider 额度: %w", quotaErr)) + } + } + } else { + hasBilling, err := s.billing.HasBillingSnapshot(ctx, accountID) + if err != nil { + s.logger.Warn("account_initial_billing_check_failed", "account_id", accountID, "error", err) + syncErr = errors.Join(syncErr, fmt.Errorf("检查额度快照: %w", err)) + } else if !hasBilling { + operationCtx, cancel := context.WithTimeout(ctx, operationTimeout) + _, err = s.billing.RefreshBilling(operationCtx, accountID) + cancel() + if err != nil { + s.logger.Warn("account_initial_billing_sync_failed", "account_id", accountID, "error", err) + syncErr = errors.Join(syncErr, fmt.Errorf("同步额度: %w", err)) + } else { + billingSnapshotCreated = true + } + } + } + + hasModels, err := s.models.HasSuccessfulAccountSync(ctx, accountID) + if err != nil { + s.logger.Warn("account_initial_model_check_failed", "account_id", accountID, "error", err) + return errors.Join(syncErr, fmt.Errorf("检查模型快照: %w", err)) + } + if hasModels && !billingSnapshotCreated { + return syncErr + } + operationCtx, cancel := context.WithTimeout(ctx, operationTimeout) + _, err = s.models.SyncAccount(operationCtx, accountID) + cancel() + if err != nil { + s.logger.Warn("account_initial_model_sync_failed", "account_id", accountID, "error", err) + syncErr = errors.Join(syncErr, fmt.Errorf("同步模型: %w", err)) + } + return syncErr +} diff --git a/backend/internal/application/accountsync/service_test.go b/backend/internal/application/accountsync/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1b9044e02762e1a7ad9c15b38b1dd4d8b98bf918 --- /dev/null +++ b/backend/internal/application/accountsync/service_test.go @@ -0,0 +1,457 @@ +package accountsync + +import ( + "context" + "encoding/base64" + "errors" + "log/slog" + "net/http" + "path/filepath" + "sync" + "testing" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + modelapp "github.com/chenyme/grok2api/backend/internal/application/model" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +type billingStub struct { + mu sync.Mutex + hasSnapshot bool + checks int + syncs int + checkErr error + syncErr error +} + +type accountReaderStub struct { + provider accountdomain.Provider + quota provider.QuotaKind +} + +type identityAccountReaderStub struct { + accountReaderStub + err error + calls int +} + +func (s *identityAccountReaderStub) SyncAccountIdentity(context.Context, uint64) error { + s.calls++ + return s.err +} + +func (s accountReaderStub) Get(context.Context, uint64) (accountapp.View, error) { + return accountapp.View{Credential: accountdomain.Credential{Provider: s.provider}}, nil +} + +func (s accountReaderStub) ProviderDefinition(value accountdomain.Provider) (provider.Definition, bool) { + quota := provider.QuotaBilling + if s.quota != "" { + quota = s.quota + } else if value == accountdomain.ProviderWeb { + quota = provider.QuotaRemoteWindow + } else if value == accountdomain.ProviderConsole { + quota = provider.QuotaLocalWindow + } + return provider.Definition{Provider: value, Quota: quota}, value.IsValid() +} + +type quotaStub struct { + hasSnapshot bool + checks int + syncs int +} + +func (s *quotaStub) HasQuotaWindows(context.Context, uint64) (bool, error) { + s.checks++ + return s.hasSnapshot, nil +} + +func (s *quotaStub) RefreshQuota(context.Context, uint64) ([]accountdomain.QuotaWindow, error) { + s.syncs++ + return []accountdomain.QuotaWindow{{Mode: "console", Remaining: 20}}, nil +} + +func (s *billingStub) HasBillingSnapshot(context.Context, uint64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.checks++ + return s.hasSnapshot, s.checkErr +} + +func (s *billingStub) RefreshBilling(context.Context, uint64) (accountdomain.Billing, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.syncs++ + return accountdomain.Billing{}, s.syncErr +} + +func (s *billingStub) counts() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.checks, s.syncs +} + +type modelStub struct { + mu sync.Mutex + hasSnapshot bool + checks int + syncs int + checkErr error + syncErr error +} + +func (s *modelStub) HasSuccessfulAccountSync(context.Context, uint64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.checks++ + return s.hasSnapshot, s.checkErr +} + +func (s *modelStub) SyncAccount(context.Context, uint64) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.syncs++ + return 1, s.syncErr +} + +func (s *modelStub) counts() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.checks, s.syncs +} + +func TestSyncAccountSkipsExistingSnapshots(t *testing.T) { + billing := &billingStub{hasSnapshot: true} + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, billing, nil, models) + + if err := service.syncAccount(context.Background(), 1); err != nil { + t.Fatal(err) + } + + billingChecks, billingSyncs := billing.counts() + modelChecks, modelSyncs := models.counts() + if billingChecks != 1 || billingSyncs != 0 || modelChecks != 1 || modelSyncs != 0 { + t.Fatalf("billing = %d/%d, models = %d/%d", billingChecks, billingSyncs, modelChecks, modelSyncs) + } +} + +func TestSyncAccountFetchesOnlyMissingSnapshots(t *testing.T) { + billing := &billingStub{hasSnapshot: true} + models := &modelStub{} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, billing, nil, models) + + if err := service.syncAccount(context.Background(), 7); err != nil { + t.Fatal(err) + } + + _, billingSyncs := billing.counts() + _, modelSyncs := models.counts() + if billingSyncs != 0 || modelSyncs != 1 { + t.Fatalf("billing syncs = %d, model syncs = %d", billingSyncs, modelSyncs) + } +} + +func TestSyncAccountRecomputesModelsAfterCreatingBillingSnapshot(t *testing.T) { + billing := &billingStub{} + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, billing, nil, models) + + if err := service.syncAccount(context.Background(), 8); err != nil { + t.Fatal(err) + } + + _, billingSyncs := billing.counts() + _, modelSyncs := models.counts() + if billingSyncs != 1 || modelSyncs != 1 { + t.Fatalf("billing syncs = %d, model syncs = %d", billingSyncs, modelSyncs) + } +} + +func TestSyncModelsAlwaysRefreshesExistingCapabilitySnapshot(t *testing.T) { + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, &billingStub{}, nil, models) + + if err := service.SyncModels(context.Background(), 8); err != nil { + t.Fatal(err) + } + checks, syncs := models.counts() + if checks != 0 || syncs != 1 { + t.Fatalf("model checks/syncs = %d/%d", checks, syncs) + } +} + +func TestSyncModelsPropagatesRefreshFailure(t *testing.T) { + models := &modelStub{syncErr: errors.New("upstream unavailable")} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, &billingStub{}, nil, models) + + if err := service.SyncModels(context.Background(), 9); err == nil { + t.Fatal("expected model sync error") + } +} + +func TestSyncAccountUsesQuotaForConsoleProvider(t *testing.T) { + billing := &billingStub{} + quota := "aStub{} + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderConsole}, billing, quota, models) + + if err := service.syncAccount(context.Background(), 9); err != nil { + t.Fatal(err) + } + + billingChecks, billingSyncs := billing.counts() + if billingChecks != 0 || billingSyncs != 0 || quota.checks != 1 || quota.syncs != 1 { + t.Fatalf("billing = %d/%d, quota = %d/%d", billingChecks, billingSyncs, quota.checks, quota.syncs) + } +} + +func TestSyncAccountIgnoresBestEffortSSOIdentityFailure(t *testing.T) { + for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} { + t.Run(string(providerValue), func(t *testing.T) { + reader := &identityAccountReaderStub{accountReaderStub: accountReaderStub{provider: providerValue}, err: errors.New("session unavailable")} + quota := "aStub{} + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), reader, &billingStub{}, quota, models) + + if err := service.syncAccount(context.Background(), 10); err != nil { + t.Fatal(err) + } + if reader.calls != 1 || quota.syncs != 1 { + t.Fatalf("identity calls=%d quota syncs=%d", reader.calls, quota.syncs) + } + }) + } +} + +func TestSyncAccountStopsAfterSSOIdentityUnauthorized(t *testing.T) { + for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} { + t.Run(string(providerValue), func(t *testing.T) { + reader := &identityAccountReaderStub{accountReaderStub: accountReaderStub{provider: providerValue}, err: provider.ErrUnauthorized} + quota := "aStub{} + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), reader, &billingStub{}, quota, models) + + if err := service.syncAccount(context.Background(), 10); !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } + if reader.calls != 1 || quota.syncs != 0 { + t.Fatalf("identity calls=%d quota syncs=%d", reader.calls, quota.syncs) + } + }) + } +} + +func TestSyncAccountUsesDeclaredQuotaPolicyInsteadOfProviderName(t *testing.T) { + billing := &billingStub{} + quota := "aStub{} + models := &modelStub{hasSnapshot: true} + reader := accountReaderStub{provider: accountdomain.ProviderBuild, quota: provider.QuotaRemoteWindow} + service := NewService(slog.Default(), reader, billing, quota, models) + + if err := service.syncAccount(context.Background(), 10); err != nil { + t.Fatal(err) + } + billingChecks, billingSyncs := billing.counts() + if billingChecks != 0 || billingSyncs != 0 || quota.checks != 1 || quota.syncs != 1 { + t.Fatalf("billing = %d/%d, quota = %d/%d", billingChecks, billingSyncs, quota.checks, quota.syncs) + } +} + +func TestSyncDeduplicatesAccountsAndWaitsForCompletion(t *testing.T) { + billing := &billingStub{} + models := &modelStub{} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, billing, nil, models) + result := service.Sync(context.Background(), 1, 1, 2, 0) + if result.Succeeded != 2 || result.Failed != 0 { + t.Fatalf("result = %#v", result) + } + billingChecks, billingSyncs := billing.counts() + modelChecks, modelSyncs := models.counts() + if billingChecks != 2 || billingSyncs != 2 || modelChecks != 2 || modelSyncs != 2 { + t.Fatalf("billing = %d/%d, models = %d/%d", billingChecks, billingSyncs, modelChecks, modelSyncs) + } +} + +func TestSyncStreamStartsBeforeImportCompletesAndDeduplicates(t *testing.T) { + billing := &billingStub{} + models := &modelStub{} + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, billing, nil, models) + service.UpdateConcurrency(10) + input := make(chan uint64) + done := make(chan Result, 1) + go func() { done <- service.SyncStream(context.Background(), input) }() + + input <- 1 + deadline := time.Now().Add(time.Second) + for { + checks, _ := billing.counts() + if checks > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("stream did not start before input closed") + } + time.Sleep(time.Millisecond) + } + input <- 1 + input <- 2 + close(input) + + result := <-done + if result.Succeeded != 2 || result.Failed != 0 { + t.Fatalf("result = %#v", result) + } + checks, syncs := billing.counts() + if checks != 2 || syncs != 2 { + t.Fatalf("billing = %d/%d", checks, syncs) + } +} + +func TestSyncStreamObservedReportsDeduplicatedCompletion(t *testing.T) { + service := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, &billingStub{}, nil, &modelStub{}) + input := make(chan uint64, 3) + input <- 1 + input <- 1 + input <- 2 + close(input) + progress := make([]int, 0, 2) + totals := make([]int, 0, 2) + + result := service.SyncStreamObserved(context.Background(), input, func(completed, total int) { + progress = append(progress, completed) + totals = append(totals, total) + }) + if result.Succeeded != 2 || len(progress) != 2 || progress[0] != 1 || progress[1] != 2 { + t.Fatalf("result = %#v, progress = %#v, totals = %#v", result, progress, totals) + } + if len(totals) != 2 || totals[0] < progress[0] || totals[1] != 2 { + t.Fatalf("progress = %#v, totals = %#v", progress, totals) + } +} + +func TestSyncReportsInitialSyncFailure(t *testing.T) { + billing := &billingStub{syncErr: errors.New("billing unavailable")} + models := &modelStub{syncErr: errors.New("models unavailable")} + result := NewService(slog.Default(), accountReaderStub{provider: accountdomain.ProviderBuild}, billing, nil, models).Sync(context.Background(), 9) + if result.Succeeded != 0 || result.Failed != 1 { + t.Fatalf("result = %#v", result) + } + _, billingSyncs := billing.counts() + _, modelSyncs := models.counts() + if billingSyncs != 1 || modelSyncs != 1 { + t.Fatalf("billing syncs = %d, model syncs = %d", billingSyncs, modelSyncs) + } +} + +func TestInitialSyncDoesNotRepeatUpstreamRequestsForSyncedAccount(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "initial-sync.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + + accounts := relational.NewAccountRepository(database) + models := relational.NewModelRepository(database) + audits := relational.NewAuditRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "initial", SourceKey: "initial", EncryptedAccessToken: encrypted, + ExpiresAt: time.Now().UTC().Add(time.Hour), AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := &countingAdapter{} + registry := provider.NewRegistry(adapter) + accountService := accountapp.NewService(accounts, audits, memory.NewDeviceSessionStore(), memory.NewStickyStore(), registry, cipher, nil) + modelService := modelapp.NewService(models, accounts, accountService, registry) + service := NewService(slog.Default(), accountService, accountService, accountService, modelService) + + if err := service.syncAccount(ctx, credential.ID); err != nil { + t.Fatal(err) + } + if err := service.syncAccount(ctx, credential.ID); err != nil { + t.Fatal(err) + } + + billingCalls, modelCalls := adapter.counts() + if billingCalls != 1 || modelCalls != 1 { + t.Fatalf("billing calls = %d, model calls = %d", billingCalls, modelCalls) + } +} + +type countingAdapter struct { + mu sync.Mutex + billingCalls int + modelCalls int +} + +func (a *countingAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderBuild } + +func (a *countingAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: accountdomain.ProviderBuild, Quota: provider.QuotaBilling, + Credential: provider.CredentialSurface{Refresh: true}, + } +} + +func (a *countingAdapter) ListModels(context.Context, accountdomain.Credential) ([]string, error) { + a.mu.Lock() + a.modelCalls++ + a.mu.Unlock() + return []string{"grok-initial"}, nil +} + +func (a *countingAdapter) GetBilling(context.Context, accountdomain.Credential) (accountdomain.Billing, error) { + a.mu.Lock() + a.billingCalls++ + a.mu.Unlock() + return accountdomain.Billing{SyncedAt: time.Now().UTC()}, nil +} + +func (a *countingAdapter) counts() (int, int) { + a.mu.Lock() + defer a.mu.Unlock() + return a.billingCalls, a.modelCalls +} + +func (a *countingAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{StatusCode: http.StatusOK}, nil +} + +func (a *countingAdapter) RefreshCredential(context.Context, accountdomain.Credential) (provider.RefreshedCredential, error) { + return provider.RefreshedCredential{}, nil +} + +func (a *countingAdapter) StartDeviceAuthorization(context.Context) (provider.DeviceAuthorization, error) { + return provider.DeviceAuthorization{}, nil +} + +func (a *countingAdapter) PollDeviceAuthorization(context.Context, string) (provider.CredentialSeed, error) { + return provider.CredentialSeed{}, nil +} + +func (a *countingAdapter) ParseImportedCredentials([]byte) ([]provider.CredentialSeed, error) { + return nil, nil +} +func (a *countingAdapter) MarshalCredentials([]provider.CredentialSeed) ([]byte, error) { + return nil, nil +} diff --git a/backend/internal/application/adminauth/service.go b/backend/internal/application/adminauth/service.go new file mode 100644 index 0000000000000000000000000000000000000000..512a529cea7dcb9723d3885e23348d75d57e0f74 --- /dev/null +++ b/backend/internal/application/adminauth/service.go @@ -0,0 +1,231 @@ +package adminauth + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/admin" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrInvalidCredentials = errors.New("管理员账号或密码错误") + ErrInvalidSession = errors.New("管理员会话无效") + ErrBootstrapRequired = errors.New("首次启动需要设置管理员账号和密码") + ErrInvalidPassword = errors.New("新密码至少需要 8 个字符") + ErrLoginRateLimited = errors.New("管理员登录尝试过于频繁") + ErrRuntimeUnavailable = errors.New("管理员认证运行态暂不可用") +) + +type Tokens struct { + AccessToken string + AccessTokenExpiresAt time.Time + RefreshToken string + RefreshTokenExpiresAt time.Time +} + +// Service 负责编排单管理员登录、JWT 和 refresh session 生命周期。 +type Service struct { + admins repository.AdminRepository + sessions repository.AdminSessionRepository + tokens *security.TokenService + accessTTL time.Duration + refreshTTL time.Duration + loginLimiter repository.RateLimiter + dummyPasswordHash string +} + +func NewService(admins repository.AdminRepository, sessions repository.AdminSessionRepository, tokens *security.TokenService, accessTTL, refreshTTL time.Duration) *Service { + dummyHash, _ := security.HashPassword("grok2api-invalid-admin-password") + return &Service{admins: admins, sessions: sessions, tokens: tokens, accessTTL: accessTTL, refreshTTL: refreshTTL, dummyPasswordHash: dummyHash} +} + +func (s *Service) SetLoginRateLimiter(limiter repository.RateLimiter) { s.loginLimiter = limiter } + +// Bootstrap 在数据库没有管理员时创建唯一管理员。 +func (s *Service) Bootstrap(ctx context.Context, username, password string) error { + count, err := s.admins.Count(ctx) + if err != nil { + return err + } + if count > 0 { + return nil + } + if strings.TrimSpace(username) == "" || len(password) < 8 { + return ErrBootstrapRequired + } + hash, err := security.HashPassword(password) + if err != nil { + return err + } + _, err = s.admins.Create(ctx, admin.Admin{Username: strings.TrimSpace(username), PasswordHash: hash}) + return err +} + +// Login 校验密码并创建新的可撤销 refresh session。 +func (s *Service) Login(ctx context.Context, username, password, remoteAddress string) (admin.Admin, Tokens, error) { + username = strings.TrimSpace(username) + if err := s.checkLoginRate(ctx, username, remoteAddress); err != nil { + return admin.Admin{}, Tokens{}, err + } + value, err := s.admins.GetByUsername(ctx, username) + if err != nil { + _ = security.VerifyPassword(s.dummyPasswordHash, password) + if !errors.Is(err, repository.ErrNotFound) { + return admin.Admin{}, Tokens{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return admin.Admin{}, Tokens{}, ErrInvalidCredentials + } + if !security.VerifyPassword(value.PasswordHash, password) { + return admin.Admin{}, Tokens{}, ErrInvalidCredentials + } + tokens, _, err := s.createSession(ctx, value.ID) + return value, tokens, err +} + +// Refresh 轮换 refresh token,旧 token 立即失效。 +func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (Tokens, error) { + hash := security.HashToken(rawRefreshToken) + session, err := s.sessions.GetByTokenHash(ctx, hash) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return Tokens{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return Tokens{}, ErrInvalidSession + } + if !time.Now().UTC().Before(session.ExpiresAt) { + return Tokens{}, ErrInvalidSession + } + adminValue, err := s.admins.GetByID(ctx, session.AdminID) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return Tokens{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return Tokens{}, ErrInvalidSession + } + accessToken, accessExpiresAt, err := s.tokens.CreateAccessToken(adminValue.ID, session.ID, s.accessTTL) + if err != nil { + return Tokens{}, err + } + refreshToken, err := security.NewOpaqueToken(32) + if err != nil { + return Tokens{}, err + } + refreshExpiresAt := time.Now().UTC().Add(s.refreshTTL) + if err := s.sessions.Rotate(ctx, session.ID, hash, security.HashToken(refreshToken), refreshExpiresAt); err != nil { + if errors.Is(err, repository.ErrConflict) || errors.Is(err, repository.ErrNotFound) { + return Tokens{}, ErrInvalidSession + } + return Tokens{}, err + } + return Tokens{AccessToken: accessToken, AccessTokenExpiresAt: accessExpiresAt, RefreshToken: refreshToken, RefreshTokenExpiresAt: refreshExpiresAt}, nil +} + +// Logout 撤销当前 refresh session。 +func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error { + session, err := s.sessions.GetByTokenHash(ctx, security.HashToken(rawRefreshToken)) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil + } + return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + if err := s.sessions.Revoke(ctx, session.ID); err != nil && !errors.Is(err, repository.ErrNotFound) { + return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return nil +} + +// AuthenticateAccess 校验 access token 并读取管理员。 +func (s *Service) AuthenticateAccess(ctx context.Context, rawAccessToken string) (admin.Admin, error) { + identity, err := s.tokens.ParseAccessToken(rawAccessToken) + if err != nil { + return admin.Admin{}, ErrInvalidSession + } + session, err := s.sessions.GetByID(ctx, identity.SessionID) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return admin.Admin{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return admin.Admin{}, ErrInvalidSession + } + if session.AdminID != identity.AdminID || !time.Now().UTC().Before(session.ExpiresAt) { + return admin.Admin{}, ErrInvalidSession + } + value, err := s.admins.GetByID(ctx, identity.AdminID) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return admin.Admin{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return admin.Admin{}, ErrInvalidSession + } + return value, nil +} + +// ChangePassword 修改密码并撤销管理员的全部 refresh session。 +func (s *Service) ChangePassword(ctx context.Context, adminID uint64, currentPassword, newPassword string) error { + if len(newPassword) < 8 { + return ErrInvalidPassword + } + value, err := s.admins.GetByID(ctx, adminID) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + return ErrInvalidCredentials + } + if !security.VerifyPassword(value.PasswordHash, currentPassword) { + return ErrInvalidCredentials + } + hash, err := security.HashPassword(newPassword) + if err != nil { + return err + } + return s.admins.UpdatePasswordAndRevokeSessions(ctx, adminID, hash) +} + +func (s *Service) createSession(ctx context.Context, adminID uint64) (Tokens, admin.Session, error) { + refreshToken, err := security.NewOpaqueToken(32) + if err != nil { + return Tokens{}, admin.Session{}, err + } + refreshExpiresAt := time.Now().UTC().Add(s.refreshTTL) + session, err := s.sessions.Create(ctx, admin.Session{AdminID: adminID, RefreshTokenHash: security.HashToken(refreshToken), ExpiresAt: refreshExpiresAt}) + if err != nil { + return Tokens{}, admin.Session{}, err + } + accessToken, accessExpiresAt, err := s.tokens.CreateAccessToken(adminID, session.ID, s.accessTTL) + if err != nil { + _ = s.sessions.Revoke(ctx, session.ID) + return Tokens{}, admin.Session{}, err + } + return Tokens{AccessToken: accessToken, AccessTokenExpiresAt: accessExpiresAt, RefreshToken: refreshToken, RefreshTokenExpiresAt: refreshExpiresAt}, session, err +} + +func (s *Service) checkLoginRate(ctx context.Context, username, remoteAddress string) error { + if s.loginLimiter == nil { + return nil + } + now := time.Now().UTC() + keys := []struct { + key string + limit int + }{ + {key: "admin-login:ip:" + security.HashToken(strings.TrimSpace(remoteAddress)), limit: 30}, + {key: "admin-login:user:" + security.HashToken(strings.ToLower(username)), limit: 12}, + } + for _, item := range keys { + allowed, err := s.loginLimiter.Allow(ctx, item.key, item.limit, now) + if err != nil { + return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err) + } + if !allowed { + return ErrLoginRateLimited + } + } + return nil +} diff --git a/backend/internal/application/adminauth/service_test.go b/backend/internal/application/adminauth/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5e4f29f6f90148243775255f9b7ca2310f310086 --- /dev/null +++ b/backend/internal/application/adminauth/service_test.go @@ -0,0 +1,208 @@ +package adminauth + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/admin" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestRefreshTokenRotationAndLogout(t *testing.T) { + database, err := relational.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "auth.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(context.Background()); err != nil { + t.Fatal(err) + } + service := NewService(relational.NewAdminRepository(database), relational.NewAdminSessionRepository(database), security.NewTokenService("12345678901234567890123456789012"), time.Minute, time.Hour) + ctx := context.Background() + if err := service.Bootstrap(ctx, "admin", "password123"); err != nil { + t.Fatal(err) + } + _, tokens, err := service.Login(ctx, "admin", "password123", "127.0.0.1") + if err != nil { + t.Fatal(err) + } + rotated, err := service.Refresh(ctx, tokens.RefreshToken) + if err != nil { + t.Fatal(err) + } + if _, err := service.Refresh(ctx, tokens.RefreshToken); !errors.Is(err, ErrInvalidSession) { + t.Fatalf("旧 refresh token 仍可使用: %v", err) + } + if err := service.Logout(ctx, rotated.RefreshToken); err != nil { + t.Fatal(err) + } + if _, err := service.AuthenticateAccess(ctx, rotated.AccessToken); !errors.Is(err, ErrInvalidSession) { + t.Fatalf("注销后的 access token 仍可使用: %v", err) + } + if _, err := service.Refresh(ctx, rotated.RefreshToken); !errors.Is(err, ErrInvalidSession) { + t.Fatalf("注销后的 refresh token 仍可使用: %v", err) + } +} + +func TestChangePasswordRevokesAllSessions(t *testing.T) { + database, err := relational.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "auth.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(context.Background()); err != nil { + t.Fatal(err) + } + service := NewService(relational.NewAdminRepository(database), relational.NewAdminSessionRepository(database), security.NewTokenService("12345678901234567890123456789012"), time.Minute, time.Hour) + ctx := context.Background() + if err := service.Bootstrap(ctx, "admin", "password123"); err != nil { + t.Fatal(err) + } + adminValue, tokens, err := service.Login(ctx, "admin", "password123", "127.0.0.1") + if err != nil { + t.Fatal(err) + } + if err := service.ChangePassword(ctx, adminValue.ID, "password123", "password456"); err != nil { + t.Fatal(err) + } + if _, err := service.AuthenticateAccess(ctx, tokens.AccessToken); !errors.Is(err, ErrInvalidSession) { + t.Fatalf("修改密码后的 access token 仍可使用: %v", err) + } + if _, err := service.Refresh(ctx, tokens.RefreshToken); !errors.Is(err, ErrInvalidSession) { + t.Fatalf("修改密码后的 refresh token 仍可使用: %v", err) + } + if _, _, err := service.Login(ctx, "admin", "password123", "127.0.0.1"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("旧密码仍可登录: %v", err) + } + if _, _, err := service.Login(ctx, "admin", "password456", "127.0.0.1"); err != nil { + t.Fatalf("新密码无法登录: %v", err) + } +} + +func TestLoginRateLimiterFailureIsEnforced(t *testing.T) { + service := NewService(nil, nil, security.NewTokenService("12345678901234567890123456789012"), time.Minute, time.Hour) + service.SetLoginRateLimiter(rejectingRateLimiter{}) + if _, _, err := service.Login(context.Background(), "admin", "password123", "127.0.0.1"); !errors.Is(err, ErrLoginRateLimited) { + t.Fatalf("login rate limit error = %v", err) + } +} + +func TestLoginDistinguishesPersistenceFailure(t *testing.T) { + service := NewService(failingAdminRepository{}, nil, security.NewTokenService("12345678901234567890123456789012"), time.Minute, time.Hour) + if _, _, err := service.Login(context.Background(), "admin", "password123", "127.0.0.1"); !errors.Is(err, ErrRuntimeUnavailable) { + t.Fatalf("login persistence error = %v", err) + } +} + +func TestConcurrentRefreshAllowsExactlyOneRotation(t *testing.T) { + database, err := relational.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "auth.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(context.Background()); err != nil { + t.Fatal(err) + } + + baseSessions := relational.NewAdminSessionRepository(database) + sessions := newCoordinatedSessionRepository(baseSessions, 2) + service := NewService( + relational.NewAdminRepository(database), + sessions, + security.NewTokenService("12345678901234567890123456789012"), + time.Minute, + time.Hour, + ) + ctx := context.Background() + if err := service.Bootstrap(ctx, "admin", "password123"); err != nil { + t.Fatal(err) + } + _, tokens, err := service.Login(ctx, "admin", "password123", "127.0.0.1") + if err != nil { + t.Fatal(err) + } + + type refreshResult struct { + tokens Tokens + err error + } + results := make(chan refreshResult, 2) + start := make(chan struct{}) + for range 2 { + go func() { + <-start + rotated, refreshErr := service.Refresh(ctx, tokens.RefreshToken) + results <- refreshResult{tokens: rotated, err: refreshErr} + }() + } + close(start) + + var successful Tokens + successCount := 0 + invalidCount := 0 + for range 2 { + result := <-results + switch { + case result.err == nil: + successCount++ + successful = result.tokens + case errors.Is(result.err, ErrInvalidSession): + invalidCount++ + default: + t.Fatalf("unexpected refresh error: %v", result.err) + } + } + if successCount != 1 || invalidCount != 1 { + t.Fatalf("successes = %d, invalid sessions = %d", successCount, invalidCount) + } + if _, err := service.Refresh(ctx, successful.RefreshToken); err != nil { + t.Fatalf("winning refresh token is unusable: %v", err) + } +} + +type coordinatedSessionRepository struct { + repository.AdminSessionRepository + mu sync.Mutex + remaining int + ready chan struct{} +} + +type rejectingRateLimiter struct{} + +func (rejectingRateLimiter) Allow(context.Context, string, int, time.Time) (bool, error) { + return false, nil +} + +type failingAdminRepository struct{ repository.AdminRepository } + +func (failingAdminRepository) GetByUsername(context.Context, string) (admin.Admin, error) { + return admin.Admin{}, errors.New("database unavailable") +} + +func newCoordinatedSessionRepository(base repository.AdminSessionRepository, reads int) *coordinatedSessionRepository { + return &coordinatedSessionRepository{AdminSessionRepository: base, remaining: reads, ready: make(chan struct{})} +} + +func (r *coordinatedSessionRepository) GetByTokenHash(ctx context.Context, tokenHash string) (admin.Session, error) { + session, err := r.AdminSessionRepository.GetByTokenHash(ctx, tokenHash) + if err != nil { + return admin.Session{}, err + } + r.mu.Lock() + if r.remaining > 0 { + r.remaining-- + if r.remaining == 0 { + close(r.ready) + } + } + ready := r.ready + r.mu.Unlock() + <-ready + return session, nil +} diff --git a/backend/internal/application/audit/service.go b/backend/internal/application/audit/service.go new file mode 100644 index 0000000000000000000000000000000000000000..4dc557d8686e632c3bf24eaf3876dcde7e036996 --- /dev/null +++ b/backend/internal/application/audit/service.go @@ -0,0 +1,961 @@ +package audit + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/chenyme/grok2api/backend/internal/pkg/resultcache" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrQueueFull = errors.New("审计写入队列已满") + ErrWriterUnavailable = errors.New("audit writer is not running") + ErrInvalidCursor = errors.New("审计游标无效") + ErrInvalidFilter = errors.New("审计筛选条件无效") + ErrInvalidPeriod = errors.New("审计时间范围无效") + ErrLedgerUnavailable = errors.New("billing ledger is not ready") +) + +type LedgerMode string + +const ( + LedgerModeObserve LedgerMode = "observe" + LedgerModeEnforce LedgerMode = "enforce" +) + +type LedgerConfig struct { + Mode LedgerMode + FailureThreshold int + UnhealthyGrace time.Duration + QueueHighWatermarkPercent int +} + +type LedgerSnapshot struct { + Mode LedgerMode + Ready bool + Irrecoverable bool + QueueDepth int + QueueCapacity int + ConsecutiveFailures int + Dropped uint64 + LastSuccessAt time.Time + LastFailureAt time.Time + UnhealthySince time.Time +} + +type Period string + +const ( + Period24Hours Period = "24h" + Period7Days Period = "7d" + Period30Days Period = "30d" + Period90Days Period = "90d" +) + +const ( + auditEnqueueWait = 25 * time.Millisecond + auditWriteTimeout = 2 * time.Second + auditWriteAttempts = 3 + auditWriteRetryBase = 250 * time.Millisecond + auditWriteRetryMax = 5 * time.Second + auditDefaultCommitDelay = 5 * time.Millisecond + auditSummaryTTL = 10 * time.Second +) + +type auditWriteRequest struct { + record auditdomain.Record + ack chan error +} + +// Service 提供请求元数据审计查询,以及有界异步批量写入。 +type Service struct { + audits repository.AuditRepository + logger *slog.Logger + queue chan auditWriteRequest + batchSize atomic.Int64 + flushInterval atomic.Int64 + commitDelay atomic.Int64 + configChanged chan struct{} + lifecycleMu sync.RWMutex + queueSpace chan struct{} + queueWaiters atomic.Int64 + startOnce sync.Once + stopOnce sync.Once + stop chan struct{} + done chan struct{} + stopped atomic.Bool + started atomic.Bool + dropped atomic.Uint64 + now func() time.Time + summaryCache *resultcache.Cache[string, SummaryResult] + ledgerMu sync.Mutex + ledgerConfig LedgerConfig + ledgerFailures int + ledgerUnhealthySince time.Time + ledgerLastSuccess time.Time + ledgerLastFailure time.Time + ledgerLastDrop time.Time + ledgerQueueHighSince time.Time + ledgerLastWarning time.Time + observerMu sync.RWMutex + commitObserver func([]string) + dropObserver func([]string) +} + +func NewService(audits repository.AuditRepository, logger *slog.Logger, bufferSize, batchSize int, flushInterval time.Duration) *Service { + if logger == nil { + logger = slog.Default() + } + service := &Service{ + audits: audits, logger: logger, queue: make(chan auditWriteRequest, bufferSize), + configChanged: make(chan struct{}, 1), queueSpace: make(chan struct{}, bufferSize), stop: make(chan struct{}), done: make(chan struct{}), + now: time.Now, summaryCache: resultcache.New[string, SummaryResult](64, auditSummaryTTL), + ledgerConfig: defaultLedgerConfig(), + } + service.UpdateConfig(batchSize, flushInterval) + return service +} + +func defaultLedgerConfig() LedgerConfig { + return LedgerConfig{ + Mode: LedgerModeEnforce, + FailureThreshold: 1, + UnhealthyGrace: 10 * time.Second, + QueueHighWatermarkPercent: 90, + } +} + +// UpdateLedgerConfig changes readiness policy without altering the audit writer. +func (s *Service) UpdateLedgerConfig(value LedgerConfig) { + defaults := defaultLedgerConfig() + if value.Mode != LedgerModeObserve && value.Mode != LedgerModeEnforce { + value.Mode = defaults.Mode + } + if value.FailureThreshold <= 0 { + value.FailureThreshold = defaults.FailureThreshold + } + if value.UnhealthyGrace <= 0 { + value.UnhealthyGrace = defaults.UnhealthyGrace + } + if value.QueueHighWatermarkPercent < 1 || value.QueueHighWatermarkPercent > 100 { + value.QueueHighWatermarkPercent = defaults.QueueHighWatermarkPercent + } + s.ledgerMu.Lock() + s.ledgerConfig = value + s.ledgerMu.Unlock() +} + +// SetCommitObserver registers a lightweight callback invoked after the audit and billing transaction commits. +func (s *Service) SetCommitObserver(observer func([]string)) { + s.observerMu.Lock() + s.commitObserver = observer + s.observerMu.Unlock() +} + +// SetDropObserver registers a callback for records that can no longer enter +// the writer. It must not release the durable billing reservation itself. +func (s *Service) SetDropObserver(observer func([]string)) { + s.observerMu.Lock() + s.dropObserver = observer + s.observerMu.Unlock() +} + +// LedgerSnapshot returns a bounded, identity-free view of the durable ledger state. +func (s *Service) LedgerSnapshot() LedgerSnapshot { + now := s.now().UTC() + queueDepth := len(s.queue) + queueCapacity := cap(s.queue) + + s.ledgerMu.Lock() + s.updateQueuePressureLocked(now, queueDepth, queueCapacity) + dropped := s.dropped.Load() + ready := dropped == 0 && s.ledgerReadyLocked(now) + snapshot := LedgerSnapshot{ + Mode: s.ledgerConfig.Mode, + Ready: ready, + Irrecoverable: dropped > 0, + QueueDepth: queueDepth, + QueueCapacity: queueCapacity, + ConsecutiveFailures: s.ledgerFailures, + Dropped: dropped, + LastSuccessAt: s.ledgerLastSuccess, + LastFailureAt: s.ledgerLastFailure, + UnhealthySince: s.ledgerUnhealthySince, + } + s.ledgerMu.Unlock() + + perfmetrics.Default.SetGauge("audit_queue_depth", perfmetrics.Labels{Subsystem: "audit", Stage: "queue"}, int64(queueDepth)) + perfmetrics.Default.SetGauge("audit_queue_capacity", perfmetrics.Labels{Subsystem: "audit", Stage: "queue"}, int64(queueCapacity)) + return snapshot +} + +// CheckLedgerReady always blocks after confirmed data loss. Observe mode only +// permits traffic while a recoverable writer or queue degradation is active. +func (s *Service) CheckLedgerReady() error { + snapshot := s.LedgerSnapshot() + if snapshot.Ready && !snapshot.Irrecoverable { + return nil + } + if !snapshot.Irrecoverable && snapshot.Mode != LedgerModeEnforce { + return nil + } + return ErrLedgerUnavailable +} + +func (s *Service) UpdateConfig(batchSize int, flushInterval time.Duration) { + s.UpdateWriterConfig(batchSize, flushInterval, time.Duration(s.commitDelay.Load())) +} + +// UpdateWriterConfig hot-reloads batching limits without replacing the active queue. +func (s *Service) UpdateWriterConfig(batchSize int, flushInterval, commitDelay time.Duration) { + if commitDelay <= 0 { + commitDelay = auditDefaultCommitDelay + } + s.batchSize.Store(int64(batchSize)) + s.flushInterval.Store(int64(flushInterval)) + s.commitDelay.Store(int64(commitDelay)) + select { + case s.configChanged <- struct{}{}: + default: + } +} + +// Start 启动单个审计写入协程,将请求热路径与关系型数据库批量写入解耦。 +func (s *Service) Start() { + s.startOnce.Do(func() { + s.started.Store(true) + go s.runSupervised() + }) +} + +// Record 将审计写入有界队列;突发满载时短暂等待,持续拥塞才降级丢弃审计。 +func (s *Service) Record(value auditdomain.Record) bool { + return s.enqueueBestEffort(context.Background(), auditWriteRequest{record: value}) == nil +} + +// Create returns success only after the audit and billing transaction commits. +func (s *Service) Create(ctx context.Context, value auditdomain.Record) error { + return s.createAcknowledged(ctx, value) +} + +// CreateDurable returns success only after the audit and billing transaction commits. +func (s *Service) CreateDurable(ctx context.Context, value auditdomain.Record) error { + return s.createAcknowledged(ctx, value) +} + +func (s *Service) createAcknowledged(ctx context.Context, value auditdomain.Record) error { + startedAt := time.Now() + if err := ctx.Err(); err != nil { + return err + } + if !s.started.Load() || s.stopped.Load() { + return ErrWriterUnavailable + } + request := auditWriteRequest{record: value, ack: make(chan error, 1)} + if err := s.enqueueAcknowledged(ctx, request); err != nil { + return err + } + select { + case err := <-request.ack: + outcome := "success" + if err != nil { + outcome = "failed" + } + labels := perfmetrics.Labels{Subsystem: "audit", Operation: string(value.Operation), Stage: "ack", Outcome: outcome} + perfmetrics.Default.Inc("audit_records_total", labels) + perfmetrics.Default.ObserveDuration("audit_ack_duration_us", labels, time.Since(startedAt)) + return err + case <-ctx.Done(): + labels := perfmetrics.Labels{Subsystem: "audit", Operation: string(value.Operation), Stage: "ack", Outcome: "timeout"} + perfmetrics.Default.Inc("audit_records_total", labels) + perfmetrics.Default.ObserveDuration("audit_ack_duration_us", labels, time.Since(startedAt)) + return ctx.Err() + } +} + +func (s *Service) tryEnqueue(request auditWriteRequest) error { + s.lifecycleMu.RLock() + defer s.lifecycleMu.RUnlock() + if s.stopped.Load() { + return ErrWriterUnavailable + } + select { + case s.queue <- request: + return nil + default: + return ErrQueueFull + } +} + +func (s *Service) enqueueBestEffort(ctx context.Context, request auditWriteRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if err := s.tryEnqueue(request); !errors.Is(err, ErrQueueFull) { + return err + } + s.queueWaiters.Add(1) + defer s.queueWaiters.Add(-1) + timer := time.NewTimer(auditEnqueueWait) + defer timer.Stop() + for { + if err := s.tryEnqueue(request); !errors.Is(err, ErrQueueFull) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.stop: + return ErrWriterUnavailable + case <-timer.C: + s.recordEnqueueDrop(request, "queue_full") + return ErrQueueFull + case <-s.queueSpace: + } + } +} + +func (s *Service) enqueueAcknowledged(ctx context.Context, request auditWriteRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if err := s.tryEnqueue(request); !errors.Is(err, ErrQueueFull) { + return err + } + s.queueWaiters.Add(1) + defer s.queueWaiters.Add(-1) + for { + if err := s.tryEnqueue(request); !errors.Is(err, ErrQueueFull) { + return err + } + select { + case <-ctx.Done(): + s.recordEnqueueDrop(request, "context_done") + return ctx.Err() + case <-s.stop: + s.recordEnqueueDrop(request, "writer_stopping") + return ErrWriterUnavailable + case <-s.queueSpace: + } + } +} + +func (s *Service) notifyQueueSpace() { + if s.queueWaiters.Load() <= 0 { + return + } + select { + case s.queueSpace <- struct{}{}: + default: + } +} + +func (s *Service) recordEnqueueDrop(request auditWriteRequest, reason string) { + dropped := s.dropped.Add(1) + s.recordLedgerDrop() + perfmetrics.Default.Inc("audit_records_total", perfmetrics.Labels{Subsystem: "audit", Operation: string(request.record.Operation), Stage: "enqueue", Outcome: "dropped"}) + if dropped == 1 || dropped%1000 == 0 { + s.logger.Warn("audit_queue_full", "reason", reason, "dropped", dropped) + } + s.notifyDropped(request.record.EventID) +} + +// Close 停止接收新审计并尽力排空队列。 +func (s *Service) Close(ctx context.Context) error { + if !s.started.Load() { + s.Start() + } + s.stopOnce.Do(func() { + s.lifecycleMu.Lock() + s.stopped.Store(true) + close(s.stop) + s.lifecycleMu.Unlock() + }) + select { + case <-s.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Service) List(ctx context.Context, page, pageSize int) ([]auditdomain.Record, int64, error) { + page, pageSize = repository.NormalizePage(page, pageSize, repository.DefaultPageSize) + return s.audits.List(ctx, (page-1)*pageSize, pageSize) +} + +func (s *Service) Get(ctx context.Context, id uint64) (auditdomain.Record, error) { + return s.audits.Get(ctx, id) +} + +// CursorResult 表示按递减 ID 游标读取的一页审计记录。 +type CursorResult struct { + Items []auditdomain.Record + NextCursor string + HasMore bool +} + +type ListFilter struct { + Model string + Status string + Mode string + Key string + Account string + Sort repository.SortQuery +} + +type auditCursorPayload struct { + Version int `json:"v"` + Field string `json:"field"` + Direction repository.SortDirection `json:"direction"` + ID uint64 `json:"id"` + Value string `json:"value"` +} + +// ListCursor 使用复合游标读取审计,适合持续增长且支持多字段排序的大数据列表。 +func (s *Service) ListCursor(ctx context.Context, rawCursor string, pageSize int, search, rawPeriod string, filter ListFilter) (CursorResult, error) { + _, pageSize = repository.NormalizePage(1, pageSize, repository.DefaultCursorPageSize) + if filter.Sort.Field == "" && filter.Sort.Direction == "" { + filter.Sort = repository.SortQuery{Field: "createdAt", Direction: repository.SortDescending} + } + if !validAuditFilter(filter.Status, "", "success", "clientError", "serverError", "2xx", "4xx", "5xx") || !validAuditFilter(filter.Mode, "", "stream", "nonStream") || !repository.IsValidSort(filter.Sort, "request", "model", "billing", "tokens", "status", "mode", "duration", "createdAt") { + return CursorResult{}, ErrInvalidFilter + } + cursor, err := decodeAuditCursor(rawCursor, filter.Sort) + if err != nil { + return CursorResult{}, err + } + _, start, end, err := s.resolvePeriod(rawPeriod) + if err != nil { + return CursorResult{}, err + } + items, hasMore, err := s.audits.ListCursor(ctx, repository.AuditCursorQuery{Cursor: cursor, Limit: pageSize, Search: search, Start: start, End: end, Sort: filter.Sort, Filter: repository.AuditListFilter{ + Model: filter.Model, Status: filter.Status, Mode: filter.Mode, Key: filter.Key, Account: filter.Account, + }}) + if err != nil { + return CursorResult{}, err + } + result := CursorResult{Items: items, HasMore: hasMore} + if hasMore && len(items) > 0 { + result.NextCursor, err = encodeAuditCursor(items[len(items)-1], filter.Sort) + if err != nil { + return CursorResult{}, err + } + } + return result, nil +} + +func decodeAuditCursor(raw string, sort repository.SortQuery) (*repository.SortCursor, error) { + if raw == "" { + return nil, nil + } + encoded, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, ErrInvalidCursor + } + var payload auditCursorPayload + if json.Unmarshal(encoded, &payload) != nil || payload.Version != 1 || payload.ID == 0 || payload.Field != sort.Field || payload.Direction != sort.Direction { + return nil, ErrInvalidCursor + } + value, err := parseAuditCursorValue(payload.Field, payload.Value) + if err != nil { + return nil, ErrInvalidCursor + } + return &repository.SortCursor{ID: payload.ID, Value: value}, nil +} + +func encodeAuditCursor(value auditdomain.Record, sort repository.SortQuery) (string, error) { + payload := auditCursorPayload{Version: 1, Field: sort.Field, Direction: sort.Direction, ID: value.ID, Value: formatAuditCursorValue(value, sort.Field)} + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(encoded), nil +} + +func parseAuditCursorValue(field, value string) (any, error) { + switch field { + case "request", "model": + return value, nil + case "billing", "tokens", "status", "mode", "duration": + return strconv.ParseInt(value, 10, 64) + case "createdAt": + return time.Parse(time.RFC3339Nano, value) + default: + return nil, ErrInvalidCursor + } +} + +func formatAuditCursorValue(value auditdomain.Record, field string) string { + switch field { + case "request": + return value.RequestID + case "model": + return strings.ToLower(value.ModelPublicID) + case "billing": + amount := value.CostInUSDTicks + if amount == 0 { + amount = value.EstimatedCostInUSDTicks + } + return strconv.FormatInt(amount, 10) + case "tokens": + return strconv.FormatInt(value.TotalTokens, 10) + case "status": + return strconv.Itoa(value.StatusCode) + case "mode": + if value.Streaming { + return "1" + } + return "0" + case "duration": + return strconv.FormatInt(value.DurationMS, 10) + default: + return value.CreatedAt.UTC().Format(time.RFC3339Nano) + } +} + +type SummaryUsage struct { + Requests int64 + SuccessfulRequests int64 + FailedRequests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + TotalTokens int64 + AverageDurationMS float64 + SuccessRate float64 + EstimatedCostInUSDTicks int64 + PricedRequests int64 + UnpricedRequests int64 + PricedTokens int64 + UnpricedTokens int64 +} + +type SummaryResult struct { + Period Period + GeneratedAt time.Time + Start time.Time + End time.Time + Usage SummaryUsage +} + +func (s *Service) Summary(ctx context.Context, search, rawPeriod string, filter ListFilter) (SummaryResult, error) { + return s.summary(ctx, search, rawPeriod, filter, true) +} + +// SummaryFresh 绕过短缓存,供管理员显式刷新时读取最新汇总。 +func (s *Service) SummaryFresh(ctx context.Context, search, rawPeriod string, filter ListFilter) (SummaryResult, error) { + return s.summary(ctx, search, rawPeriod, filter, false) +} + +func (s *Service) summary(ctx context.Context, search, rawPeriod string, filter ListFilter, useCache bool) (SummaryResult, error) { + if !validAuditFilter(filter.Status, "", "success", "clientError", "serverError", "2xx", "4xx", "5xx") || !validAuditFilter(filter.Mode, "", "stream", "nonStream") { + return SummaryResult{}, ErrInvalidFilter + } + period, start, end, err := s.resolvePeriod(rawPeriod) + if err != nil { + return SummaryResult{}, err + } + if !useCache { + return s.loadSummary(ctx, search, filter, period, start, end) + } + cacheKey := fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%s", period, search, filter.Model, filter.Status, filter.Mode, filter.Key, filter.Account) + return s.summaryCache.Load(ctx, cacheKey, end, func() (SummaryResult, error) { + return s.loadSummary(ctx, search, filter, period, start, end) + }) +} + +func (s *Service) loadSummary(ctx context.Context, search string, filter ListFilter, period Period, start, end time.Time) (SummaryResult, error) { + aggregate, err := s.audits.Summarize(ctx, repository.AuditSummaryQuery{Search: search, Start: start, End: end, Filter: repository.AuditListFilter{ + Model: filter.Model, Status: filter.Status, Mode: filter.Mode, Key: filter.Key, Account: filter.Account, + }}) + if err != nil { + return SummaryResult{}, err + } + usage := SummaryUsage{ + Requests: aggregate.Requests, SuccessfulRequests: aggregate.SuccessfulRequests, FailedRequests: aggregate.FailedRequests, + InputTokens: aggregate.InputTokens, CachedInputTokens: aggregate.CachedInputTokens, OutputTokens: aggregate.OutputTokens, + ReasoningTokens: aggregate.ReasoningTokens, TotalTokens: aggregate.TotalTokens, + EstimatedCostInUSDTicks: aggregate.EstimatedCostInUSDTicks, PricedRequests: aggregate.PricedRequests, + UnpricedRequests: aggregate.UnpricedRequests, PricedTokens: aggregate.PricedTokens, UnpricedTokens: aggregate.UnpricedTokens, + } + if aggregate.Requests > 0 { + usage.SuccessRate = float64(aggregate.SuccessfulRequests) / float64(aggregate.Requests) * 100 + usage.AverageDurationMS = float64(aggregate.DurationMS) / float64(aggregate.Requests) + } + return SummaryResult{Period: period, GeneratedAt: end, Start: start, End: end, Usage: usage}, nil +} + +func (s *Service) resolvePeriod(value string) (Period, time.Time, time.Time, error) { + period, duration, err := parsePeriod(value) + if err != nil { + return "", time.Time{}, time.Time{}, err + } + end := s.now().UTC() + return period, end.Add(-duration), end, nil +} + +func parsePeriod(value string) (Period, time.Duration, error) { + if value == "" { + value = string(Period24Hours) + } + switch Period(value) { + case Period24Hours: + return Period24Hours, 24 * time.Hour, nil + case Period7Days: + return Period7Days, 7 * 24 * time.Hour, nil + case Period30Days: + return Period30Days, 30 * 24 * time.Hour, nil + case Period90Days: + return Period90Days, 90 * 24 * time.Hour, nil + default: + return "", 0, ErrInvalidPeriod + } +} + +func validAuditFilter(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func (s *Service) runSupervised() { + defer close(s.done) + backoff := 100 * time.Millisecond + for { + err := batch.Do(context.Background(), func(context.Context) error { + s.run() + return nil + }) + if err == nil { + return + } + var panicErr *batch.PanicError + if errors.As(err, &panicErr) { + s.logger.Error("audit_worker_restarting", "backoff", backoff, "error", panicErr, "stack", string(panicErr.Stack)) + } else { + s.logger.Error("audit_worker_restarting", "backoff", backoff, "error", err) + } + timer := time.NewTimer(backoff) + select { + case <-s.stop: + timer.Stop() + _ = batch.Do(context.Background(), func(context.Context) error { + s.run() + return nil + }) + return + case <-timer.C: + } + backoff = min(backoff*2, 5*time.Second) + } +} + +func (s *Service) run() { + var timer *time.Timer + var timerC <-chan time.Time + requests := make([]auditWriteRequest, 0, int(s.batchSize.Load())) + hasAck := false + resetTimer := func(delay time.Duration) { + if delay <= 0 { + delay = auditDefaultCommitDelay + } + if timer == nil { + timer = time.NewTimer(delay) + } else { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(delay) + } + timerC = timer.C + } + defer func() { + if timer != nil { + timer.Stop() + } + }() + flush := func() { + if len(requests) == 0 { + return + } + s.persistBatch(requests) + requests = requests[:0] + hasAck = false + timerC = nil + } + appendRequest := func(request auditWriteRequest) { + wasEmpty := len(requests) == 0 + requests = append(requests, request) + if request.ack != nil && !hasAck { + hasAck = true + resetTimer(time.Duration(s.commitDelay.Load())) + } else if wasEmpty { + resetTimer(time.Duration(s.flushInterval.Load())) + } + } + for { + select { + case request := <-s.queue: + s.notifyQueueSpace() + appendRequest(request) + if len(requests) >= int(s.batchSize.Load()) { + flush() + } + case <-timerC: + flush() + case <-s.configChanged: + if len(requests) >= int(s.batchSize.Load()) { + flush() + } else if len(requests) > 0 { + if hasAck { + resetTimer(time.Duration(s.commitDelay.Load())) + } else { + resetTimer(time.Duration(s.flushInterval.Load())) + } + } + case <-s.stop: + for { + select { + case request := <-s.queue: + s.notifyQueueSpace() + appendRequest(request) + if len(requests) >= int(s.batchSize.Load()) { + flush() + } + default: + flush() + return + } + } + } + } +} + +func (s *Service) persistBatch(requests []auditWriteRequest) { + startedAt := time.Now() + pending := append([]auditWriteRequest(nil), requests...) + retryDelay := auditWriteRetryBase + retryRound := 0 + for len(pending) > 0 { + lastErr := s.persistAuditRequests(pending) + var invalid *repository.InvalidBatchRecordError + if errors.As(lastErr, &invalid) && invalid.Index >= 0 && invalid.Index < len(pending) { + rejected := pending[invalid.Index] + completeAuditWrites([]auditWriteRequest{rejected}, lastErr) + s.recordRejectedAudit(rejected, lastErr) + pending = append(pending[:invalid.Index], pending[invalid.Index+1:]...) + continue + } + if lastErr == nil { + records := auditRecords(pending) + s.recordLedgerSuccess() + s.notifyCommitted(records) + perfmetrics.Default.Add("audit_records_total", perfmetrics.Labels{Subsystem: "audit", Stage: "batch", Outcome: "success"}, int64(len(records))) + perfmetrics.Default.Add("audit_batch_size", perfmetrics.Labels{Subsystem: "audit", Stage: "batch", Outcome: "success"}, int64(len(records))) + perfmetrics.Default.ObserveDuration("audit_batch_commit_duration_us", perfmetrics.Labels{Subsystem: "audit", Stage: "batch", Outcome: "success"}, time.Since(startedAt)) + completeAuditWrites(pending, nil) + return + } + perfmetrics.Default.Add("audit_batch_size", perfmetrics.Labels{Subsystem: "audit", Stage: "batch", Outcome: "retry"}, int64(len(pending))) + retryRound++ + var panicErr *batch.PanicError + if errors.As(lastErr, &panicErr) { + s.logger.Error("audit_batch_write_retrying", "count", len(pending), "attempts", auditWriteAttempts, "retry_in", retryDelay, "error", panicErr, "stack", string(panicErr.Stack)) + } else if retryRound == 1 { + s.logger.Warn("audit_batch_write_retrying", "count", len(pending), "attempts", auditWriteAttempts, "retry_in", retryDelay, "error", lastErr) + } else { + s.logger.Debug("audit_batch_write_retrying", "count", len(pending), "attempts", auditWriteAttempts, "retry_in", retryDelay, "error", lastErr) + } + timer := time.NewTimer(retryDelay) + <-timer.C + retryDelay = min(retryDelay*2, auditWriteRetryMax) + } +} + +func (s *Service) persistAuditRequests(requests []auditWriteRequest) error { + records := auditRecords(requests) + var lastErr error + for attempt := 1; attempt <= auditWriteAttempts; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), auditWriteTimeout) + lastErr = batch.Do(ctx, func(workCtx context.Context) error { return s.audits.CreateBatch(workCtx, records) }) + cancel() + if lastErr == nil { + return nil + } + if errors.Is(lastErr, repository.ErrInvalidRecord) { + return lastErr + } + s.recordLedgerFailure() + if attempt < auditWriteAttempts { + timer := time.NewTimer(time.Duration(attempt) * 100 * time.Millisecond) + select { + case <-s.stop: + timer.Stop() + case <-timer.C: + } + } + } + return lastErr +} + +func auditRecords(requests []auditWriteRequest) []auditdomain.Record { + records := make([]auditdomain.Record, len(requests)) + for index := range requests { + records[index] = requests[index].record + } + return records +} + +func (s *Service) recordRejectedAudit(request auditWriteRequest, err error) { + dropped := s.dropped.Add(1) + s.recordLedgerDrop() + perfmetrics.Default.Inc("audit_records_total", perfmetrics.Labels{Subsystem: "audit", Operation: string(request.record.Operation), Stage: "batch", Outcome: "rejected"}) + s.logger.Error("audit_record_rejected", "event_id", request.record.EventID, "dropped", dropped, "error", err) + s.notifyDropped(request.record.EventID) +} + +func completeAuditWrites(requests []auditWriteRequest, err error) { + for _, request := range requests { + if request.ack == nil { + continue + } + select { + case request.ack <- err: + default: + } + } +} + +func (s *Service) recordLedgerSuccess() { + now := s.now().UTC() + s.ledgerMu.Lock() + s.ledgerFailures = 0 + s.ledgerLastSuccess = now + if s.dropped.Load() == 0 && s.ledgerQueueHighSince.IsZero() { + s.ledgerUnhealthySince = time.Time{} + } + s.ledgerMu.Unlock() +} + +func (s *Service) recordLedgerFailure() { + now := s.now().UTC() + s.ledgerMu.Lock() + s.ledgerFailures++ + s.ledgerLastFailure = now + if s.ledgerFailures >= s.ledgerConfig.FailureThreshold && s.ledgerUnhealthySince.IsZero() { + s.ledgerUnhealthySince = now + } + s.warnLedgerIfNeededLocked(now, "durable_write_failed") + s.ledgerMu.Unlock() +} + +func (s *Service) recordLedgerDrop() { + now := s.now().UTC() + s.ledgerMu.Lock() + s.ledgerLastDrop = now + if s.ledgerUnhealthySince.IsZero() { + s.ledgerUnhealthySince = now + } + s.warnLedgerIfNeededLocked(now, "audit_record_dropped") + s.ledgerMu.Unlock() +} + +func (s *Service) updateQueuePressureLocked(now time.Time, depth, capacity int) { + if capacity <= 0 || depth*100 < capacity*s.ledgerConfig.QueueHighWatermarkPercent { + wasHigh := !s.ledgerQueueHighSince.IsZero() + s.ledgerQueueHighSince = time.Time{} + if wasHigh && s.ledgerFailures == 0 && s.dropped.Load() == 0 { + s.ledgerUnhealthySince = time.Time{} + } + return + } + if s.ledgerQueueHighSince.IsZero() { + s.ledgerQueueHighSince = now + } + if s.ledgerUnhealthySince.IsZero() { + s.ledgerUnhealthySince = s.ledgerQueueHighSince + } +} + +func (s *Service) ledgerReadyLocked(now time.Time) bool { + degradedSince := s.ledgerUnhealthySince + if !s.ledgerQueueHighSince.IsZero() && (degradedSince.IsZero() || s.ledgerQueueHighSince.Before(degradedSince)) { + degradedSince = s.ledgerQueueHighSince + } + if degradedSince.IsZero() { + return true + } + return now.Sub(degradedSince) < s.ledgerConfig.UnhealthyGrace +} + +func (s *Service) warnLedgerIfNeededLocked(now time.Time, reason string) { + if !s.ledgerLastWarning.IsZero() && now.Sub(s.ledgerLastWarning) < time.Minute { + return + } + s.ledgerLastWarning = now + s.logger.Warn("billing_ledger_degraded", "reason", reason, "mode", s.ledgerConfig.Mode, "consecutive_failures", s.ledgerFailures, "queue_depth", len(s.queue), "queue_capacity", cap(s.queue)) +} + +func (s *Service) notifyCommitted(records []auditdomain.Record) { + s.observerMu.RLock() + observer := s.commitObserver + s.observerMu.RUnlock() + if observer == nil { + return + } + eventIDs := make([]string, 0, len(records)) + for _, record := range records { + if record.EventID != "" { + eventIDs = append(eventIDs, record.EventID) + } + } + if len(eventIDs) > 0 { + func() { + defer func() { + if recovered := recover(); recovered != nil { + s.logger.Error("audit_commit_observer_panicked", "error", recovered) + } + }() + observer(eventIDs) + }() + } +} + +func (s *Service) notifyDropped(eventID string) { + if eventID == "" { + return + } + s.observerMu.RLock() + observer := s.dropObserver + s.observerMu.RUnlock() + if observer == nil { + return + } + defer func() { + if recovered := recover(); recovered != nil { + s.logger.Error("audit_drop_observer_panicked", "error", recovered) + } + }() + observer([]string{eventID}) +} diff --git a/backend/internal/application/audit/service_benchmark_test.go b/backend/internal/application/audit/service_benchmark_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b19063b1994fae00968c18690a93b41e61227c8b --- /dev/null +++ b/backend/internal/application/audit/service_benchmark_test.go @@ -0,0 +1,74 @@ +package audit + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "sync/atomic" + "testing" + "time" + + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" +) + +func BenchmarkAuditServiceSQLite(b *testing.B) { + for _, attemptCount := range []int{0, 2} { + b.Run(fmt.Sprintf("attempts-%d", attemptCount), func(b *testing.B) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(b.TempDir(), "audit-benchmark.db")) + if err != nil { + b.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + database.Close() + b.Fatal(err) + } + service := NewService(relational.NewAuditRepository(database), slog.Default(), 16_384, 256, 250*time.Millisecond) + service.Start() + + var sequence atomic.Uint64 + errCh := make(chan error, 1) + baseTime := time.Date(2026, 7, 22, 0, 0, 0, 0, time.UTC) + b.SetParallelism(4) + b.ResetTimer() + b.RunParallel(func(worker *testing.PB) { + for worker.Next() { + index := sequence.Add(1) + record := auditdomain.Record{ + EventID: fmt.Sprintf("evt_benchmark_%020d", index), RequestID: fmt.Sprintf("benchmark-%d", index), + ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: baseTime.Add(time.Duration(index)), + } + for attempt := 1; attempt <= attemptCount; attempt++ { + record.Attempts = append(record.Attempts, auditdomain.Attempt{ + Number: attempt, Source: auditdomain.AttemptSourceCredential, Stage: "credential", StartedAt: record.CreatedAt, + }) + } + if err := service.Create(context.Background(), record); err != nil { + select { + case errCh <- err: + default: + } + } + } + }) + b.StopTimer() + closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := service.Close(closeCtx); err != nil { + cancel() + database.Close() + b.Fatal(err) + } + cancel() + if err := database.Close(); err != nil { + b.Fatal(err) + } + select { + case err := <-errCh: + b.Fatal(err) + default: + } + }) + } +} diff --git a/backend/internal/application/audit/service_test.go b/backend/internal/application/audit/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..46730fa7ec7440fcdf6e0c2e0e7875a7ac310e6d --- /dev/null +++ b/backend/internal/application/audit/service_test.go @@ -0,0 +1,666 @@ +package audit + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestServiceCloseFlushesQueuedAudits(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-service.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := relational.NewAuditRepository(database) + service := NewService(repository, slog.Default(), 16, 8, time.Hour) + service.Start() + for index := range 5 { + if err := service.Create(ctx, auditdomain.Record{RequestID: "queued-" + string(rune('a'+index)), ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + } + closeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := service.Close(closeCtx); err != nil { + t.Fatal(err) + } + values, total, err := repository.List(ctx, 0, 10) + if err != nil { + t.Fatal(err) + } + if total != 5 || len(values) != 5 { + t.Fatalf("total = %d, values = %d", total, len(values)) + } +} + +func TestAuditBatchRetriesTransientDatabaseFailure(t *testing.T) { + repo := &flakyAuditRepository{failures: 5} + service := NewService(repo, slog.Default(), 8, 4, time.Hour) + service.Start() + if !service.Record(auditdomain.Record{RequestID: "retry", StatusCode: 200}) { + t.Fatal("record was not queued") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Fatal(err) + } + if attempts := repo.attempts.Load(); attempts != 6 { + t.Fatalf("attempts = %d", attempts) + } +} + +func TestAuditBatchRecoversRepositoryPanicAndRetries(t *testing.T) { + repo := &panicAuditRepository{} + service := NewService(repo, slog.Default(), 8, 1, time.Hour) + service.Start() + if !service.Record(auditdomain.Record{RequestID: "panic-retry", StatusCode: 200}) { + t.Fatal("record was not queued") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Fatal(err) + } + if attempts := repo.attempts.Load(); attempts != 2 { + t.Fatalf("attempts = %d", attempts) + } +} + +type flakyAuditRepository struct { + repository.AuditRepository + failures int32 + attempts atomic.Int32 +} + +type panicAuditRepository struct { + repository.AuditRepository + attempts atomic.Int32 +} + +type summaryAuditRepository struct { + repository.AuditRepository + calls int +} + +func (r *summaryAuditRepository) Summarize(context.Context, repository.AuditSummaryQuery) (auditdomain.Summary, error) { + r.calls++ + return auditdomain.Summary{Requests: 1, SuccessfulRequests: 1}, nil +} + +func TestSummaryCachesRepeatedAggregate(t *testing.T) { + repo := &summaryAuditRepository{} + service := NewService(repo, slog.Default(), 8, 4, time.Second) + service.now = func() time.Time { return time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) } + + if _, err := service.Summary(context.Background(), "", "24h", ListFilter{}); err != nil { + t.Fatal(err) + } + if _, err := service.Summary(context.Background(), "", "24h", ListFilter{}); err != nil { + t.Fatal(err) + } + if repo.calls != 1 { + t.Fatalf("summary calls = %d", repo.calls) + } +} + +func TestSummaryFreshBypassesAggregateCache(t *testing.T) { + repo := &summaryAuditRepository{} + service := NewService(repo, slog.Default(), 8, 4, time.Second) + service.now = func() time.Time { return time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) } + + if _, err := service.Summary(context.Background(), "", "24h", ListFilter{}); err != nil { + t.Fatal(err) + } + if _, err := service.SummaryFresh(context.Background(), "", "24h", ListFilter{}); err != nil { + t.Fatal(err) + } + if repo.calls != 2 { + t.Fatalf("summary calls = %d", repo.calls) + } +} + +func TestCreateDurableHonorsCallerDeadline(t *testing.T) { + release := make(chan struct{}) + repo := &blockingAuditRepository{release: release} + service := NewService(repo, slog.Default(), 8, 4, time.Second) + service.Start() + t.Cleanup(func() { closeAuditService(t, service) }) + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + started := time.Now() + err := service.CreateDurable(ctx, auditdomain.Record{EventID: "deadline"}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v", err) + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("deadline was not honored: %s", elapsed) + } + close(release) +} + +func TestCreateRequiresStartedWriter(t *testing.T) { + service := NewService(&toggleAuditRepository{}, slog.Default(), 8, 4, time.Second) + err := service.Create(context.Background(), auditdomain.Record{EventID: "not-started"}) + if !errors.Is(err, ErrWriterUnavailable) { + t.Fatalf("err = %v", err) + } +} + +func TestCloseStartsWriterAndFlushesPrestartRecords(t *testing.T) { + repo := newGatedAuditRepository() + close(repo.release) + service := NewService(repo, slog.Default(), 8, 8, time.Hour) + if !service.Record(auditdomain.Record{EventID: "prestart-record"}) { + t.Fatal("record was not queued") + } + closeAuditService(t, service) + if calls := repo.calls.Load(); calls != 1 { + t.Fatalf("CreateBatch calls = %d", calls) + } + select { + case <-repo.committed: + default: + t.Fatal("prestart record was not committed") + } +} + +func TestAcknowledgedWritesWaitForCommitAndCoalesce(t *testing.T) { + repo := newGatedAuditRepository() + service := NewService(repo, slog.Default(), 32, 32, time.Second) + service.UpdateWriterConfig(32, time.Second, 100*time.Millisecond) + service.Start() + t.Cleanup(func() { closeAuditService(t, service) }) + + const writeCount = 8 + start := make(chan struct{}) + results := make(chan error, writeCount) + for index := range writeCount { + go func() { + <-start + results <- service.Create(context.Background(), auditdomain.Record{EventID: fmt.Sprintf("coalesced-%d", index)}) + }() + } + close(start) + + var batch []auditdomain.Record + select { + case batch = <-repo.started: + case <-time.After(time.Second): + t.Fatal("batch did not start") + } + if len(batch) != writeCount { + t.Fatalf("batch size = %d", len(batch)) + } + select { + case err := <-results: + t.Fatalf("ack completed before commit: %v", err) + case <-time.After(20 * time.Millisecond): + } + + close(repo.release) + for range writeCount { + if err := <-results; err != nil { + t.Fatal(err) + } + } + if calls := repo.calls.Load(); calls != 1 { + t.Fatalf("CreateBatch calls = %d", calls) + } +} + +func TestAcknowledgedWriteUsesCallerBudgetWhenQueueIsFull(t *testing.T) { + repo := newGatedAuditRepository() + service := NewService(repo, slog.Default(), 1, 1, time.Second) + service.UpdateWriterConfig(1, time.Second, time.Millisecond) + service.Start() + t.Cleanup(func() { closeAuditService(t, service) }) + + firstResult := make(chan error, 1) + go func() { + firstResult <- service.Create(context.Background(), auditdomain.Record{EventID: "evt_queue_budget_first_0001", RequestID: "first", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: time.Now().UTC()}) + }() + select { + case <-repo.started: + case <-time.After(time.Second): + t.Fatal("first batch did not start") + } + if !service.Record(auditdomain.Record{EventID: "evt_queue_budget_buffered_02", RequestID: "buffered", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: time.Now().UTC()}) { + t.Fatal("failed to fill the audit queue") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + ackResult := make(chan error, 1) + go func() { + ackResult <- service.Create(ctx, auditdomain.Record{EventID: "evt_queue_budget_waiting_003", RequestID: "waiting", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: time.Now().UTC()}) + }() + select { + case err := <-ackResult: + t.Fatalf("acknowledged write returned before queue capacity recovered: %v", err) + case <-time.After(2 * auditEnqueueWait): + } + + close(repo.release) + if err := <-firstResult; err != nil { + t.Fatal(err) + } + if err := <-ackResult; err != nil { + t.Fatal(err) + } +} + +func TestWriterIsolatesInvalidAuditRecordFromValidBatch(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-invalid-isolation.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + baseRepo := relational.NewAuditRepository(database) + repo := &observedBatchAuditRepository{AuditRepository: baseRepo, sizes: make(chan int, 4)} + service := NewService(repo, slog.Default(), 8, 8, time.Second) + service.UpdateWriterConfig(8, time.Second, 100*time.Millisecond) + service.UpdateLedgerConfig(LedgerConfig{Mode: LedgerModeObserve, FailureThreshold: 1, UnhealthyGrace: time.Hour, QueueHighWatermarkPercent: 90}) + droppedEvents := make(chan string, 1) + service.SetDropObserver(func(eventIDs []string) { + if len(eventIDs) > 0 { + droppedEvents <- eventIDs[0] + } + }) + service.Start() + t.Cleanup(func() { closeAuditService(t, service) }) + + start := make(chan struct{}) + type result struct { + name string + err error + } + results := make(chan result, 2) + go func() { + <-start + results <- result{name: "valid", err: service.Create(ctx, auditdomain.Record{ + EventID: "evt_isolated_valid_record_001", RequestID: "valid", ClientKeyID: 1, ModelRouteID: 1, + StatusCode: 200, CreatedAt: time.Now().UTC(), + })} + }() + go func() { + <-start + results <- result{name: "invalid", err: service.Create(ctx, auditdomain.Record{ + EventID: "evt_isolated_invalid_record_02", RequestID: "invalid", ClientKeyID: 1, ModelRouteID: 1, + StatusCode: 200, CreatedAt: time.Now().UTC(), + Attempts: []auditdomain.Attempt{{Number: 0, Source: auditdomain.AttemptSourceCredential, Stage: "credential", StartedAt: time.Now().UTC()}}, + })} + }() + close(start) + + for range 2 { + value := <-results + switch value.name { + case "valid": + if value.err != nil { + t.Fatalf("valid audit failed: %v", value.err) + } + case "invalid": + if !errors.Is(value.err, repository.ErrInvalidRecord) { + t.Fatalf("invalid audit error = %v", value.err) + } + } + } + if size := <-repo.sizes; size != 2 { + t.Fatalf("initial batch size = %d, want 2", size) + } + _, total, err := baseRepo.List(ctx, 0, 10) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Fatalf("persisted audits = %d, want 1", total) + } + select { + case eventID := <-droppedEvents: + if eventID != "evt_isolated_invalid_record_02" { + t.Fatalf("dropped event = %q", eventID) + } + case <-time.After(time.Second): + t.Fatal("drop observer was not notified") + } + if snapshot := service.LedgerSnapshot(); snapshot.Ready || !snapshot.Irrecoverable || snapshot.Dropped != 1 { + t.Fatalf("ledger snapshot after rejected record = %#v", snapshot) + } + if err := service.CheckLedgerReady(); !errors.Is(err, ErrLedgerUnavailable) { + t.Fatalf("irrecoverable drop did not block observe mode: %v", err) + } +} + +func TestCallerTimeoutDoesNotCancelQueuedSettlement(t *testing.T) { + repo := newGatedAuditRepository() + service := NewService(repo, slog.Default(), 8, 8, time.Second) + service.UpdateWriterConfig(8, time.Second, time.Millisecond) + service.Start() + t.Cleanup(func() { closeAuditService(t, service) }) + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + result := make(chan error, 1) + go func() { + result <- service.Create(ctx, auditdomain.Record{EventID: "caller-timeout"}) + }() + select { + case <-repo.started: + case <-time.After(time.Second): + t.Fatal("batch did not start") + } + if err := <-result; !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v", err) + } + close(repo.release) + select { + case <-repo.committed: + case <-time.After(time.Second): + t.Fatal("timed-out write was not committed") + } + if err := service.Create(context.Background(), auditdomain.Record{EventID: "after-timeout"}); err != nil { + t.Fatal(err) + } +} + +func TestLedgerReadinessObserveAndEnforceModes(t *testing.T) { + initialNow := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + var nowNanos atomic.Int64 + nowNanos.Store(initialNow.UnixNano()) + repo := &toggleAuditRepository{err: errors.New("database unavailable")} + service := NewService(repo, slog.Default(), 8, 4, time.Second) + service.Start() + t.Cleanup(func() { + repo.setError(nil) + closeAuditService(t, service) + }) + service.now = func() time.Time { return time.Unix(0, nowNanos.Load()).UTC() } + service.UpdateLedgerConfig(LedgerConfig{Mode: LedgerModeObserve, FailureThreshold: 1, UnhealthyGrace: time.Second, QueueHighWatermarkPercent: 90}) + writeCtx, writeCancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer writeCancel() + if err := service.CreateDurable(writeCtx, auditdomain.Record{EventID: "observe"}); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("write error = %v", err) + } + nowNanos.Add(int64(2 * time.Second)) + if snapshot := service.LedgerSnapshot(); snapshot.Ready || snapshot.ConsecutiveFailures < 1 { + t.Fatalf("snapshot = %#v", snapshot) + } + if err := service.CheckLedgerReady(); err != nil { + t.Fatalf("observe mode blocked traffic: %v", err) + } + + service.UpdateLedgerConfig(LedgerConfig{Mode: LedgerModeEnforce, FailureThreshold: 1, UnhealthyGrace: time.Second, QueueHighWatermarkPercent: 90}) + if err := service.CheckLedgerReady(); !errors.Is(err, ErrLedgerUnavailable) { + t.Fatalf("enforce readiness error = %v", err) + } + repo.setError(nil) + recoveryCtx, recoveryCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer recoveryCancel() + if err := service.CreateDurable(recoveryCtx, auditdomain.Record{EventID: "recovered"}); err != nil { + t.Fatal(err) + } + if snapshot := service.LedgerSnapshot(); !snapshot.Ready || snapshot.ConsecutiveFailures != 0 { + t.Fatalf("recovered snapshot = %#v", snapshot) + } +} + +func TestLedgerReadinessDetectsSustainedQueuePressure(t *testing.T) { + now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + service := NewService(&toggleAuditRepository{}, slog.Default(), 2, 2, time.Hour) + service.now = func() time.Time { return now } + service.UpdateLedgerConfig(LedgerConfig{Mode: LedgerModeEnforce, FailureThreshold: 3, UnhealthyGrace: time.Second, QueueHighWatermarkPercent: 50}) + if !service.Record(auditdomain.Record{EventID: "queued"}) { + t.Fatal("record was not queued") + } + if snapshot := service.LedgerSnapshot(); !snapshot.Ready { + t.Fatalf("queue pressure should honor grace: %#v", snapshot) + } + now = now.Add(2 * time.Second) + if err := service.CheckLedgerReady(); !errors.Is(err, ErrLedgerUnavailable) { + t.Fatalf("queue pressure readiness error = %v", err) + } + <-service.queue + service.recordLedgerSuccess() + if snapshot := service.LedgerSnapshot(); !snapshot.Ready { + t.Fatalf("drained queue did not restore readiness: %#v", snapshot) + } +} + +func TestCommitObserverRunsOnlyAfterDurableCommit(t *testing.T) { + repo := &toggleAuditRepository{} + service := NewService(repo, slog.Default(), 8, 4, time.Hour) + service.Start() + var mu sync.Mutex + var committed []string + service.SetCommitObserver(func(values []string) { + mu.Lock() + committed = append(committed, values...) + mu.Unlock() + }) + if err := service.CreateDurable(context.Background(), auditdomain.Record{EventID: "sync"}); err != nil { + t.Fatal(err) + } + if !service.Record(auditdomain.Record{EventID: "batch"}) { + t.Fatal("record was not queued") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(committed) != 2 || committed[0] != "sync" || committed[1] != "batch" { + t.Fatalf("committed = %#v", committed) + } +} + +type toggleAuditRepository struct { + repository.AuditRepository + mu sync.RWMutex + err error +} + +func (r *toggleAuditRepository) setError(err error) { + r.mu.Lock() + r.err = err + r.mu.Unlock() +} + +func (r *toggleAuditRepository) currentError() error { + r.mu.RLock() + defer r.mu.RUnlock() + return r.err +} + +func (r *toggleAuditRepository) Create(context.Context, auditdomain.Record) error { + return r.currentError() +} + +func (r *toggleAuditRepository) CreateBatch(context.Context, []auditdomain.Record) error { + return r.currentError() +} + +type blockingAuditRepository struct { + repository.AuditRepository + release <-chan struct{} +} + +func (r *blockingAuditRepository) Create(ctx context.Context, _ auditdomain.Record) error { + <-ctx.Done() + return ctx.Err() +} + +func (r *blockingAuditRepository) CreateBatch(ctx context.Context, _ []auditdomain.Record) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-r.release: + return nil + } +} + +type gatedAuditRepository struct { + repository.AuditRepository + started chan []auditdomain.Record + release chan struct{} + committed chan struct{} + calls atomic.Int32 +} + +type observedBatchAuditRepository struct { + repository.AuditRepository + sizes chan int +} + +func (r *observedBatchAuditRepository) CreateBatch(ctx context.Context, values []auditdomain.Record) error { + select { + case r.sizes <- len(values): + default: + } + return r.AuditRepository.CreateBatch(ctx, values) +} + +func newGatedAuditRepository() *gatedAuditRepository { + return &gatedAuditRepository{ + started: make(chan []auditdomain.Record, 8), + release: make(chan struct{}), + committed: make(chan struct{}, 8), + } +} + +func (r *gatedAuditRepository) CreateBatch(ctx context.Context, values []auditdomain.Record) error { + r.calls.Add(1) + copyValues := append([]auditdomain.Record(nil), values...) + select { + case r.started <- copyValues: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-r.release: + case <-ctx.Done(): + return ctx.Err() + } + select { + case r.committed <- struct{}{}: + default: + } + return nil +} + +func closeAuditService(t *testing.T, service *Service) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Errorf("close audit service: %v", err) + } +} + +func (r *panicAuditRepository) CreateBatch(context.Context, []auditdomain.Record) error { + if r.attempts.Add(1) == 1 { + panic("database driver panic") + } + return nil +} + +func (r *flakyAuditRepository) CreateBatch(context.Context, []auditdomain.Record) error { + attempt := r.attempts.Add(1) + if attempt <= r.failures { + return errors.New("temporary database error") + } + return nil +} + +func TestSummaryUsesOfficialPricesAndExcludesUnknownModels(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-summary.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := relational.NewAuditRepository(database) + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + if err := repository.CreateBatch(ctx, []auditdomain.Record{ + {RequestID: "priced", ClientKeyID: 1, ModelRouteID: 1, ModelUpstreamModel: "grok-build-0.1", StatusCode: 200, InputTokens: 1_000_000, CachedInputTokens: 200_000, OutputTokens: 500_000, TotalTokens: 1_500_000, EstimatedCostInUSDTicks: 36_800_000_000, PricingModel: "grok-build-0.1", PricingVersion: auditdomain.OfficialPricingAsOf, DurationMS: 100, CreatedAt: now.Add(-time.Hour)}, + {RequestID: "unknown", ClientKeyID: 1, ModelRouteID: 1, ModelUpstreamModel: "grok-4.5-build-free", StatusCode: 500, InputTokens: 100, OutputTokens: 50, TotalTokens: 150, DurationMS: 300, CreatedAt: now.Add(-2 * time.Hour)}, + {RequestID: "outside", ClientKeyID: 1, ModelRouteID: 1, ModelUpstreamModel: "grok-build-0.1", StatusCode: 200, TotalTokens: 999, CreatedAt: now.Add(-25 * time.Hour)}, + }); err != nil { + t.Fatal(err) + } + service := NewService(repository, slog.Default(), 16, 8, time.Hour) + service.now = func() time.Time { return now } + result, err := service.Summary(ctx, "", "24h", ListFilter{}) + if err != nil { + t.Fatal(err) + } + if result.Usage.Requests != 2 || result.Usage.SuccessfulRequests != 1 || result.Usage.TotalTokens != 1_500_150 { + t.Fatalf("usage = %#v", result.Usage) + } + if result.Usage.EstimatedCostInUSDTicks != 36_800_000_000 || result.Usage.PricedRequests != 1 || result.Usage.UnpricedRequests != 1 { + t.Fatalf("pricing = %#v", result.Usage) + } + if result.Usage.AverageDurationMS != 200 || result.Usage.SuccessRate != 50 { + t.Fatalf("rates = %#v", result.Usage) + } +} + +func TestListCursorKeepsStableOrderAcrossEqualSortValues(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-sorted-cursor.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewAuditRepository(database) + now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + if err := repo.CreateBatch(ctx, []auditdomain.Record{ + {RequestID: "low", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, TotalTokens: 50, CreatedAt: now.Add(-3 * time.Minute)}, + {RequestID: "equal-old", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, TotalTokens: 100, CreatedAt: now.Add(-2 * time.Minute)}, + {RequestID: "equal-new", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, TotalTokens: 100, CreatedAt: now.Add(-time.Minute)}, + }); err != nil { + t.Fatal(err) + } + service := NewService(repo, slog.Default(), 8, 4, time.Hour) + service.now = func() time.Time { return now } + filter := ListFilter{Sort: repository.SortQuery{Field: "tokens", Direction: repository.SortDescending}} + first, err := service.ListCursor(ctx, "", 2, "", "24h", filter) + if err != nil || !first.HasMore || len(first.Items) != 2 || first.Items[0].RequestID != "equal-new" || first.Items[1].RequestID != "equal-old" || first.NextCursor == "" { + t.Fatalf("first page = %#v, err = %v", first, err) + } + second, err := service.ListCursor(ctx, first.NextCursor, 2, "", "24h", filter) + if err != nil || second.HasMore || len(second.Items) != 1 || second.Items[0].RequestID != "low" { + t.Fatalf("second page = %#v, err = %v", second, err) + } + wrongSort := ListFilter{Sort: repository.SortQuery{Field: "duration", Direction: repository.SortDescending}} + if _, err := service.ListCursor(ctx, first.NextCursor, 2, "", "24h", wrongSort); !errors.Is(err, ErrInvalidCursor) { + t.Fatalf("mismatched cursor error = %v", err) + } +} diff --git a/backend/internal/application/clientkey/cache.go b/backend/internal/application/clientkey/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..4ed4d71f8852e38ecfa3f35346baf299172c6329 --- /dev/null +++ b/backend/internal/application/clientkey/cache.go @@ -0,0 +1,146 @@ +package clientkey + +import ( + "sync" + "time" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" +) + +const ( + keyTouchInterval = time.Minute + touchTrackerMaxEntries = 10000 + keyAuthCacheTTL = time.Second + keyAuthCacheMaxEntries = 10000 +) + +type cachedAuthKey struct { + value clientkeydomain.Key + expiresAt time.Time +} + +type authKeyCache struct { + mu sync.RWMutex + byPrefix map[string]cachedAuthKey +} + +func newAuthKeyCache() *authKeyCache { + return &authKeyCache{byPrefix: make(map[string]cachedAuthKey)} +} + +func (c *authKeyCache) get(prefix string, now time.Time) (clientkeydomain.Key, bool) { + c.mu.RLock() + entry, ok := c.byPrefix[prefix] + c.mu.RUnlock() + if !ok || !now.Before(entry.expiresAt) { + if ok { + c.mu.Lock() + delete(c.byPrefix, prefix) + c.mu.Unlock() + } + return clientkeydomain.Key{}, false + } + value := entry.value + value.AllowedModels = append([]uint64(nil), entry.value.AllowedModels...) + return value, true +} + +func (c *authKeyCache) put(prefix string, value clientkeydomain.Key, now time.Time) { + if prefix == "" || value.BillingLimitUSDTicks > 0 { + return + } + value.EncryptedSecret = "" + value.AllowedModels = append([]uint64(nil), value.AllowedModels...) + c.mu.Lock() + defer c.mu.Unlock() + c.byPrefix[prefix] = cachedAuthKey{value: value, expiresAt: now.Add(keyAuthCacheTTL)} + if len(c.byPrefix) <= keyAuthCacheMaxEntries { + return + } + for candidate, entry := range c.byPrefix { + if !now.Before(entry.expiresAt) { + delete(c.byPrefix, candidate) + } + } + for len(c.byPrefix) > keyAuthCacheMaxEntries { + for candidate := range c.byPrefix { + delete(c.byPrefix, candidate) + break + } + } +} + +func (c *authKeyCache) deleteID(id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + for prefix, entry := range c.byPrefix { + if entry.value.ID == id { + delete(c.byPrefix, prefix) + } + } +} + +func (c *authKeyCache) deleteIDs(ids []uint64) { + set := make(map[uint64]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + c.mu.Lock() + defer c.mu.Unlock() + for prefix, entry := range c.byPrefix { + if _, ok := set[entry.value.ID]; ok { + delete(c.byPrefix, prefix) + } + } +} + +func (c *authKeyCache) clear() { + c.mu.Lock() + clear(c.byPrefix) + c.mu.Unlock() +} + +// touchTracker 合并非关键的最近使用时间写入。 +type touchTracker struct { + mu sync.Mutex + lastTouched map[uint64]time.Time +} + +func newTouchTracker() *touchTracker { + return &touchTracker{lastTouched: make(map[uint64]time.Time)} +} + +func (c *touchTracker) deleteID(id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.lastTouched, id) +} + +func (c *touchTracker) deleteIDs(ids []uint64) { + c.mu.Lock() + defer c.mu.Unlock() + for _, id := range ids { + delete(c.lastTouched, id) + } +} + +func (c *touchTracker) shouldTouch(id uint64, now time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + if last := c.lastTouched[id]; !last.IsZero() && now.Sub(last) < keyTouchInterval { + return false + } + c.lastTouched[id] = now + if len(c.lastTouched) > touchTrackerMaxEntries { + var oldestID uint64 + var oldest time.Time + for candidateID, touchedAt := range c.lastTouched { + if oldestID == 0 || touchedAt.Before(oldest) { + oldestID = candidateID + oldest = touchedAt + } + } + delete(c.lastTouched, oldestID) + } + return true +} diff --git a/backend/internal/application/clientkey/service.go b/backend/internal/application/clientkey/service.go new file mode 100644 index 0000000000000000000000000000000000000000..bfa2931f639b090c4e5f63687fe2635943b5aa2b --- /dev/null +++ b/backend/internal/application/clientkey/service.go @@ -0,0 +1,511 @@ +package clientkey + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrInvalidKey = errors.New("客户端 API Key 无效") + ErrModelNotAllowed = errors.New("客户端 API Key 无权使用该模型") + ErrRateLimited = errors.New("客户端 API Key 已超过 RPM 限制") + ErrConcurrencyLimit = errors.New("客户端 API Key 已达到并发上限") + ErrBillingLimit = errors.New("客户端 API Key 已达到用量上限") + ErrRuntimeUnavailable = errors.New("运行态存储暂不可用") + ErrInvalidFilter = errors.New("客户端 Key 筛选条件无效") + ErrInvalidInput = errors.New("客户端 Key 参数无效") + ErrNotFound = errors.New("客户端 Key 不存在") + ErrConflict = errors.New("客户端 Key 冲突") + ErrSecretUnavailable = errors.New("客户端 Key 明文不可用") +) + +type CreateInput struct { + Name string + Enabled bool + ExpiresAt *time.Time + RPMLimit int + RPMUnlimited bool + MaxConcurrent int + ConcurrencyUnlimited bool + BillingLimitUSDTicks int64 + AllowModelAliases bool + AllowedModels []uint64 + ProviderScope clientkeydomain.ProviderScope + TierScope clientkeydomain.TierScope +} + +type UpdateInput struct { + Name *string + Enabled *bool + ExpiresAt *time.Time + ClearExpiresAt bool + RPMLimit *int + MaxConcurrent *int + BillingLimitUSDTicks *int64 + AllowModelAliases *bool + AllowedModels *[]uint64 + ProviderScope *clientkeydomain.ProviderScope + TierScope *clientkeydomain.TierScope +} + +type Created struct { + Key clientkeydomain.Key + Secret string +} + +type ListFilter struct { + Status string + ModelScope string + Sort repository.SortQuery +} + +// Service 负责下游 API Key 创建、鉴权和调用限制。 +type Service struct { + keys repository.ClientKeyRepository + rateLimiter repository.RateLimiter + concurrency repository.ConcurrencyLimiter + defaultRPM atomic.Int64 + defaultMax atomic.Int64 + authCache *authKeyCache + touches *touchTracker + cipher *security.Cipher + activeMu sync.RWMutex + activeBilling map[string]struct{} +} + +type billingReservationRepository interface { + ReserveBillingUsage(ctx context.Context, id uint64, eventID string, amount int64, expiresAt time.Time) (bool, error) + CancelBillingReservation(ctx context.Context, eventID string) error + CleanupExpiredBillingReservations(ctx context.Context, now time.Time, limit int, protectedEventIDs ...[]string) (int, error) +} + +func NewService(keys repository.ClientKeyRepository, rateLimiter repository.RateLimiter, concurrency repository.ConcurrencyLimiter, defaultRPM, defaultMax int, cipher *security.Cipher) *Service { + service := &Service{keys: keys, rateLimiter: rateLimiter, concurrency: concurrency, authCache: newAuthKeyCache(), touches: newTouchTracker(), cipher: cipher, activeBilling: make(map[string]struct{})} + service.UpdateDefaults(defaultRPM, defaultMax) + return service +} + +func (s *Service) UpdateDefaults(defaultRPM, defaultMax int) { + s.defaultRPM.Store(int64(defaultRPM)) + s.defaultMax.Store(int64(defaultMax)) +} + +// ApplyInvalidation removes cached authorization policy after a local or remote +// client-key mutation. A zero ID represents a batch-wide invalidation. +func (s *Service) ApplyInvalidation(event repository.InvalidationEvent) { + if event.Kind != repository.InvalidationClientKeyChanged { + return + } + if event.ClientKeyID == 0 { + s.authCache.clear() + return + } + s.authCache.deleteID(event.ClientKeyID) +} + +func (s *Service) List(ctx context.Context, page, pageSize int, search string, filter ListFilter) ([]clientkeydomain.Key, int64, error) { + page, pageSize = normalizePage(page, pageSize) + if !validListFilter(filter.Status, "", "active", "disabled", "expired") || !validListFilter(filter.ModelScope, "", "all", "restricted") || !repository.IsValidSort(filter.Sort, "name", "prefix", "status", "rpmLimit", "maxConcurrent", "billingLimit", "expiresAt", "lastUsedAt") { + return nil, 0, ErrInvalidFilter + } + if prefix, ok := security.SplitClientKey(strings.TrimSpace(search)); ok { + search = prefix + } + return s.keys.List(ctx, repository.ClientKeyListQuery{Page: repository.PageQuery{Offset: (page - 1) * pageSize, Limit: pageSize, Search: search, Sort: filter.Sort}, Filter: repository.ClientKeyListFilter{Status: filter.Status, ModelScope: filter.ModelScope, Now: time.Now().UTC()}}) +} + +func validListFilter(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +// Create 创建客户端 Key;哈希用于鉴权,加密副本仅供管理员按需再次复制。 +func (s *Service) Create(ctx context.Context, input CreateInput) (Created, error) { + if strings.TrimSpace(input.Name) == "" { + return Created{}, invalidInput("Key 名称不能为空") + } + if input.RPMLimit < 0 || input.RPMLimit > clientkeydomain.MaxRPMLimit { + return Created{}, invalidInput("rpmLimit 必须在 0 到 100000 之间") + } + if input.MaxConcurrent < 0 || input.MaxConcurrent > clientkeydomain.MaxConcurrent { + return Created{}, invalidInput("maxConcurrent 必须在 0 到 1024 之间") + } + if input.BillingLimitUSDTicks < 0 || input.BillingLimitUSDTicks > clientkeydomain.MaxBillingLimitTicks { + return Created{}, invalidInput("billingLimitUsdTicks 超出允许范围") + } + providerScope, providerScopeValid := clientkeydomain.NormalizeProviderScope(input.ProviderScope) + tierScope, tierScopeValid := clientkeydomain.NormalizeTierScope(input.TierScope) + if !providerScopeValid || !tierScopeValid { + return Created{}, invalidInput("providerScope 或 tierScope 无效") + } + prefix, err := security.NewHexToken(6) + if err != nil { + return Created{}, err + } + secretPart, err := security.NewOpaqueToken(24) + if err != nil { + return Created{}, err + } + raw := security.FormatClientKey(prefix, secretPart) + if s.cipher == nil { + return Created{}, errors.New("客户端 Key 加密器未配置") + } + encryptedSecret, err := s.cipher.Encrypt(raw) + if err != nil { + return Created{}, fmt.Errorf("加密客户端 Key: %w", err) + } + if input.RPMUnlimited { + input.RPMLimit = 0 + } else if input.RPMLimit == 0 { + input.RPMLimit = int(s.defaultRPM.Load()) + } + if input.ConcurrencyUnlimited { + input.MaxConcurrent = 0 + } else if input.MaxConcurrent == 0 { + input.MaxConcurrent = int(s.defaultMax.Load()) + } + if input.RPMLimit < 0 || input.MaxConcurrent < 0 { + return Created{}, invalidInput("RPM 和最大并发不能小于零") + } + value, err := s.keys.Create(ctx, clientkeydomain.Key{ + Name: strings.TrimSpace(input.Name), Prefix: prefix, SecretHash: security.HashToken(raw), EncryptedSecret: encryptedSecret, + Enabled: input.Enabled, ExpiresAt: input.ExpiresAt, RPMLimit: input.RPMLimit, MaxConcurrent: input.MaxConcurrent, + BillingLimitUSDTicks: input.BillingLimitUSDTicks, AllowModelAliases: input.AllowModelAliases, AllowedModels: input.AllowedModels, + ProviderScope: providerScope, TierScope: tierScope, + }) + return Created{Key: value, Secret: raw}, mapRepositoryError(err) +} + +// RevealSecret 解密指定客户端 Key,并校验密文、前缀和鉴权哈希仍然一致。 +func (s *Service) RevealSecret(ctx context.Context, id uint64) (string, error) { + value, err := s.keys.Get(ctx, id) + if err != nil { + return "", mapRepositoryError(err) + } + if s.cipher == nil || value.EncryptedSecret == "" { + return "", ErrSecretUnavailable + } + raw, err := s.cipher.Decrypt(value.EncryptedSecret) + if err != nil { + return "", fmt.Errorf("解密客户端 Key: %w", err) + } + prefix, ok := security.SplitClientKey(raw) + if !ok || prefix != value.Prefix || subtle.ConstantTimeCompare([]byte(security.HashToken(raw)), []byte(value.SecretHash)) != 1 { + return "", errors.New("客户端 Key 加密副本校验失败") + } + return raw, nil +} + +func (s *Service) Update(ctx context.Context, id uint64, input UpdateInput) (clientkeydomain.Key, error) { + value, err := s.keys.Get(ctx, id) + if err != nil { + return clientkeydomain.Key{}, mapRepositoryError(err) + } + if input.Name != nil { + value.Name = strings.TrimSpace(*input.Name) + if value.Name == "" { + return clientkeydomain.Key{}, invalidInput("Key 名称不能为空") + } + } + if input.Enabled != nil { + value.Enabled = *input.Enabled + } + if input.ClearExpiresAt { + value.ExpiresAt = nil + } else if input.ExpiresAt != nil { + value.ExpiresAt = input.ExpiresAt + } + if input.RPMLimit != nil { + if *input.RPMLimit < 0 || *input.RPMLimit > clientkeydomain.MaxRPMLimit { + return clientkeydomain.Key{}, invalidInput("rpmLimit 必须在 0 到 100000 之间") + } + value.RPMLimit = *input.RPMLimit + } + if input.MaxConcurrent != nil { + if *input.MaxConcurrent < 0 || *input.MaxConcurrent > clientkeydomain.MaxConcurrent { + return clientkeydomain.Key{}, invalidInput("maxConcurrent 必须在 0 到 1024 之间") + } + value.MaxConcurrent = *input.MaxConcurrent + } + if input.BillingLimitUSDTicks != nil { + if *input.BillingLimitUSDTicks < 0 || *input.BillingLimitUSDTicks > clientkeydomain.MaxBillingLimitTicks { + return clientkeydomain.Key{}, invalidInput("billingLimitUsdTicks 超出允许范围") + } + value.BillingLimitUSDTicks = *input.BillingLimitUSDTicks + } + if input.AllowModelAliases != nil { + value.AllowModelAliases = *input.AllowModelAliases + } + if input.AllowedModels != nil { + value.AllowedModels = *input.AllowedModels + } + if input.ProviderScope != nil { + providerScope, valid := clientkeydomain.NormalizeProviderScope(*input.ProviderScope) + if !valid { + return clientkeydomain.Key{}, invalidInput("providerScope 无效") + } + value.ProviderScope = providerScope + } + if input.TierScope != nil { + tierScope, valid := clientkeydomain.NormalizeTierScope(*input.TierScope) + if !valid { + return clientkeydomain.Key{}, invalidInput("tierScope 无效") + } + value.TierScope = tierScope + } + updated, err := s.keys.Update(ctx, value) + if err == nil { + s.authCache.deleteID(id) + } + return updated, mapRepositoryError(err) +} + +func (s *Service) Delete(ctx context.Context, id uint64) error { + if err := s.keys.Delete(ctx, id); err != nil { + return mapRepositoryError(err) + } + s.touches.deleteID(id) + s.authCache.deleteID(id) + return nil +} + +// BatchSetEnabled 批量启用或停用客户端 Key。 +func (s *Service) BatchSetEnabled(ctx context.Context, ids []uint64, enabled bool) (int64, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, err + } + updated, err := s.keys.UpdateManyEnabled(ctx, values, enabled) + if err == nil { + s.touches.deleteIDs(values) + s.authCache.deleteIDs(values) + } + return updated, err +} + +// BatchDelete 原子删除客户端 Key 及其模型权限。 +func (s *Service) BatchDelete(ctx context.Context, ids []uint64) (int64, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, err + } + deleted, err := s.keys.DeleteMany(ctx, values) + if err == nil { + s.touches.deleteIDs(values) + s.authCache.deleteIDs(values) + } + return deleted, err +} + +// Authenticate 校验 API Key、RPM 和并发限制,并返回请求结束时必须调用的 release。 +func (s *Service) Authenticate(ctx context.Context, raw string) (clientkeydomain.Key, func(), error) { + prefix, ok := security.SplitClientKey(raw) + if !ok { + return clientkeydomain.Key{}, nil, ErrInvalidKey + } + now := time.Now().UTC() + value, cached := s.authCache.get(prefix, now) + if !cached { + var err error + value, err = s.keys.GetByPrefix(ctx, prefix) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return clientkeydomain.Key{}, nil, fmt.Errorf("%w: 客户端 Key 仓储: %v", ErrRuntimeUnavailable, err) + } + return clientkeydomain.Key{}, nil, ErrInvalidKey + } + s.authCache.put(prefix, value, now) + } + if !value.IsAvailable(now) { + return clientkeydomain.Key{}, nil, ErrInvalidKey + } + want := security.HashToken(raw) + if subtle.ConstantTimeCompare([]byte(want), []byte(value.SecretHash)) != 1 { + return clientkeydomain.Key{}, nil, ErrInvalidKey + } + if value.BillingLimitUSDTicks > 0 { + remaining := value.BillingLimitUSDTicks - value.BilledUsageUSDTicks + if remaining <= 0 || value.ReservedUsageUSDTicks >= remaining { + return clientkeydomain.Key{}, nil, ErrBillingLimit + } + } + if value.RPMLimit > 0 { + allowed, err := s.rateLimiter.Allow(ctx, fmt.Sprintf("client:%d", value.ID), value.RPMLimit, now) + if err != nil { + return clientkeydomain.Key{}, nil, fmt.Errorf("%w: RPM 限流器: %v", ErrRuntimeUnavailable, err) + } + if !allowed { + return clientkeydomain.Key{}, nil, ErrRateLimited + } + } + release := func() {} + if value.MaxConcurrent > 0 { + var acquired bool + var err error + release, acquired, err = s.concurrency.Acquire(ctx, fmt.Sprintf("client:%d", value.ID), value.MaxConcurrent) + if err != nil { + return clientkeydomain.Key{}, nil, fmt.Errorf("%w: 并发租约: %v", ErrRuntimeUnavailable, err) + } + if !acquired { + return clientkeydomain.Key{}, nil, ErrConcurrencyLimit + } + } + if s.touches.shouldTouch(value.ID, now) { + _ = s.keys.Touch(ctx, value.ID) + } + return value, release, nil +} + +// CanUseModel 判断空权限列表代表全部模型,否则要求显式授权。 +func (s *Service) CanUseModel(value clientkeydomain.Key, modelID uint64) bool { + return value.AllowsModel(modelID) +} + +// ReserveBilling 为有限额 Key 原子预留本次请求的预计费用。 +func (s *Service) ReserveBilling(ctx context.Context, key clientkeydomain.Key, eventID string, amount int64, ttl time.Duration) (bool, error) { + if key.BillingLimitUSDTicks <= 0 || amount <= 0 { + return false, nil + } + repo, ok := s.keys.(billingReservationRepository) + if !ok { + return false, fmt.Errorf("%w: 客户端 Key 仓储不支持计费预留", ErrRuntimeUnavailable) + } + if ttl <= 0 { + ttl = 24 * time.Hour + } + reserved, err := repo.ReserveBillingUsage(ctx, key.ID, eventID, amount, time.Now().UTC().Add(ttl)) + if errors.Is(err, repository.ErrLimitExceeded) { + perfmetrics.Default.Inc("billing_reservation_total", perfmetrics.Labels{Subsystem: "billing", Operation: "reserve", Outcome: "limit_exceeded"}) + return false, ErrBillingLimit + } + if err != nil { + perfmetrics.Default.Inc("billing_reservation_total", perfmetrics.Labels{Subsystem: "billing", Operation: "reserve", Outcome: "failed"}) + return false, fmt.Errorf("%w: 计费预留: %v", ErrRuntimeUnavailable, err) + } + if reserved { + s.activeMu.Lock() + s.activeBilling[eventID] = struct{}{} + s.activeMu.Unlock() + } + perfmetrics.Default.Inc("billing_reservation_total", perfmetrics.Labels{Subsystem: "billing", Operation: "reserve", Outcome: "success"}) + return reserved, nil +} + +// CancelBilling 释放未进入审计结算的计费预留。 +func (s *Service) CancelBilling(ctx context.Context, eventID string) error { + repo, ok := s.keys.(billingReservationRepository) + if !ok { + return nil + } + if err := repo.CancelBillingReservation(ctx, eventID); err != nil { + perfmetrics.Default.Inc("billing_reservation_total", perfmetrics.Labels{Subsystem: "billing", Operation: "cancel", Outcome: "failed"}) + return fmt.Errorf("%w: 取消计费预留: %v", ErrRuntimeUnavailable, err) + } + s.CompleteBilling(eventID) + perfmetrics.Default.Inc("billing_reservation_total", perfmetrics.Labels{Subsystem: "billing", Operation: "cancel", Outcome: "success"}) + return nil +} + +// CompleteBilling removes the process-local active marker after the audit and +// billing transaction commits or the reservation is explicitly cancelled. +func (s *Service) CompleteBilling(eventID string) { + if eventID == "" { + return + } + s.CompleteBillingBatch([]string{eventID}) +} + +func (s *Service) CompleteBillingBatch(eventIDs []string) { + s.ReleaseBillingProtectionBatch(eventIDs) +} + +// ReleaseBillingProtectionBatch removes process-local activity markers. The +// durable reservation remains authoritative until commit, cancel, or expiry. +func (s *Service) ReleaseBillingProtectionBatch(eventIDs []string) { + if len(eventIDs) == 0 { + return + } + s.activeMu.Lock() + for _, eventID := range eventIDs { + delete(s.activeBilling, eventID) + } + s.activeMu.Unlock() +} + +// CleanupExpiredBilling 释放进程异常遗留的过期预留。 +func (s *Service) CleanupExpiredBilling(ctx context.Context, limit int) (int, error) { + repo, ok := s.keys.(billingReservationRepository) + if !ok { + return 0, fmt.Errorf("%w: 客户端 Key 仓储不支持计费预留", ErrRuntimeUnavailable) + } + s.activeMu.RLock() + protected := make([]string, 0, len(s.activeBilling)) + for eventID := range s.activeBilling { + protected = append(protected, eventID) + } + s.activeMu.RUnlock() + cleaned, err := repo.CleanupExpiredBillingReservations(ctx, time.Now().UTC(), limit, protected) + outcome := "success" + if err != nil { + outcome = "failed" + } + perfmetrics.Default.Add("billing_reservation_cleanup_rows", perfmetrics.Labels{Subsystem: "billing", Operation: "cleanup", Outcome: outcome}, int64(cleaned)) + return cleaned, err +} + +func normalizePage(page, pageSize int) (int, int) { + return repository.NormalizePage(page, pageSize, repository.DefaultPageSize) +} + +func normalizeBatchIDs(ids []uint64) ([]uint64, error) { + if len(ids) == 0 { + return nil, invalidInput("至少选择一个 Key") + } + if len(ids) > repository.MaxPageSize { + return nil, invalidInput(fmt.Sprintf("单次最多处理 %d 个 Key", repository.MaxPageSize)) + } + seen := make(map[uint64]struct{}, len(ids)) + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + return nil, invalidInput("Key ID 无效") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result, nil +} + +// invalidInput 为可安全返回给管理端的客户端 Key 参数错误附加稳定语义。 +func invalidInput(message string) error { + return fmt.Errorf("%w: %s", ErrInvalidInput, message) +} + +// mapRepositoryError 将仓储错误转换为客户端 Key 应用错误。 +func mapRepositoryError(err error) error { + if errors.Is(err, repository.ErrNotFound) { + return ErrNotFound + } + if errors.Is(err, repository.ErrConflict) { + return ErrConflict + } + return err +} diff --git a/backend/internal/application/clientkey/service_test.go b/backend/internal/application/clientkey/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9316b3d031aec1397827239d247da79fd1bd83c3 --- /dev/null +++ b/backend/internal/application/clientkey/service_test.go @@ -0,0 +1,378 @@ +package clientkey + +import ( + "context" + "encoding/base64" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestCreateUsesG2AClientKeyFormat(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "client-key.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + service := NewService(relational.NewClientKeyRepository(database), nil, nil, 60, 5, testCipher(t)) + created, err := service.Create(ctx, CreateInput{Name: "test", Enabled: true}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(created.Secret, "g2a_") { + t.Fatalf("client key = %q", created.Secret) + } + prefix, ok := security.SplitClientKey(created.Secret) + if !ok || prefix != created.Key.Prefix { + t.Fatalf("parsed prefix = %q, key prefix = %q, ok = %v", prefix, created.Key.Prefix, ok) + } + values, total, err := service.List(ctx, 1, 20, created.Secret, ListFilter{}) + if err != nil || total != 1 || len(values) != 1 || values[0].ID != created.Key.ID { + t.Fatalf("search by full client key values = %#v, total = %d, err = %v", values, total, err) + } + if values[0].EncryptedSecret != "" || values[0].SecretHash != "" { + t.Fatal("客户端 Key 列表不应加载哈希或加密密文") + } + if _, err := service.Create(ctx, CreateInput{Name: "unlimited", Enabled: true, RPMLimit: -1}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("negative rpm error = %v", err) + } + zero := 0 + updated, err := service.Update(ctx, created.Key.ID, UpdateInput{MaxConcurrent: &zero}) + if err != nil || updated.MaxConcurrent != 0 { + t.Fatalf("zero concurrency update = %#v, err = %v", updated, err) + } + revealed, err := service.RevealSecret(ctx, created.Key.ID) + if err != nil || revealed != created.Secret { + t.Fatalf("revealed secret = %q, err = %v", revealed, err) + } +} + +func TestUnlimitedRuntimeLimitsBypassLimiterStores(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "unlimited-runtime.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewClientKeyRepository(database) + service := NewService(repo, failingRateLimiter{}, failingConcurrencyLimiter{}, 60, 5, testCipher(t)) + created, err := service.Create(ctx, CreateInput{ + Name: "unlimited", Enabled: true, + RPMUnlimited: true, ConcurrencyUnlimited: true, + }) + if err != nil { + t.Fatal(err) + } + if created.Key.RPMLimit != 0 || created.Key.MaxConcurrent != 0 { + t.Fatalf("persisted limits = rpm %d, concurrency %d", created.Key.RPMLimit, created.Key.MaxConcurrent) + } + value, release, err := service.Authenticate(ctx, created.Secret) + if err != nil { + t.Fatalf("authenticate unlimited key: %v", err) + } + if value.ID != created.Key.ID { + t.Fatalf("authenticated key = %d, want %d", value.ID, created.Key.ID) + } + release() +} + +func TestAuthenticateDistinguishesRuntimeStoreFailures(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "runtime-errors.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewClientKeyRepository(database) + cipher := testCipher(t) + created, err := NewService(repo, nil, nil, 60, 5, cipher).Create(ctx, CreateInput{Name: "test", Enabled: true}) + if err != nil { + t.Fatal(err) + } + + rateFailure := NewService(repo, failingRateLimiter{}, successfulConcurrencyLimiter{}, 60, 5, cipher) + if _, _, err := rateFailure.Authenticate(ctx, created.Secret); !errors.Is(err, ErrRuntimeUnavailable) { + t.Fatalf("rate limiter error = %v", err) + } + concurrencyFailure := NewService(repo, successfulRateLimiter{}, failingConcurrencyLimiter{}, 60, 5, cipher) + if _, _, err := concurrencyFailure.Authenticate(ctx, created.Secret); !errors.Is(err, ErrRuntimeUnavailable) { + t.Fatalf("concurrency limiter error = %v", err) + } + persistenceFailure := NewService(failingClientKeyRepository{ClientKeyRepository: repo}, successfulRateLimiter{}, successfulConcurrencyLimiter{}, 60, 5, cipher) + if _, _, err := persistenceFailure.Authenticate(ctx, created.Secret); !errors.Is(err, ErrRuntimeUnavailable) { + t.Fatalf("client key repository error = %v", err) + } +} + +func TestBillingLimitUsesAtomicReservations(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "billing-limit.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := relational.NewClientKeyRepository(database) + service := NewService(keys, successfulRateLimiter{}, successfulConcurrencyLimiter{}, 60, 5, testCipher(t)) + created, err := service.Create(ctx, CreateInput{Name: "limited", Enabled: true, BillingLimitUSDTicks: 6_000_000_000}) + if err != nil { + t.Fatal(err) + } + reserved, err := service.ReserveBilling(ctx, created.Key, "evt_client_key_reservation_0001", 2_000_000_000, time.Hour) + if err != nil || !reserved { + t.Fatal(err) + } + reserved, err = service.ReserveBilling(ctx, created.Key, "evt_client_key_reservation_0002", 4_000_000_000, time.Hour) + if err != nil || !reserved { + t.Fatalf("reserve remaining limit: reserved=%v err=%v", reserved, err) + } + if _, _, err := service.Authenticate(ctx, created.Secret); !errors.Is(err, ErrBillingLimit) { + t.Fatalf("reserved billing limit error = %v", err) + } + if _, err := service.ReserveBilling(ctx, created.Key, "evt_client_key_reservation_0003", 1, time.Hour); !errors.Is(err, ErrBillingLimit) { + t.Fatalf("billing limit error = %v", err) + } + if err := service.CancelBilling(ctx, "evt_client_key_reservation_0001"); err != nil { + t.Fatal(err) + } + if reserved, err := service.ReserveBilling(ctx, created.Key, "evt_client_key_reservation_0003", 1_000_000_000, time.Hour); err != nil || !reserved { + t.Fatalf("reserve after cancel: reserved=%v err=%v", reserved, err) + } + values, _, err := service.List(ctx, 1, 20, "", ListFilter{}) + if err != nil || len(values) != 1 || values[0].ReservedUsageUSDTicks != 5_000_000_000 { + t.Fatalf("listed usage = %#v, err = %v", values, err) + } + unlimited, err := service.Create(ctx, CreateInput{Name: "unlimited", Enabled: true}) + if err != nil { + t.Fatal(err) + } + if reserved, err := service.ReserveBilling(ctx, unlimited.Key, "evt_client_key_unlimited_0001", 100_000_000_000, time.Hour); err != nil || reserved { + t.Fatalf("unlimited reservation = %v, err = %v", reserved, err) + } + _, unlimitedRelease, err := service.Authenticate(ctx, unlimited.Secret) + if err != nil { + t.Fatalf("authenticate unlimited key: %v", err) + } + unlimitedRelease() +} + +func TestCleanupExpiredBillingProtectsActiveRequest(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "active-billing.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := relational.NewClientKeyRepository(database) + service := NewService(repository, nil, nil, 60, 5, testCipher(t)) + created, err := service.Create(ctx, CreateInput{Name: "active", Enabled: true, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + const eventID = "evt_active_cleanup_protection" + if reserved, reserveErr := service.ReserveBilling(ctx, created.Key, eventID, 40, time.Nanosecond); reserveErr != nil || !reserved { + t.Fatalf("reserve: reserved=%v err=%v", reserved, reserveErr) + } + time.Sleep(time.Millisecond) + if cleaned, cleanupErr := service.CleanupExpiredBilling(ctx, 10); cleanupErr != nil || cleaned != 0 { + t.Fatalf("active cleanup: cleaned=%d err=%v", cleaned, cleanupErr) + } + service.CompleteBilling(eventID) + if cleaned, cleanupErr := service.CleanupExpiredBilling(ctx, 10); cleanupErr != nil || cleaned != 1 { + t.Fatalf("completed cleanup: cleaned=%d err=%v", cleaned, cleanupErr) + } +} + +func TestAuthenticateCachesUnlimitedKeyAndInvalidatesOnDisable(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "auth-cache.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + base := relational.NewClientKeyRepository(database) + created, err := NewService(base, successfulRateLimiter{}, successfulConcurrencyLimiter{}, 60, 5, testCipher(t)).Create(ctx, CreateInput{Name: "cached", Enabled: true}) + if err != nil { + t.Fatal(err) + } + repository := &countingClientKeyRepository{ClientKeyRepository: base} + service := NewService(repository, successfulRateLimiter{}, successfulConcurrencyLimiter{}, 60, 5, testCipher(t)) + for range 2 { + _, release, err := service.Authenticate(ctx, created.Secret) + if err != nil { + t.Fatal(err) + } + release() + } + if repository.lookups != 1 { + t.Fatalf("鉴权查询次数 = %d, want 1", repository.lookups) + } + if _, err := service.BatchSetEnabled(ctx, []uint64{created.Key.ID}, false); err != nil { + t.Fatal(err) + } + if _, _, err := service.Authenticate(ctx, created.Secret); !errors.Is(err, ErrInvalidKey) { + t.Fatalf("停用后的鉴权错误 = %v", err) + } + if repository.lookups != 2 { + t.Fatalf("缓存失效后的查询次数 = %d, want 2", repository.lookups) + } +} + +func TestAccountScopePersistsAndAuthCacheInvalidatesOnChange(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "account-pool-auth-cache.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + base := relational.NewClientKeyRepository(database) + service := NewService(base, successfulRateLimiter{}, successfulConcurrencyLimiter{}, 60, 5, testCipher(t)) + created, err := service.Create(ctx, CreateInput{Name: "scoped", Enabled: true, ProviderScope: clientkeydomain.ProviderScopeBuild | clientkeydomain.ProviderScopeWeb, TierScope: clientkeydomain.TierScopeFree}) + if err != nil { + t.Fatal(err) + } + value, release, err := service.Authenticate(ctx, created.Secret) + if err != nil { + t.Fatal(err) + } + release() + if value.ProviderScope != clientkeydomain.ProviderScopeBuild|clientkeydomain.ProviderScopeWeb || value.TierScope != clientkeydomain.TierScopeFree { + t.Fatalf("authenticated account scope = %+v", value.AccountScope()) + } + consoleScope := clientkeydomain.ProviderScopeConsole + superTier := clientkeydomain.TierScopeSuper + if _, err := service.Update(ctx, created.Key.ID, UpdateInput{ProviderScope: &consoleScope, TierScope: &superTier}); err != nil { + t.Fatal(err) + } + value, release, err = service.Authenticate(ctx, created.Secret) + if err != nil { + t.Fatal(err) + } + release() + if value.ProviderScope != clientkeydomain.ProviderScopeConsole || value.TierScope != clientkeydomain.TierScopeSuper { + t.Fatalf("account scope after cache invalidation = %+v", value.AccountScope()) + } + stored, err := base.Get(ctx, created.Key.ID) + if err != nil { + t.Fatal(err) + } + stored.ProviderScope = clientkeydomain.ProviderScopeWeb + stored.TierScope = clientkeydomain.TierScopeFree + if _, err := base.Update(ctx, stored); err != nil { + t.Fatal(err) + } + value, release, err = service.Authenticate(ctx, created.Secret) + if err != nil { + t.Fatal(err) + } + release() + if value.ProviderScope != clientkeydomain.ProviderScopeConsole || value.TierScope != clientkeydomain.TierScopeSuper { + t.Fatalf("cache unexpectedly changed before remote invalidation = %+v", value.AccountScope()) + } + service.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged, ClientKeyID: created.Key.ID}) + value, release, err = service.Authenticate(ctx, created.Secret) + if err != nil { + t.Fatal(err) + } + release() + if value.ProviderScope != clientkeydomain.ProviderScopeWeb || value.TierScope != clientkeydomain.TierScopeFree { + t.Fatalf("account scope after remote invalidation = %+v", value.AccountScope()) + } + service.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged}) + service.authCache.mu.RLock() + cacheEntries := len(service.authCache.byPrefix) + service.authCache.mu.RUnlock() + if cacheEntries != 0 { + t.Fatalf("batch invalidation retained %d auth cache entries", cacheEntries) + } + invalid := clientkeydomain.ProviderScope(8) + if _, err := service.Update(ctx, created.Key.ID, UpdateInput{ProviderScope: &invalid}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid account scope error = %v", err) + } + all, err := service.Create(ctx, CreateInput{Name: "legacy-default", Enabled: true}) + if err != nil || all.Key.ProviderScope != clientkeydomain.ProviderScopeAll || all.Key.TierScope != clientkeydomain.TierScopeAll { + t.Fatalf("default account scope = %+v, err = %v", all.Key.AccountScope(), err) + } +} + +func testCipher(t *testing.T) *security.Cipher { + t.Helper() + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + return cipher +} + +type failingRateLimiter struct{} + +func (failingRateLimiter) Allow(context.Context, string, int, time.Time) (bool, error) { + return false, errors.New("redis unavailable") +} + +type successfulRateLimiter struct{} + +func (successfulRateLimiter) Allow(context.Context, string, int, time.Time) (bool, error) { + return true, nil +} + +type failingConcurrencyLimiter struct{} + +func (failingConcurrencyLimiter) Acquire(context.Context, string, int) (func(), bool, error) { + return nil, false, errors.New("redis unavailable") +} +func (failingConcurrencyLimiter) Current(context.Context, string) (int, error) { return 0, nil } + +type successfulConcurrencyLimiter struct{} + +func (successfulConcurrencyLimiter) Acquire(context.Context, string, int) (func(), bool, error) { + return func() {}, true, nil +} +func (successfulConcurrencyLimiter) Current(context.Context, string) (int, error) { return 0, nil } + +type failingClientKeyRepository struct{ repository.ClientKeyRepository } + +func (failingClientKeyRepository) GetByPrefix(context.Context, string) (clientkeydomain.Key, error) { + return clientkeydomain.Key{}, errors.New("database unavailable") +} + +type countingClientKeyRepository struct { + repository.ClientKeyRepository + lookups int +} + +func (r *countingClientKeyRepository) GetByPrefix(ctx context.Context, prefix string) (clientkeydomain.Key, error) { + r.lookups++ + return r.ClientKeyRepository.GetByPrefix(ctx, prefix) +} + +var _ repository.RateLimiter = failingRateLimiter{} +var _ repository.ConcurrencyLimiter = failingConcurrencyLimiter{} diff --git a/backend/internal/application/dashboard/service.go b/backend/internal/application/dashboard/service.go new file mode 100644 index 0000000000000000000000000000000000000000..172d398f261be06e223044b157f0c3d9aecfa51d --- /dev/null +++ b/backend/internal/application/dashboard/service.go @@ -0,0 +1,249 @@ +package dashboard + +import ( + "context" + "errors" + "strings" + "time" + + dashboarddomain "github.com/chenyme/grok2api/backend/internal/domain/dashboard" + "github.com/chenyme/grok2api/backend/internal/pkg/resultcache" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ErrInvalidPeriod = errors.New("Dashboard 时间范围无效") +var ErrInvalidTimezone = errors.New("Dashboard 时区无效") + +const dashboardCacheTTL = 15 * time.Second + +type Period string + +const ( + Period24Hours Period = "24h" + Period7Days Period = "7d" + Period30Days Period = "30d" + Period90Days Period = "90d" +) + +type Range struct { + Start time.Time + End time.Time +} + +type SeriesPoint struct { + Start time.Time + End time.Time + Requests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 +} + +type ActivityPoint struct { + Start time.Time + End time.Time + Requests int64 +} + +type ModelUsage struct { + Model string + Requests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 +} + +type Result struct { + Period Period + GeneratedAt time.Time + Range Range + Resources dashboarddomain.Resources + Usage dashboarddomain.Usage + Series []SeriesPoint + Activity []ActivityPoint + TopModels []ModelUsage + Providers []dashboarddomain.ProviderUsage +} + +// Service 负责 Dashboard 时间范围校验和固定时间桶编排。 +type Service struct { + dashboard repository.DashboardRepository + now func() time.Time + cache *resultcache.Cache[string, Result] +} + +func NewService(dashboard repository.DashboardRepository) *Service { + return &Service{dashboard: dashboard, now: time.Now, cache: resultcache.New[string, Result](32, dashboardCacheTTL)} +} + +// Get 返回指定时间范围的 Dashboard 聚合快照。 +func (s *Service) Get(ctx context.Context, rawPeriod, rawTimezone string) (Result, error) { + return s.get(ctx, rawPeriod, rawTimezone, true) +} + +// Refresh 绕过短缓存,供管理员显式刷新时读取最新聚合数据。 +func (s *Service) Refresh(ctx context.Context, rawPeriod, rawTimezone string) (Result, error) { + return s.get(ctx, rawPeriod, rawTimezone, false) +} + +func (s *Service) get(ctx context.Context, rawPeriod, rawTimezone string, useCache bool) (Result, error) { + period, bucketCount, bucketDays, err := parsePeriod(rawPeriod) + if err != nil { + return Result{}, err + } + location, err := parseTimezone(rawTimezone) + if err != nil { + return Result{}, err + } + rawNow := s.now() + if !useCache { + return s.load(ctx, period, bucketCount, bucketDays, location, rawNow) + } + cacheKey := string(period) + "\x00" + location.String() + return s.cache.Load(ctx, cacheKey, rawNow, func() (Result, error) { + return s.load(ctx, period, bucketCount, bucketDays, location, rawNow) + }) +} + +func (s *Service) load(ctx context.Context, period Period, bucketCount, bucketDays int, location *time.Location, rawNow time.Time) (Result, error) { + now := rawNow.In(location) + series := buildSeries(now, period, bucketCount, bucketDays) + boundaries := make([]time.Time, 0, len(series)) + for _, point := range series { + boundaries = append(boundaries, point.Start) + } + boundaries = append(boundaries, series[len(series)-1].End) + activity := buildActivitySeries(now) + activityBoundaries := make([]time.Time, 0, len(activity)+1) + for _, point := range activity { + activityBoundaries = append(activityBoundaries, point.Start) + } + activityBoundaries = append(activityBoundaries, activity[len(activity)-1].End) + generatedAt := rawNow.UTC() + aggregate, err := s.dashboard.Snapshot(ctx, repository.DashboardSnapshotWindow{ + BucketBoundaries: boundaries, + ActivityBoundaries: activityBoundaries, + }, generatedAt) + if err != nil { + return Result{}, err + } + for _, bucket := range aggregate.Buckets { + if bucket.Index < 0 || bucket.Index >= len(series) { + continue + } + series[bucket.Index].Requests = bucket.Requests + series[bucket.Index].InputTokens = bucket.InputTokens + series[bucket.Index].CachedInputTokens = bucket.CachedInputTokens + series[bucket.Index].OutputTokens = bucket.OutputTokens + series[bucket.Index].ReasoningTokens = bucket.ReasoningTokens + series[bucket.Index].Tokens = bucket.Tokens + series[bucket.Index].BilledCostUSDTicks = bucket.BilledCostUSDTicks + } + for _, bucket := range aggregate.ActivityBuckets { + if bucket.Index < 0 || bucket.Index >= len(activity) { + continue + } + activity[bucket.Index].Requests = bucket.Requests + } + topModels := make([]ModelUsage, 0, len(aggregate.TopModels)) + for _, item := range aggregate.TopModels { + topModels = append(topModels, ModelUsage{Model: item.Model, Requests: item.Requests, InputTokens: item.InputTokens, CachedInputTokens: item.CachedInputTokens, OutputTokens: item.OutputTokens, ReasoningTokens: item.ReasoningTokens, Tokens: item.Tokens, BilledCostUSDTicks: item.BilledCostUSDTicks}) + } + return Result{ + Period: period, + GeneratedAt: generatedAt, + Range: Range{Start: boundaries[0], End: boundaries[len(boundaries)-1]}, + Resources: aggregate.Resources, + Usage: aggregate.Usage, + Series: series, + Activity: activity, + TopModels: topModels, + Providers: aggregate.Providers, + }, nil +} + +func parseTimezone(value string) (*time.Location, error) { + value = strings.TrimSpace(value) + if value == "" { + value = "UTC" + } + if value == "Local" || len(value) > 128 { + return nil, ErrInvalidTimezone + } + location, err := time.LoadLocation(value) + if err != nil { + return nil, ErrInvalidTimezone + } + return location, nil +} + +func parsePeriod(value string) (Period, int, int, error) { + if value == "" { + value = string(Period24Hours) + } + switch Period(value) { + case Period24Hours: + return Period24Hours, 24, 0, nil + case Period7Days: + return Period7Days, 7, 1, nil + case Period30Days: + return Period30Days, 30, 1, nil + case Period90Days: + return Period90Days, 13, 7, nil + default: + return "", 0, 0, ErrInvalidPeriod + } +} + +func alignedRange(now time.Time, period Period) (time.Time, time.Time) { + location := now.Location() + if period == Period24Hours { + currentHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, location) + return currentHour.Add(-23 * time.Hour), currentHour.Add(time.Hour) + } + tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, location) + switch period { + case Period7Days: + return tomorrow.AddDate(0, 0, -7), tomorrow + case Period30Days: + return tomorrow.AddDate(0, 0, -30), tomorrow + default: + return tomorrow.AddDate(0, 0, -90), tomorrow + } +} + +func buildSeries(now time.Time, period Period, bucketCount, bucketDays int) []SeriesPoint { + start, end := alignedRange(now, period) + series := make([]SeriesPoint, bucketCount) + for index := range series { + bucketStart := start.AddDate(0, 0, index*bucketDays) + bucketEnd := bucketStart.AddDate(0, 0, bucketDays) + if period == Period24Hours { + bucketStart = start.Add(time.Duration(index) * time.Hour) + bucketEnd = bucketStart.Add(time.Hour) + } + if bucketEnd.After(end) { + bucketEnd = end + } + series[index] = SeriesPoint{Start: bucketStart.UTC(), End: bucketEnd.UTC()} + } + return series +} + +func buildActivitySeries(now time.Time) []ActivityPoint { + location := now.Location() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + start := today.AddDate(0, 0, -179) + series := make([]ActivityPoint, 180) + for index := range series { + dayStart := start.AddDate(0, 0, index) + series[index] = ActivityPoint{Start: dayStart.UTC(), End: dayStart.AddDate(0, 0, 1).UTC()} + } + return series +} diff --git a/backend/internal/application/dashboard/service_test.go b/backend/internal/application/dashboard/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7dfb1042ec7d5a960cf4ac788d6e50aff61ae0e7 --- /dev/null +++ b/backend/internal/application/dashboard/service_test.go @@ -0,0 +1,194 @@ +package dashboard + +import ( + "context" + "testing" + "time" + + dashboarddomain "github.com/chenyme/grok2api/backend/internal/domain/dashboard" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestGetBuildsStableBucketsAndEnrichedData(t *testing.T) { + now := time.Date(2026, 7, 11, 12, 34, 56, 0, time.UTC) + repository := &dashboardRepositoryStub{aggregate: dashboarddomain.Aggregate{ + Usage: dashboarddomain.Usage{Requests: 4, SuccessfulRequests: 3, FailedRequests: 1, Tokens: 120}, + Buckets: []dashboarddomain.Bucket{{Index: 0, Requests: 1, Tokens: 20}, {Index: 23, Requests: 3, Tokens: 100}}, + ActivityBuckets: []dashboarddomain.ActivityBucket{{Index: 179, Requests: 4}}, + TopModels: []dashboarddomain.ModelUsage{{Model: "grok-test", Requests: 4, Tokens: 120}}, + Providers: []dashboarddomain.ProviderUsage{{Provider: "grok_build", Requests: 4, SuccessfulRequests: 3, Tokens: 120}}, + }} + service := NewService(repository) + service.now = func() time.Time { return now } + + result, err := service.Get(context.Background(), "24h", "UTC") + if err != nil { + t.Fatal(err) + } + if result.Period != Period24Hours || len(result.Series) != 24 { + t.Fatalf("result = %#v", result) + } + if result.Series[0].Requests != 1 || result.Series[23].Tokens != 100 { + t.Fatalf("series = %#v", result.Series) + } + if len(result.TopModels) != 1 || result.TopModels[0].Model != "grok-test" || result.TopModels[0].Requests != 4 { + t.Fatalf("top models = %#v", result.TopModels) + } + if len(result.Activity) != 180 || result.Activity[179].Requests != 4 || len(result.Providers) != 1 { + t.Fatalf("enriched dashboard = %#v", result) + } + if !result.Range.Start.Equal(time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC)) || !result.Range.End.Equal(time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC)) { + t.Fatalf("range = %#v", result.Range) + } + if !result.GeneratedAt.Equal(now) || !result.Series[23].Start.Equal(time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)) || !result.Series[23].End.Equal(time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC)) { + t.Fatalf("current hour = %#v", result.Series[23]) + } +} + +func TestGetUsesPeriodSpecificBucketCounts(t *testing.T) { + for period, expected := range map[string]int{"24h": 24, "7d": 7, "30d": 30, "90d": 13} { + repository := &dashboardRepositoryStub{} + service := NewService(repository) + if _, err := service.Get(context.Background(), period, "UTC"); err != nil { + t.Fatalf("period %s: %v", period, err) + } + if repository.bucketCount != expected { + t.Fatalf("period %s bucket count = %d, want %d", period, repository.bucketCount, expected) + } + } +} + +func TestGetCachesRepeatedAggregate(t *testing.T) { + repository := &dashboardRepositoryStub{} + service := NewService(repository) + service.now = func() time.Time { return time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) } + + if _, err := service.Get(context.Background(), "24h", "UTC"); err != nil { + t.Fatal(err) + } + if _, err := service.Get(context.Background(), "24h", "UTC"); err != nil { + t.Fatal(err) + } + if repository.calls != 1 { + t.Fatalf("snapshot calls = %d", repository.calls) + } +} + +func TestRefreshBypassesAggregateCache(t *testing.T) { + repository := &dashboardRepositoryStub{} + service := NewService(repository) + service.now = func() time.Time { return time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) } + + if _, err := service.Get(context.Background(), "24h", "UTC"); err != nil { + t.Fatal(err) + } + if _, err := service.Refresh(context.Background(), "24h", "UTC"); err != nil { + t.Fatal(err) + } + if repository.calls != 2 { + t.Fatalf("snapshot calls = %d", repository.calls) + } +} + +func TestGetAlignsDailyBucketsToCalendarDays(t *testing.T) { + location := time.FixedZone("Asia/Shanghai", 8*60*60) + now := time.Date(2026, 7, 11, 12, 34, 56, 0, location) + repository := &dashboardRepositoryStub{} + service := NewService(repository) + service.now = func() time.Time { return now } + + result, err := service.Get(context.Background(), "7d", "Asia/Shanghai") + if err != nil { + t.Fatal(err) + } + wantStart := time.Date(2026, 7, 5, 0, 0, 0, 0, location).UTC() + wantEnd := time.Date(2026, 7, 12, 0, 0, 0, 0, location).UTC() + if !result.Range.Start.Equal(wantStart) || !result.Range.End.Equal(wantEnd) { + t.Fatalf("range = %#v", result.Range) + } + if !result.Series[6].Start.Equal(time.Date(2026, 7, 11, 0, 0, 0, 0, location).UTC()) || !result.Series[6].End.Equal(wantEnd) { + t.Fatalf("today bucket = %#v", result.Series[6]) + } + if len(result.Activity) != 180 || !result.Activity[179].Start.Equal(time.Date(2026, 7, 11, 0, 0, 0, 0, location).UTC()) { + t.Fatalf("activity range = %#v", result.Activity) + } +} + +func TestGetUsesWeeklyBucketsFor90Days(t *testing.T) { + now := time.Date(2026, 7, 11, 12, 34, 56, 0, time.UTC) + service := NewService(&dashboardRepositoryStub{}) + service.now = func() time.Time { return now } + + result, err := service.Get(context.Background(), "90d", "UTC") + if err != nil { + t.Fatal(err) + } + for index, bucket := range result.Series[:len(result.Series)-1] { + if bucket.End.Sub(bucket.Start) != 7*24*time.Hour { + t.Fatalf("bucket %d duration = %s", index, bucket.End.Sub(bucket.Start)) + } + } + last := result.Series[len(result.Series)-1] + if last.End.Sub(last.Start) != 6*24*time.Hour || !last.End.Equal(result.Range.End) { + t.Fatalf("last bucket = %#v, range = %#v", last, result.Range) + } +} + +func TestGetRejectsUnknownPeriod(t *testing.T) { + service := NewService(&dashboardRepositoryStub{}) + if _, err := service.Get(context.Background(), "365d", "UTC"); err != ErrInvalidPeriod { + t.Fatalf("err = %v", err) + } +} + +func TestGetUsesCalendarBoundariesAcrossDST(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + repository := &dashboardRepositoryStub{} + service := NewService(repository) + service.now = func() time.Time { return now } + + result, err := service.Get(context.Background(), "7d", "America/New_York") + if err != nil { + t.Fatal(err) + } + var foundShortDay bool + for _, bucket := range result.Series { + if bucket.End.Sub(bucket.Start) == 23*time.Hour { + foundShortDay = true + } + } + if !foundShortDay { + t.Fatalf("DST transition was not represented: %#v", result.Series) + } + foundShortDay = false + for index := 1; index < len(repository.window.ActivityBoundaries); index++ { + if repository.window.ActivityBoundaries[index].Sub(repository.window.ActivityBoundaries[index-1]) == 23*time.Hour { + foundShortDay = true + break + } + } + if !foundShortDay { + t.Fatalf("DST transition was not represented in activity boundaries") + } +} + +func TestGetRejectsInvalidTimezone(t *testing.T) { + service := NewService(&dashboardRepositoryStub{}) + if _, err := service.Get(context.Background(), "24h", "Mars/Olympus"); err != ErrInvalidTimezone { + t.Fatalf("err = %v", err) + } +} + +type dashboardRepositoryStub struct { + aggregate dashboarddomain.Aggregate + bucketCount int + calls int + window repository.DashboardSnapshotWindow +} + +func (s *dashboardRepositoryStub) Snapshot(_ context.Context, window repository.DashboardSnapshotWindow, _ time.Time) (dashboarddomain.Aggregate, error) { + s.calls++ + s.bucketCount = len(window.BucketBoundaries) - 1 + s.window = window + return s.aggregate, nil +} diff --git a/backend/internal/application/egress/assignment.go b/backend/internal/application/egress/assignment.go new file mode 100644 index 0000000000000000000000000000000000000000..0abb1e0ec2c97a853d6046cbfbda692c493c96a3 --- /dev/null +++ b/backend/internal/application/egress/assignment.go @@ -0,0 +1,345 @@ +package egress + +import ( + "context" + "errors" + "sort" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + autoAssignmentMigrationCooldown = 5 * time.Minute + maxAutomaticReassignments = 200 +) + +type RebalanceResult struct { + Assigned int + Rebalanced int + Unplaced int +} + +// RebalanceAccounts allocates only accounts that are either unbound or +// explicitly marked auto. Manual bindings are never changed, even when their +// node is unhealthy or over capacity. +func (s *Service) RebalanceAccounts(ctx context.Context, autoAssign, autoBalance bool, probeInterval time.Duration) (RebalanceResult, error) { + if s.accounts == nil { + return RebalanceResult{}, ErrOperationsUnavailable + } + if !autoAssign && !autoBalance { + return RebalanceResult{}, nil + } + s.assignmentMu.Lock() + defer s.assignmentMu.Unlock() + now := time.Now().UTC() + if probeInterval <= 0 { + probeInterval = defaultProbeIntervalSeconds * time.Second + } + result := RebalanceResult{} + for _, provider := range accountdomain.Providers() { + // Node capacity is global across Provider pools. Refresh the counts after + // each provider so Web and Console assignments cannot overfill a shared + // Web node during the same maintenance pass. + nodes, err := s.repository.ListEgressNodes(ctx, "", repository.SortQuery{}) + if err != nil { + return result, err + } + providerResult, providerErr := s.rebalanceProvider(ctx, provider, nodes, autoAssign, autoBalance, probeInterval, now) + result.Assigned += providerResult.Assigned + result.Rebalanced += providerResult.Rebalanced + result.Unplaced += providerResult.Unplaced + if providerErr != nil { + return result, providerErr + } + } + return result, nil +} + +func (s *Service) rebalanceProvider(ctx context.Context, provider accountdomain.Provider, allNodes []domain.Node, autoAssign, autoBalance bool, probeInterval time.Duration, now time.Time) (RebalanceResult, error) { + accounts, err := s.accounts.ListEgressAssignments(ctx, provider) + if err != nil { + return RebalanceResult{}, err + } + nodes := s.eligibleNodesForProvider(allNodes, provider, probeInterval, now) + if len(nodes) == 0 { + return RebalanceResult{Unplaced: countAutoAssignable(accounts, autoAssign, autoBalance)}, nil + } + loads := make(map[uint64]int, len(nodes)) + byID := make(map[uint64]domain.Node, len(nodes)) + for _, node := range nodes { + loads[node.ID] = node.AssignedAccountCount + byID[node.ID] = node + } + original := make(map[uint64]uint64, len(accounts)) + assignment := make(map[uint64]uint64, len(accounts)) + freshMove := make(map[uint64]bool) + result := RebalanceResult{} + + for _, credential := range accounts { + original[credential.ID] = credential.EgressNodeID + assignment[credential.ID] = credential.EgressNodeID + if !isAutoAssignable(credential, autoAssign, autoBalance) { + continue + } + _, currentHealthy := byID[credential.EgressNodeID] + needsPlacement := credential.EgressNodeID == 0 || !currentHealthy + if !needsPlacement { + continue + } + if credential.EgressNodeID != 0 && credential.EgressAssignmentMode != accountdomain.EgressAssignmentAuto { + continue + } + if credential.EgressNodeID == 0 && !autoAssign { + continue + } + target, found := leastLoadedNode(nodes, loads) + if !found { + result.Unplaced++ + continue + } + assignment[credential.ID] = target.ID + loads[target.ID]++ + freshMove[credential.ID] = true + if credential.EgressNodeID == 0 { + result.Assigned++ + } else { + result.Rebalanced++ + } + } + + // Capacity is a placement constraint, not an optional balancing preference. + // Repair automatic assignments that became over capacity even when ordinary + // load balancing is disabled. Manual assignments remain immovable. + moves := 0 + blockedCapacitySources := make(map[uint64]bool) + for moves < maxAutomaticReassignments { + source, destination, found := overCapacityPair(nodes, loads, blockedCapacitySources) + if !found { + break + } + candidateID, movable := findMovableAccount(accounts, assignment, freshMove, source.ID, now) + if !movable { + blockedCapacitySources[source.ID] = true + continue + } + assignment[candidateID] = destination.ID + loads[source.ID]-- + loads[destination.ID]++ + freshMove[candidateID] = true + moves++ + result.Rebalanced++ + } + + if autoBalance { + blocked := make(map[uint64]bool) + for moves < maxAutomaticReassignments { + source, destination, found := rebalancePair(nodes, loads, blocked) + if !found { + break + } + candidateID, movable := findMovableAccount(accounts, assignment, freshMove, source.ID, now) + if !movable { + blocked[source.ID] = true + continue + } + assignment[candidateID] = destination.ID + loads[source.ID]-- + loads[destination.ID]++ + freshMove[candidateID] = true + moves++ + result.Rebalanced++ + } + } + + updates := make(map[uint64][]uint64) + for _, credential := range accounts { + target := assignment[credential.ID] + if target == 0 || target == original[credential.ID] { + continue + } + updates[target] = append(updates[target], credential.ID) + } + for nodeID, ids := range updates { + if _, err := s.accounts.UpdateEgressBindings(ctx, provider, ids, &nodeID, accountdomain.EgressAssignmentAuto, now); err != nil { + return result, err + } + } + return result, nil +} + +func (s *Service) eligibleNodesForProvider(values []domain.Node, provider accountdomain.Provider, probeInterval time.Duration, now time.Time) []domain.Node { + values = append([]domain.Node(nil), values...) + result := make([]domain.Node, 0, len(values)) + maxAge := max(probeInterval*2, time.Minute) + for _, value := range values { + if !value.Enabled || value.EncryptedProxyURL == "" || !scopeSupportsProvider(value.Scope, provider) || value.ProbeStatus != domain.ProbeStatusHealthy || value.LastProbedAt == nil || now.Sub(value.LastProbedAt.UTC()) > maxAge { + continue + } + if value.CooldownUntil != nil && now.Before(value.CooldownUntil.UTC()) && !value.ProxyPool && !s.accountBoundProxy(value) { + continue + } + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func isAutoAssignable(credential accountdomain.Credential, autoAssign, autoBalance bool) bool { + if !credential.Enabled || credential.AuthStatus != accountdomain.AuthStatusActive { + return false + } + if credential.EgressNodeID == 0 { + return autoAssign + } + // Once auto assignment owns a binding, it must continue repairing unhealthy + // or over-capacity placement whenever either automatic mode is running. + // autoBalance only controls optional movement between otherwise valid nodes. + return credential.EgressAssignmentMode == accountdomain.EgressAssignmentAuto && (autoAssign || autoBalance) +} + +func countAutoAssignable(values []accountdomain.Credential, autoAssign, autoBalance bool) int { + count := 0 + for _, value := range values { + if isAutoAssignable(value, autoAssign, autoBalance) { + count++ + } + } + return count +} + +func leastLoadedNode(values []domain.Node, loads map[uint64]int) (domain.Node, bool) { + return leastLoadedNodeExcept(values, loads, 0) +} + +func leastLoadedNodeExcept(values []domain.Node, loads map[uint64]int, excludedID uint64) (domain.Node, bool) { + var selected domain.Node + found := false + for _, value := range values { + if value.ID == excludedID { + continue + } + if value.AccountCapacity > 0 && loads[value.ID] >= value.AccountCapacity { + continue + } + if !found || loads[value.ID] < loads[selected.ID] || (loads[value.ID] == loads[selected.ID] && value.ID < selected.ID) { + selected, found = value, true + } + } + return selected, found +} + +func overCapacityPair(values []domain.Node, loads map[uint64]int, blocked map[uint64]bool) (domain.Node, domain.Node, bool) { + ordered := append([]domain.Node(nil), values...) + sort.Slice(ordered, func(i, j int) bool { + iOverflow := loads[ordered[i].ID] - ordered[i].AccountCapacity + jOverflow := loads[ordered[j].ID] - ordered[j].AccountCapacity + if iOverflow == jOverflow { + return ordered[i].ID < ordered[j].ID + } + return iOverflow > jOverflow + }) + for _, source := range ordered { + if blocked[source.ID] || source.AccountCapacity <= 0 || loads[source.ID] <= source.AccountCapacity { + continue + } + destination, found := leastLoadedNodeExcept(values, loads, source.ID) + if found { + return source, destination, true + } + } + return domain.Node{}, domain.Node{}, false +} + +func rebalancePair(values []domain.Node, loads map[uint64]int, blocked map[uint64]bool) (domain.Node, domain.Node, bool) { + ordered := append([]domain.Node(nil), values...) + sort.Slice(ordered, func(i, j int) bool { + if loads[ordered[i].ID] == loads[ordered[j].ID] { + return ordered[i].ID < ordered[j].ID + } + return loads[ordered[i].ID] < loads[ordered[j].ID] + }) + for _, destination := range ordered { + if destination.AccountCapacity > 0 && loads[destination.ID] >= destination.AccountCapacity { + continue + } + for index := len(ordered) - 1; index >= 0; index-- { + source := ordered[index] + if source.ID == destination.ID || blocked[source.ID] || loads[source.ID] <= loads[destination.ID]+1 { + continue + } + return source, destination, true + } + } + return domain.Node{}, domain.Node{}, false +} + +func findMovableAccount(values []accountdomain.Credential, assignment map[uint64]uint64, freshMove map[uint64]bool, sourceID uint64, now time.Time) (uint64, bool) { + for _, value := range values { + if assignment[value.ID] != sourceID || freshMove[value.ID] || !value.Enabled || value.AuthStatus != accountdomain.AuthStatusActive || value.EgressAssignmentMode != accountdomain.EgressAssignmentAuto { + continue + } + if value.EgressAssignedAt != nil && now.Sub(value.EgressAssignedAt.UTC()) < autoAssignmentMigrationCooldown { + continue + } + return value.ID, true + } + return 0, false +} + +func (s *Service) RunMaintenance(ctx context.Context) error { + operations, err := s.operationsRepository() + if err != nil { + return err + } + config, err := operations.GetEgressOperationsConfig(ctx) + if err != nil { + return err + } + var resultErr error + sources, err := operations.ListDueEgressSources(ctx, time.Now().UTC(), 3) + if err != nil { + resultErr = errors.Join(resultErr, err) + } else { + for _, source := range sources { + if _, syncErr := s.syncSource(ctx, operations, source); syncErr != nil { + resultErr = errors.Join(resultErr, syncErr) + } + } + } + nodes, err := operations.ListDueEgressNodes(ctx, time.Now().UTC(), time.Duration(config.ProbeIntervalSeconds)*time.Second, 32) + if err != nil { + resultErr = errors.Join(resultErr, err) + } else if len(nodes) > 0 { + ids := make([]uint64, 0, len(nodes)) + for _, node := range nodes { + ids = append(ids, node.ID) + } + if _, probeErr := s.TestNodes(ctx, ids); probeErr != nil { + resultErr = errors.Join(resultErr, probeErr) + } + } + if config.AutoAssignEnabled || config.AutoBalanceEnabled { + s.mu.Lock() + due := !s.assignmentRunning && (s.lastAssignmentRun.IsZero() || time.Since(s.lastAssignmentRun) >= time.Duration(config.AssignmentIntervalSeconds)*time.Second) + if due { + s.assignmentRunning = true + } + s.mu.Unlock() + if due { + _, balanceErr := s.RebalanceAccounts(ctx, config.AutoAssignEnabled, config.AutoBalanceEnabled, time.Duration(config.ProbeIntervalSeconds)*time.Second) + s.mu.Lock() + s.assignmentRunning = false + if balanceErr == nil { + s.lastAssignmentRun = time.Now().UTC() + } + s.mu.Unlock() + if balanceErr != nil { + resultErr = errors.Join(resultErr, balanceErr) + } + } + } + return resultErr +} diff --git a/backend/internal/application/egress/operations.go b/backend/internal/application/egress/operations.go new file mode 100644 index 0000000000000000000000000000000000000000..f5ebeb4d7b2fb3535fdd1e953c929eeeee273784 --- /dev/null +++ b/backend/internal/application/egress/operations.go @@ -0,0 +1,527 @@ +package egress + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + defaultProbeIntervalSeconds = 900 + defaultAssignmentIntervalSeconds = 300 + maxEgressAccountCapacity = 100000 + maxManualProbeNodes = 200 + maxConcurrentProbes = 8 +) + +var ErrOperationsUnavailable = errors.New("代理运营功能不可用") + +// OperationsRepository is deliberately optional. Existing egress consumers +// still only need the narrow routing repository while relational persistence +// provides this richer administrative surface. +type OperationsRepository interface { + ListEgressSources(context.Context) ([]domain.SubscriptionSource, error) + ListDueEgressSources(context.Context, time.Time, int) ([]domain.SubscriptionSource, error) + GetEgressSource(context.Context, uint64) (domain.SubscriptionSource, error) + CreateEgressSource(context.Context, domain.SubscriptionSource) (domain.SubscriptionSource, error) + UpdateEgressSource(context.Context, domain.SubscriptionSource) (domain.SubscriptionSource, error) + DeleteEgressSource(context.Context, uint64) error + UpdateEgressSourceSync(context.Context, uint64, time.Time, time.Time, int, string) error + UpsertEgressNodesFromSource(context.Context, uint64, []domain.Node) (int, error) + CreateEgressNodes(context.Context, []domain.Node) (int, error) + UpdateEgressNodeProbe(context.Context, uint64, string, domain.ProbeResult) error + ListDueEgressNodes(context.Context, time.Time, time.Duration, int) ([]domain.Node, error) + GetEgressOperationsConfig(context.Context) (domain.OperationsConfig, error) + SaveEgressOperationsConfig(context.Context, domain.OperationsConfig) (domain.OperationsConfig, error) +} + +// NodeProber is implemented by the infrastructure egress manager. Its fixed +// probe endpoint prevents admin input from controlling the outbound target. +type NodeProber interface { + ProbeEgressNode(context.Context, domain.Node) (domain.ProbeResult, error) +} + +type OperationsConfigInvalidator interface { + InvalidateOperationsConfig() +} + +type SubscriptionSourceInput struct { + Name string + Scope domain.Scope + Enabled bool + URL *string + ClearURL bool + RefreshIntervalSeconds *int + DefaultAccountCapacity *int +} + +type ImportInput struct { + Name string + Scope domain.Scope + AccountCapacity int + Content string +} + +type ImportResult struct { + Imported int + Skipped int +} + +type ProbeBatchResult struct { + Requested int + Healthy int + Unhealthy int +} + +type OperationsConfigInput struct { + ProbeProvider domain.ProbeProvider + ProbeIntervalSeconds int + AutoAssignEnabled bool + AutoBalanceEnabled bool + AssignmentIntervalSeconds int + Fallbacks map[domain.Scope]FallbackConfigInput +} + +type FallbackConfigInput struct { + Mode domain.FallbackMode + NodeID uint64 +} + +func (s *Service) operationsRepository() (OperationsRepository, error) { + if s == nil || s.operations == nil { + return nil, ErrOperationsUnavailable + } + return s.operations, nil +} + +func (s *Service) SetNodeProber(value NodeProber) { + s.mu.Lock() + s.prober = value + s.mu.Unlock() +} + +func (s *Service) SetOperationsConfigInvalidator(value OperationsConfigInvalidator) { + s.mu.Lock() + s.operationsCache = value + s.mu.Unlock() +} + +func (s *Service) invalidateOperationsConfig() { + s.mu.RLock() + value := s.operationsCache + s.mu.RUnlock() + if value != nil { + value.InvalidateOperationsConfig() + } +} + +func (s *Service) nodeProber() NodeProber { + s.mu.RLock() + defer s.mu.RUnlock() + return s.prober +} + +func (s *Service) ListSources(ctx context.Context) ([]domain.PublicSubscriptionSource, error) { + operations, err := s.operationsRepository() + if err != nil { + return nil, err + } + values, err := operations.ListEgressSources(ctx) + if err != nil { + return nil, err + } + result := make([]domain.PublicSubscriptionSource, 0, len(values)) + for _, value := range values { + result = append(result, publicSource(value)) + } + return result, nil +} + +func (s *Service) CreateSource(ctx context.Context, input SubscriptionSourceInput) (domain.PublicSubscriptionSource, error) { + operations, err := s.operationsRepository() + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + value, err := s.applySourceInput(domain.SubscriptionSource{}, input, true) + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + created, err := operations.CreateEgressSource(ctx, value) + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + return publicSource(created), nil +} + +func (s *Service) UpdateSource(ctx context.Context, id uint64, input SubscriptionSourceInput) (domain.PublicSubscriptionSource, error) { + operations, err := s.operationsRepository() + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + value, err := operations.GetEgressSource(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return domain.PublicSubscriptionSource{}, ErrNotFound + } + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + previousScope := value.Scope + value, err = s.applySourceInput(value, input, false) + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + if previousScope != value.Scope { + if err := s.validateSourceBindingScope(ctx, value.ID, value.Scope); err != nil { + return domain.PublicSubscriptionSource{}, err + } + } + updated, err := operations.UpdateEgressSource(ctx, value) + if errors.Is(err, repository.ErrNotFound) { + return domain.PublicSubscriptionSource{}, ErrNotFound + } + if err != nil { + return domain.PublicSubscriptionSource{}, err + } + return publicSource(updated), nil +} + +func (s *Service) DeleteSource(ctx context.Context, id uint64) error { + operations, err := s.operationsRepository() + if err != nil { + return err + } + err = operations.DeleteEgressSource(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return ErrNotFound + } + return err +} + +func (s *Service) SyncSource(ctx context.Context, id uint64) (ImportResult, error) { + operations, err := s.operationsRepository() + if err != nil { + return ImportResult{}, err + } + source, err := operations.GetEgressSource(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return ImportResult{}, ErrNotFound + } + if err != nil { + return ImportResult{}, err + } + return s.syncSource(ctx, operations, source) +} + +func (s *Service) ImportText(ctx context.Context, input ImportInput) (ImportResult, error) { + operations, err := s.operationsRepository() + if err != nil { + return ImportResult{}, err + } + if err := validateImportInput(input); err != nil { + return ImportResult{}, err + } + entries, skipped, err := parseProxySubscription(input.Content) + if err != nil { + return ImportResult{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + nodes := make([]domain.Node, 0, len(entries)) + for index, entry := range entries { + encryptedProxy, encryptErr := s.cipher.Encrypt(entry.ProxyURL) + if encryptErr != nil { + return ImportResult{}, encryptErr + } + nodes = append(nodes, domain.Node{ + Name: sourceNodeName(input.Name, index), Scope: input.Scope, Enabled: true, + AccountCapacity: input.AccountCapacity, EncryptedProxyURL: encryptedProxy, Health: 1, + ProbeStatus: domain.ProbeStatusUnknown, + }) + } + created, err := operations.CreateEgressNodes(ctx, nodes) + if err != nil { + return ImportResult{}, err + } + return ImportResult{Imported: created, Skipped: skipped}, nil +} + +func (s *Service) TestNode(ctx context.Context, id uint64) (domain.ProbeResult, error) { + operations, err := s.operationsRepository() + if err != nil { + return domain.ProbeResult{}, err + } + node, err := s.repository.GetEgressNode(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return domain.ProbeResult{}, ErrNotFound + } else if err != nil { + return domain.ProbeResult{}, err + } + prober := s.nodeProber() + if prober == nil { + return domain.ProbeResult{}, ErrOperationsUnavailable + } + result, probeErr := prober.ProbeEgressNode(ctx, node) + if result.TestedAt.IsZero() { + result.TestedAt = time.Now().UTC() + } + if !result.Status.IsValid() { + result.Status = domain.ProbeStatusUnhealthy + } + if probeErr != nil { + result.Status = domain.ProbeStatusUnhealthy + if strings.TrimSpace(result.Error) == "" { + result.Error = "代理探测失败" + } + } + if updateErr := operations.UpdateEgressNodeProbe(ctx, id, node.EncryptedProxyURL, result); updateErr != nil { + if errors.Is(updateErr, repository.ErrNotFound) { + return result, ErrNotFound + } + if errors.Is(updateErr, repository.ErrConflict) { + return result, ErrProbeStale + } + return result, updateErr + } + // An unreachable proxy is a completed probe with an unhealthy result, not + // an API operation failure. Persistence and repository failures still return + // above so callers can distinguish them from node health. + return result, nil +} + +func (s *Service) TestNodes(ctx context.Context, ids []uint64) (ProbeBatchResult, error) { + if len(ids) == 0 { + nodes, err := s.repository.ListEgressNodes(ctx, "", repository.SortQuery{}) + if err != nil { + return ProbeBatchResult{}, err + } + ids = make([]uint64, 0, len(nodes)) + for _, node := range nodes { + if node.Enabled && node.EncryptedProxyURL != "" { + ids = append(ids, node.ID) + } + } + } + ids = uniqueIDs(ids) + if len(ids) > maxManualProbeNodes { + return ProbeBatchResult{}, fmt.Errorf("%w: 单次最多测试 %d 个代理", ErrInvalidInput, maxManualProbeNodes) + } + result := ProbeBatchResult{Requested: len(ids)} + if len(ids) == 0 { + return result, nil + } + var mu sync.Mutex + jobs := make(chan uint64) + var workers sync.WaitGroup + for range min(maxConcurrentProbes, len(ids)) { + workers.Add(1) + go func() { + defer workers.Done() + for id := range jobs { + probe, err := s.TestNode(ctx, id) + mu.Lock() + if err == nil && probe.Status == domain.ProbeStatusHealthy { + result.Healthy++ + } else { + result.Unhealthy++ + } + mu.Unlock() + } + }() + } + for _, id := range ids { + select { + case jobs <- id: + case <-ctx.Done(): + close(jobs) + workers.Wait() + return result, ctx.Err() + } + } + close(jobs) + workers.Wait() + return result, nil +} + +func (s *Service) OperationsConfig(ctx context.Context) (domain.OperationsConfig, error) { + operations, err := s.operationsRepository() + if err != nil { + return domain.OperationsConfig{}, err + } + return operations.GetEgressOperationsConfig(ctx) +} + +func (s *Service) UpdateOperationsConfig(ctx context.Context, input OperationsConfigInput) (domain.OperationsConfig, error) { + operations, err := s.operationsRepository() + if err != nil { + return domain.OperationsConfig{}, err + } + if input.ProbeIntervalSeconds < 60 || input.ProbeIntervalSeconds > 86400 || input.AssignmentIntervalSeconds < 60 || input.AssignmentIntervalSeconds > 86400 { + return domain.OperationsConfig{}, fmt.Errorf("%w: 自动任务间隔必须在 60 到 86400 秒之间", ErrInvalidInput) + } + current, err := operations.GetEgressOperationsConfig(ctx) + if err != nil { + return domain.OperationsConfig{}, err + } + probeProvider := input.ProbeProvider + if probeProvider == "" { + probeProvider = current.ProbeProvider.Normalized() + } + if !probeProvider.IsValid() { + return domain.OperationsConfig{}, fmt.Errorf("%w: 不支持的代理探测服务", ErrInvalidInput) + } + fallbacks := current.Fallbacks + if input.Fallbacks != nil { + fallbacks, err = s.validateFallbacks(ctx, current, input.Fallbacks) + if err != nil { + return domain.OperationsConfig{}, err + } + } + saved, err := operations.SaveEgressOperationsConfig(ctx, domain.OperationsConfig{ + ProbeProvider: probeProvider, ProbeIntervalSeconds: input.ProbeIntervalSeconds, AutoAssignEnabled: input.AutoAssignEnabled, + AutoBalanceEnabled: input.AutoBalanceEnabled, AssignmentIntervalSeconds: input.AssignmentIntervalSeconds, + Fallbacks: fallbacks, UpdatedAt: time.Now().UTC(), + }) + if errors.Is(err, repository.ErrEgressFallbackInUse) { + return domain.OperationsConfig{}, fmt.Errorf("%w: 固定回退节点必须保持启用且可用", ErrInvalidInput) + } + if err == nil { + s.invalidateOperationsConfig() + } + return saved, err +} + +func (s *Service) validateFallbacks(ctx context.Context, current domain.OperationsConfig, input map[domain.Scope]FallbackConfigInput) (map[domain.Scope]domain.FallbackConfig, error) { + result := make(map[domain.Scope]domain.FallbackConfig, 4) + for _, scope := range []domain.Scope{domain.ScopeBuild, domain.ScopeWeb, domain.ScopeConsole, domain.ScopeWebAsset} { + result[scope] = current.FallbackFor(scope) + } + for scope, fallback := range input { + if !validScope(scope) { + return nil, fmt.Errorf("%w: 回退作用域无效", ErrInvalidInput) + } + mode := fallback.Mode.Normalized() + if !mode.IsValid() { + return nil, fmt.Errorf("%w: 回退模式无效", ErrInvalidInput) + } + switch mode { + case domain.FallbackModeNone, domain.FallbackModeDirect: + if fallback.NodeID != 0 { + return nil, fmt.Errorf("%w: 仅固定代理回退可以指定节点", ErrInvalidInput) + } + case domain.FallbackModeFixed: + if fallback.NodeID == 0 { + return nil, fmt.Errorf("%w: 固定代理回退必须指定节点", ErrInvalidInput) + } + node, err := s.repository.GetEgressNode(ctx, fallback.NodeID) + if errors.Is(err, repository.ErrNotFound) { + return nil, fmt.Errorf("%w: 固定回退节点不存在", ErrInvalidInput) + } + if err != nil { + return nil, err + } + if err := s.validateFixedFallbackNode(scope, node, true); err != nil { + return nil, err + } + } + result[scope] = domain.FallbackConfig{Mode: mode, NodeID: fallback.NodeID} + } + return result, nil +} + +func (s *Service) validateFixedFallbackNode(scope domain.Scope, node domain.Node, rejectCooldown bool) error { + if !domain.SupportsScope(node.Scope, scope) { + return fmt.Errorf("%w: 固定回退节点与 %s 作用域不兼容", ErrInvalidInput, scope) + } + if !node.Enabled || strings.TrimSpace(node.EncryptedProxyURL) == "" { + return fmt.Errorf("%w: 固定回退节点必须启用且配置代理地址", ErrInvalidInput) + } + if node.ProxyPool { + return fmt.Errorf("%w: 固定回退节点不能使用代理池模式", ErrInvalidInput) + } + if rejectCooldown && node.CooldownUntil != nil && time.Now().UTC().Before(*node.CooldownUntil) { + return fmt.Errorf("%w: 固定回退节点正在冷却", ErrInvalidInput) + } + proxyURL, err := s.cipher.Decrypt(node.EncryptedProxyURL) + if err != nil { + return fmt.Errorf("%w: 固定回退节点代理配置无效", ErrInvalidInput) + } + proxyURL, err = NormalizeProxyURL(proxyURL) + if err != nil || proxyURL == "" { + return fmt.Errorf("%w: 固定回退节点代理地址无效", ErrInvalidInput) + } + if strings.Contains(proxyURL, ProxyAccountPlaceholder) { + return fmt.Errorf("%w: 固定回退节点不能使用账号代理模板", ErrInvalidInput) + } + return nil +} + +func (s *Service) applySourceInput(value domain.SubscriptionSource, input SubscriptionSourceInput, create bool) (domain.SubscriptionSource, error) { + name := strings.TrimSpace(input.Name) + if name == "" || len(name) > 160 { + return domain.SubscriptionSource{}, fmt.Errorf("%w: 订阅名称必须在 1 到 160 个字符之间", ErrInvalidInput) + } + if !validScope(input.Scope) { + return domain.SubscriptionSource{}, fmt.Errorf("%w: 订阅作用域无效", ErrInvalidInput) + } + value.Name, value.Scope, value.Enabled = name, input.Scope, input.Enabled + if input.RefreshIntervalSeconds != nil { + if *input.RefreshIntervalSeconds < 60 || *input.RefreshIntervalSeconds > 86400 { + return domain.SubscriptionSource{}, fmt.Errorf("%w: 订阅刷新间隔必须在 60 到 86400 秒之间", ErrInvalidInput) + } + value.RefreshIntervalSeconds = *input.RefreshIntervalSeconds + } + if value.RefreshIntervalSeconds == 0 { + value.RefreshIntervalSeconds = defaultProbeIntervalSeconds + } + if input.DefaultAccountCapacity != nil { + if *input.DefaultAccountCapacity < 0 || *input.DefaultAccountCapacity > maxEgressAccountCapacity { + return domain.SubscriptionSource{}, fmt.Errorf("%w: 每个代理的账号容量必须在 0 到 %d 之间", ErrInvalidInput, maxEgressAccountCapacity) + } + value.DefaultAccountCapacity = *input.DefaultAccountCapacity + } + if input.ClearURL { + value.EncryptedURL = "" + } else if input.URL != nil { + urlValue, err := normalizeSubscriptionURL(*input.URL) + if err != nil { + return domain.SubscriptionSource{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + encryptedURL, err := s.cipher.Encrypt(urlValue) + if err != nil { + return domain.SubscriptionSource{}, err + } + value.EncryptedURL = encryptedURL + } + if create && value.EncryptedURL == "" { + return domain.SubscriptionSource{}, fmt.Errorf("%w: 必须提供订阅地址", ErrInvalidInput) + } + if create || input.URL != nil || input.ClearURL { + value.NextSyncAt = nil + value.LastSyncError = "" + } + return value, nil +} + +func publicSource(value domain.SubscriptionSource) domain.PublicSubscriptionSource { + return domain.PublicSubscriptionSource{ + ID: value.ID, Name: value.Name, Scope: value.Scope, Enabled: value.Enabled, URLConfigured: value.EncryptedURL != "", + RefreshIntervalSeconds: value.RefreshIntervalSeconds, DefaultAccountCapacity: value.DefaultAccountCapacity, + LastSyncedAt: value.LastSyncedAt, NextSyncAt: value.NextSyncAt, LastSyncImported: value.LastSyncImported, LastSyncError: value.LastSyncError, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func validScope(scope domain.Scope) bool { + return scope == domain.ScopeBuild || scope == domain.ScopeWeb || scope == domain.ScopeConsole || scope == domain.ScopeWebAsset +} + +func validateImportInput(input ImportInput) error { + if strings.TrimSpace(input.Name) == "" || len(strings.TrimSpace(input.Name)) > 150 || !validScope(input.Scope) || input.AccountCapacity < 0 || input.AccountCapacity > maxEgressAccountCapacity || strings.TrimSpace(input.Content) == "" { + return fmt.Errorf("%w: 批量导入参数无效", ErrInvalidInput) + } + return nil +} diff --git a/backend/internal/application/egress/service.go b/backend/internal/application/egress/service.go new file mode 100644 index 0000000000000000000000000000000000000000..8badd4937528ab27053d076c54a01b0387eb359b --- /dev/null +++ b/backend/internal/application/egress/service.go @@ -0,0 +1,802 @@ +package egress + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrInvalidInput = errors.New("代理节点参数无效") + ErrInvalidFilter = errors.New("出口代理筛选条件无效") + ErrInvalidSort = errors.New("代理节点排序条件无效") + ErrNotFound = errors.New("代理节点不存在") + ErrProbeStale = errors.New("代理配置在探测期间已更新,请重新测试") + ErrClearanceUnavailable = errors.New("Clearance 刷新不可用") +) + +const ( + maxProxyURLBytes = 8192 + maxCloudflareCookieBytes = 16 << 10 + ProxyAccountPlaceholder = "{account}" + proxyAccountSentinel = "grok2api_account_placeholder" +) + +type Input struct { + Name string + Scope domain.Scope + Enabled bool + ProxyPool *bool + AccountCapacity *int + ProxyURL *string + ClearProxyURL bool + UserAgent string + CloudflareCookies *string + ClearCookies bool +} + +type ListFilter struct { + Scope domain.Scope + Enabled string + ProbeStatus string + Assignment string + Sort repository.SortQuery +} + +type ServiceRepository interface { + repository.EgressRepository + repository.EgressNodePageRepository +} + +type Service struct { + repository ServiceRepository + accounts AccountBindingRepository + operations OperationsRepository + cipher *security.Cipher + mu sync.RWMutex + browserUA string + clearance ClearanceManager + prober NodeProber + operationsCache OperationsConfigInvalidator + assignmentMu sync.Mutex + lastAssignmentRun time.Time + assignmentRunning bool +} + +// AccountBindingRepository is intentionally narrow so existing account +// repository consumers do not gain egress concerns. +type AccountBindingRepository interface { + CountProviderAccountsByIDs(context.Context, accountdomain.Provider, []uint64) (int64, error) + UpdateEgressBindings(context.Context, accountdomain.Provider, []uint64, *uint64, accountdomain.EgressAssignmentMode, time.Time) (int64, error) + ListEgressAssignments(context.Context, accountdomain.Provider) ([]accountdomain.Credential, error) + ListEgressBindingProviders(context.Context, uint64) ([]accountdomain.Provider, error) + ListEgressSourceBindingProviders(context.Context, uint64) ([]accountdomain.Provider, error) +} + +type AssignmentResult struct { + Assigned int +} + +type UnhealthyCleanupPreview struct { + Nodes int64 + BoundAccounts int64 + SubscriptionManaged int64 +} + +// BatchNodeDeleter is optional so lightweight repository adapters only need +// the single-node contract unless they can provide an atomic bulk operation. +type BatchNodeDeleter interface { + DeleteEgressNodes(context.Context, []uint64) (int, error) +} + +type BatchNodeEnabledUpdater interface { + UpdateEgressNodesEnabled(context.Context, []uint64, bool) (int, error) +} + +type ClearanceManager interface { + RefreshClearance(context.Context, uint64) error + ForgetClearance(uint64) +} + +type BatchClearanceManager interface { + ForgetClearances([]uint64) +} + +func NewService(repository ServiceRepository, cipher *security.Cipher, browserUA string, accounts ...AccountBindingRepository) *Service { + service := &Service{repository: repository, cipher: cipher, browserUA: strings.TrimSpace(browserUA)} + if operations, ok := repository.(OperationsRepository); ok { + service.operations = operations + } + if len(accounts) > 0 { + service.accounts = accounts[0] + } + return service +} + +func (s *Service) UpdateDefaults(browserUA string) { + s.mu.Lock() + defer s.mu.Unlock() + s.browserUA = strings.TrimSpace(browserUA) +} + +func (s *Service) SetClearanceManager(value ClearanceManager) { + s.mu.Lock() + s.clearance = value + s.mu.Unlock() +} + +func (s *Service) DefaultUserAgents() map[string]string { + s.mu.RLock() + defer s.mu.RUnlock() + return map[string]string{ + string(domain.ScopeBuild): "", string(domain.ScopeWeb): s.browserUA, string(domain.ScopeConsole): s.browserUA, + string(domain.ScopeWebAsset): s.browserUA, + } +} + +func (s *Service) List(ctx context.Context, page, pageSize int, search string, filter ListFilter) ([]domain.PublicNode, int64, error) { + page, pageSize = repository.NormalizePage(page, pageSize, repository.DefaultPageSize) + if !validListScope(filter.Scope) || !validListValue(filter.Enabled, "enabled", "disabled") || + !validListValue(filter.ProbeStatus, string(domain.ProbeStatusHealthy), string(domain.ProbeStatusUnhealthy), string(domain.ProbeStatusUnknown)) || + !validListValue(filter.Assignment, "bound", "unbound") { + return nil, 0, ErrInvalidFilter + } + if !repository.IsValidSort(filter.Sort, "name", "scope", "proxy", "clearance", "health") { + return nil, 0, ErrInvalidSort + } + var enabled *bool + if filter.Enabled != "" { + value := filter.Enabled == "enabled" + enabled = &value + } + values, total, err := s.repository.ListEgressNodePage(ctx, repository.EgressNodeListQuery{ + Page: repository.PageQuery{Offset: (page - 1) * pageSize, Limit: pageSize, Search: strings.TrimSpace(search), Sort: filter.Sort}, + Filter: repository.EgressNodeListFilter{ + Scope: filter.Scope, Enabled: enabled, ProbeStatus: domain.ProbeStatus(filter.ProbeStatus), Assignment: filter.Assignment, + }, + }) + if err != nil { + return nil, 0, err + } + return s.publicNodes(values), total, nil +} + +func (s *Service) ListAll(ctx context.Context, scope domain.Scope, sort repository.SortQuery) ([]domain.PublicNode, error) { + if !validListScope(scope) { + return nil, ErrInvalidFilter + } + if !repository.IsValidSort(sort, "name", "scope", "proxy", "clearance", "health") { + return nil, ErrInvalidSort + } + values, err := s.repository.ListEgressNodes(ctx, scope, sort) + if err != nil { + return nil, err + } + return s.publicNodes(values), nil +} + +func (s *Service) publicNodes(values []domain.Node) []domain.PublicNode { + result := make([]domain.PublicNode, 0, len(values)) + for _, value := range values { + result = append(result, s.publicNode(value)) + } + return result +} + +func validListScope(scope domain.Scope) bool { + return scope == "" || scope == domain.ScopeBuild || scope == domain.ScopeWeb || scope == domain.ScopeConsole || scope == domain.ScopeWebAsset +} + +func validListValue(value string, allowed ...string) bool { + if value == "" { + return true + } + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func (s *Service) Create(ctx context.Context, input Input) (domain.PublicNode, error) { + value, err := s.applyInput(domain.Node{}, input, true) + if err != nil { + return domain.PublicNode{}, err + } + created, err := s.repository.CreateEgressNode(ctx, value) + if err == nil { + s.forgetClearance(created.ID) + } + return s.publicNode(created), err +} + +func (s *Service) Update(ctx context.Context, id uint64, input Input) (domain.PublicNode, error) { + value, err := s.repository.GetEgressNode(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return domain.PublicNode{}, ErrNotFound + } + if err != nil { + return domain.PublicNode{}, err + } + previousScope := value.Scope + value, err = s.applyInput(value, input, false) + if err != nil { + return domain.PublicNode{}, err + } + if err := s.validateFallbackNodeUpdate(ctx, value); err != nil { + return domain.PublicNode{}, err + } + if previousScope != value.Scope { + if err := s.validateNodeBindingScope(ctx, value.ID, value.Scope); err != nil { + return domain.PublicNode{}, err + } + } + updated, err := s.repository.UpdateEgressNode(ctx, value) + if err == nil { + s.forgetClearance(updated.ID) + } + return s.publicNode(updated), err +} + +func (s *Service) validateFallbackNodeUpdate(ctx context.Context, node domain.Node) error { + if s.operations == nil { + return nil + } + config, err := s.operations.GetEgressOperationsConfig(ctx) + if err != nil { + return err + } + return s.validateFallbackNodeUpdateWithConfig(node, config) +} + +func (s *Service) validateFallbackNodeUpdateWithConfig(node domain.Node, config domain.OperationsConfig) error { + for _, scope := range []domain.Scope{domain.ScopeBuild, domain.ScopeWeb, domain.ScopeConsole, domain.ScopeWebAsset} { + fallback := config.FallbackFor(scope) + if fallback.Mode != domain.FallbackModeFixed || fallback.NodeID != node.ID { + continue + } + if err := s.validateFixedFallbackNode(scope, node, false); err != nil { + return fmt.Errorf("节点已配置为 %s 固定回退,无法应用当前修改: %w", scope, err) + } + } + return nil +} + +// UpdateManyEnabled changes only the scheduling state, leaving proxy secrets, +// health, probes, and account bindings untouched. +func (s *Service) UpdateManyEnabled(ctx context.Context, nodeIDs []uint64, enabled bool) (int, error) { + ids := uniqueIDs(nodeIDs) + if len(ids) == 0 { + return 0, fmt.Errorf("%w: 代理节点参数无效", ErrInvalidInput) + } + + // Disabling a fixed fallback would make the persisted routing policy + // invalid. At most four fallback nodes need point lookups, regardless of + // the batch size. + if !enabled && s.operations != nil { + config, err := s.operations.GetEgressOperationsConfig(ctx) + if err != nil { + return 0, err + } + selected := make(map[uint64]struct{}, len(ids)) + for _, id := range ids { + selected[id] = struct{}{} + } + fallbackNodeIDs := make(map[uint64]struct{}, 4) + for _, scope := range []domain.Scope{domain.ScopeBuild, domain.ScopeWeb, domain.ScopeConsole, domain.ScopeWebAsset} { + fallback := config.FallbackFor(scope) + if fallback.Mode == domain.FallbackModeFixed { + if _, exists := selected[fallback.NodeID]; exists { + fallbackNodeIDs[fallback.NodeID] = struct{}{} + } + } + } + for id := range fallbackNodeIDs { + node, err := s.repository.GetEgressNode(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + continue + } + if err != nil { + return 0, err + } + node.Enabled = false + if err := s.validateFallbackNodeUpdateWithConfig(node, config); err != nil { + return 0, err + } + } + } + + if batch, ok := s.repository.(BatchNodeEnabledUpdater); ok { + updated, err := batch.UpdateEgressNodesEnabled(ctx, ids, enabled) + if errors.Is(err, repository.ErrEgressFallbackInUse) { + return 0, fmt.Errorf("%w: 固定回退节点不能被批量禁用", ErrInvalidInput) + } + if err != nil { + return 0, err + } + if updated > 0 { + s.forgetClearances(ids) + } + return updated, nil + } + + updated := 0 + for _, id := range ids { + node, err := s.repository.GetEgressNode(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + continue + } + if err != nil { + return updated, err + } + if node.Enabled == enabled { + continue + } + node.Enabled = enabled + if _, err := s.repository.UpdateEgressNode(ctx, node); err != nil { + return updated, err + } + s.forgetClearance(id) + updated++ + } + return updated, nil +} + +func (s *Service) Delete(ctx context.Context, id uint64) error { + err := s.repository.DeleteEgressNode(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return ErrNotFound + } + if err == nil { + s.forgetClearance(id) + s.invalidateOperationsConfig() + } + return err +} + +// DeleteMany removes nodes in one repository operation when available. The +// relational implementation also clears any account bindings in that same +// transaction, so a deleted node can never remain referenced by an account. +func (s *Service) DeleteMany(ctx context.Context, nodeIDs []uint64) (int, error) { + ids := uniqueIDs(nodeIDs) + if len(ids) == 0 { + return 0, fmt.Errorf("%w: 代理节点参数无效", ErrInvalidInput) + } + if batch, ok := s.repository.(BatchNodeDeleter); ok { + deleted, err := batch.DeleteEgressNodes(ctx, ids) + if err != nil { + return 0, err + } + for _, id := range ids { + s.forgetClearance(id) + } + s.invalidateOperationsConfig() + return deleted, nil + } + + deleted := 0 + for _, id := range ids { + if err := s.Delete(ctx, id); err != nil { + if errors.Is(err, ErrNotFound) { + continue + } + return deleted, err + } + deleted++ + } + return deleted, nil +} + +func (s *Service) PreviewUnhealthyCleanup(ctx context.Context) (UnhealthyCleanupPreview, error) { + if cleaner, ok := s.repository.(repository.EgressNodeUnhealthyCleaner); ok { + value, err := cleaner.PreviewUnhealthyEgressNodes(ctx) + return UnhealthyCleanupPreview{ + Nodes: value.Nodes, BoundAccounts: value.BoundAccounts, SubscriptionManaged: value.SubscriptionManaged, + }, err + } + values, err := s.repository.ListEgressNodes(ctx, "", repository.SortQuery{}) + if err != nil { + return UnhealthyCleanupPreview{}, err + } + result := UnhealthyCleanupPreview{} + for _, value := range values { + if value.IPv4Probe.Status != domain.ProbeStatusUnhealthy || value.IPv6Probe.Status != domain.ProbeStatusUnhealthy { + continue + } + result.Nodes++ + result.BoundAccounts += int64(value.AssignedAccountCount) + if value.SourceID != 0 { + result.SubscriptionManaged++ + } + } + return result, nil +} + +func (s *Service) DeleteUnhealthy(ctx context.Context) (int, error) { + if cleaner, ok := s.repository.(repository.EgressNodeUnhealthyCleaner); ok { + ids, err := cleaner.DeleteUnhealthyEgressNodes(ctx) + if err != nil { + return 0, err + } + for _, id := range ids { + s.forgetClearance(id) + } + if len(ids) > 0 { + s.invalidateOperationsConfig() + } + return len(ids), nil + } + values, err := s.repository.ListEgressNodes(ctx, "", repository.SortQuery{}) + if err != nil { + return 0, err + } + ids := make([]uint64, 0) + for _, value := range values { + if value.IPv4Probe.Status == domain.ProbeStatusUnhealthy && value.IPv6Probe.Status == domain.ProbeStatusUnhealthy { + ids = append(ids, value.ID) + } + } + if len(ids) == 0 { + return 0, nil + } + return s.DeleteMany(ctx, ids) +} + +func (s *Service) RefreshClearance(ctx context.Context, id uint64) error { + if _, err := s.repository.GetEgressNode(ctx, id); errors.Is(err, repository.ErrNotFound) { + return ErrNotFound + } else if err != nil { + return err + } + s.mu.RLock() + manager := s.clearance + s.mu.RUnlock() + if manager == nil { + return ErrClearanceUnavailable + } + return manager.RefreshClearance(ctx, id) +} + +// AssignAccounts creates explicit many-to-one account bindings. A binding is +// not a proxy-pool preference: runtime requests must use the selected node. +func (s *Service) AssignAccounts(ctx context.Context, nodeID uint64, provider accountdomain.Provider, accountIDs []uint64, mode accountdomain.EgressAssignmentMode) (AssignmentResult, error) { + if s.accounts == nil { + return AssignmentResult{}, errors.New("账号出口绑定不可用") + } + if nodeID == 0 || !provider.IsValid() || !mode.IsValid() || len(accountIDs) == 0 { + return AssignmentResult{}, fmt.Errorf("%w: 账号出口绑定参数无效", ErrInvalidInput) + } + node, err := s.repository.GetEgressNode(ctx, nodeID) + if errors.Is(err, repository.ErrNotFound) { + return AssignmentResult{}, ErrNotFound + } + if err != nil { + return AssignmentResult{}, err + } + if !node.Enabled || strings.TrimSpace(node.EncryptedProxyURL) == "" { + return AssignmentResult{}, fmt.Errorf("%w: 只能绑定启用且已配置代理地址的节点", ErrInvalidInput) + } + if !scopeSupportsProvider(node.Scope, provider) { + return AssignmentResult{}, fmt.Errorf("%w: 代理节点作用域与账号来源不兼容", ErrInvalidInput) + } + unique := uniqueIDs(accountIDs) + count, err := s.accounts.CountProviderAccountsByIDs(ctx, provider, unique) + if err != nil { + return AssignmentResult{}, err + } + if count != int64(len(unique)) { + return AssignmentResult{}, fmt.Errorf("%w: 包含不属于当前账号池的账号", ErrInvalidInput) + } + assigned, err := s.accounts.UpdateEgressBindings(ctx, provider, unique, &nodeID, mode, time.Now().UTC()) + if err != nil { + return AssignmentResult{}, err + } + return AssignmentResult{Assigned: int(assigned)}, nil +} + +// UnassignAccounts removes an explicit binding and restores scope pool routing. +func (s *Service) UnassignAccounts(ctx context.Context, provider accountdomain.Provider, accountIDs []uint64) (AssignmentResult, error) { + if s.accounts == nil { + return AssignmentResult{}, errors.New("账号出口绑定不可用") + } + if !provider.IsValid() || len(accountIDs) == 0 { + return AssignmentResult{}, fmt.Errorf("%w: 账号出口解绑参数无效", ErrInvalidInput) + } + unique := uniqueIDs(accountIDs) + count, err := s.accounts.CountProviderAccountsByIDs(ctx, provider, unique) + if err != nil { + return AssignmentResult{}, err + } + if count != int64(len(unique)) { + return AssignmentResult{}, fmt.Errorf("%w: 包含不属于当前账号池的账号", ErrInvalidInput) + } + updated, err := s.accounts.UpdateEgressBindings(ctx, provider, unique, nil, "", time.Time{}) + if err != nil { + return AssignmentResult{}, err + } + return AssignmentResult{Assigned: int(updated)}, nil +} + +func scopeSupportsProvider(scope domain.Scope, provider accountdomain.Provider) bool { + switch provider { + case accountdomain.ProviderBuild: + return scope == domain.ScopeBuild + case accountdomain.ProviderWeb: + return scope == domain.ScopeWeb + case accountdomain.ProviderConsole: + return domain.SupportsScope(scope, domain.ScopeConsole) + default: + return false + } +} + +func (s *Service) validateNodeBindingScope(ctx context.Context, nodeID uint64, scope domain.Scope) error { + if s.accounts == nil { + return nil + } + providers, err := s.accounts.ListEgressBindingProviders(ctx, nodeID) + if err != nil { + return err + } + return validateBindingProviders(scope, providers) +} + +func (s *Service) validateSourceBindingScope(ctx context.Context, sourceID uint64, scope domain.Scope) error { + if s.accounts == nil { + return nil + } + providers, err := s.accounts.ListEgressSourceBindingProviders(ctx, sourceID) + if err != nil { + return err + } + return validateBindingProviders(scope, providers) +} + +func validateBindingProviders(scope domain.Scope, providers []accountdomain.Provider) error { + for _, provider := range providers { + if !scopeSupportsProvider(scope, provider) { + return fmt.Errorf("%w: 当前节点仍绑定 %s 账号,不能改为 %s 作用域", ErrInvalidInput, provider, scope) + } + } + return nil +} + +func uniqueIDs(values []uint64) []uint64 { + seen := make(map[uint64]struct{}, len(values)) + result := make([]uint64, 0, len(values)) + for _, value := range values { + if value == 0 { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func (s *Service) forgetClearance(id uint64) { + s.mu.RLock() + manager := s.clearance + s.mu.RUnlock() + if manager != nil { + manager.ForgetClearance(id) + } +} + +func (s *Service) forgetClearances(ids []uint64) { + s.mu.RLock() + manager := s.clearance + s.mu.RUnlock() + if manager == nil { + return + } + if batch, ok := manager.(BatchClearanceManager); ok { + batch.ForgetClearances(ids) + return + } + for _, id := range ids { + manager.ForgetClearance(id) + } +} + +func (s *Service) applyInput(value domain.Node, input Input, create bool) (domain.Node, error) { + proxyPool := value.ProxyPool + if input.ProxyPool != nil { + proxyPool = *input.ProxyPool + } + configurationChanged := create || value.Scope != input.Scope || value.ProxyPool != proxyPool || (!value.Enabled && input.Enabled) || input.ClearProxyURL || input.ProxyURL != nil + name := strings.TrimSpace(input.Name) + if name == "" || len(name) > 160 { + return domain.Node{}, fmt.Errorf("%w: 名称必须在 1 到 160 个字符之间", ErrInvalidInput) + } + if input.Scope != domain.ScopeBuild && input.Scope != domain.ScopeWeb && input.Scope != domain.ScopeConsole && input.Scope != domain.ScopeWebAsset { + return domain.Node{}, fmt.Errorf("%w: scope 必须是 grok_build、grok_web、grok_console 或 grok_web_asset", ErrInvalidInput) + } + value.Name, value.Scope, value.Enabled, value.ProxyPool = name, input.Scope, input.Enabled, proxyPool + if input.AccountCapacity != nil { + if *input.AccountCapacity < 0 || *input.AccountCapacity > 100000 { + return domain.Node{}, fmt.Errorf("%w: 每个代理的账号容量必须在 0 到 100000 之间", ErrInvalidInput) + } + value.AccountCapacity = *input.AccountCapacity + } + if input.Scope == domain.ScopeBuild { + // Build 请求始终沿用 Provider 生成的 CLI User-Agent,出口节点不得覆盖协议身份。 + value.UserAgent = "" + } else { + value.UserAgent = strings.TrimSpace(input.UserAgent) + } + if input.Scope != domain.ScopeBuild && value.UserAgent == "" { + s.mu.RLock() + value.UserAgent = s.browserUA + s.mu.RUnlock() + } + if len(value.UserAgent) > 512 { + return domain.Node{}, fmt.Errorf("%w: User-Agent 过长", ErrInvalidInput) + } + if input.ClearProxyURL { + value.EncryptedProxyURL = "" + value.ProxyPool = false + } else if input.ProxyURL != nil { + normalized, err := NormalizeProxyURL(*input.ProxyURL) + if err != nil { + return domain.Node{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + if normalized != "" { + value.EncryptedProxyURL, err = s.cipher.Encrypt(normalized) + if err != nil { + return domain.Node{}, err + } + } + } + if value.ProxyPool && strings.TrimSpace(value.EncryptedProxyURL) == "" { + return domain.Node{}, fmt.Errorf("%w: 代理池模式需要配置代理地址", ErrInvalidInput) + } + if input.Scope == domain.ScopeBuild { + value.EncryptedCloudflareCookie = "" + } else if input.ClearCookies { + value.EncryptedCloudflareCookie = "" + } else if input.CloudflareCookies != nil { + if len(*input.CloudflareCookies) > maxCloudflareCookieBytes { + return domain.Node{}, fmt.Errorf("%w: Cloudflare Cookie 不能超过 16 KiB", ErrInvalidInput) + } + cookies := SanitizeCloudflareCookies(*input.CloudflareCookies) + if cookies != "" || create { + var err error + value.EncryptedCloudflareCookie, err = s.cipher.Encrypt(cookies) + if err != nil { + return domain.Node{}, err + } + } + } + if configurationChanged { + value.Health = 1 + value.FailureCount = 0 + value.CooldownUntil = nil + value.LastError = "" + value.ProbeStatus = domain.ProbeStatusUnknown + value.LastProbedAt = nil + value.ProbeLatencyMS = 0 + value.ExitIP = "" + value.ProbeError = "" + value.ProbeProvider = "" + value.IPv4Probe = domain.ProbeFamilyResult{Status: domain.ProbeStatusUnknown} + value.IPv6Probe = domain.ProbeFamilyResult{Status: domain.ProbeStatusUnknown} + } + // Any administrator edit invalidates freshness. Keep the binding fingerprint: + // managed mode may use the existing cookie as last-known-good only when the + // target and actual proxy still match the binding that produced it. + value.ClearanceRefreshedAt = nil + value.ClearanceFingerprint = "" + return value, nil +} + +func (s *Service) publicNode(value domain.Node) domain.PublicNode { + userAgent := value.UserAgent + if value.Scope == domain.ScopeBuild { + userAgent = "" + } + accountBoundProxy := s.accountBoundProxy(value) + proxyPool := value.ProxyPool || accountBoundProxy + health, failureCount, cooldownUntil, lastError := value.Health, value.FailureCount, value.CooldownUntil, value.LastError + if proxyPool { + health, failureCount, cooldownUntil, lastError = 1, 0, nil, "" + } + return domain.PublicNode{ + ID: value.ID, Name: value.Name, Scope: value.Scope, Enabled: value.Enabled, + ProxyConfigured: value.EncryptedProxyURL != "", UserAgent: userAgent, CookieConfigured: value.EncryptedCloudflareCookie != "", + ProxyPool: proxyPool, + SourceID: value.SourceID, + AccountCapacity: value.AccountCapacity, + AccountBoundProxy: accountBoundProxy, + Health: health, FailureCount: failureCount, CooldownUntil: cooldownUntil, LastError: lastError, + ProbeStatus: value.ProbeStatus, LastProbedAt: value.LastProbedAt, ProbeLatencyMS: value.ProbeLatencyMS, ExitIP: value.ExitIP, ProbeError: value.ProbeError, + ProbeProvider: value.ProbeProvider, + IPv4Probe: value.IPv4Probe, IPv6Probe: value.IPv6Probe, + AssignedAccountCount: value.AssignedAccountCount, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func (s *Service) accountBoundProxy(value domain.Node) bool { + if s == nil || s.cipher == nil || strings.TrimSpace(value.EncryptedProxyURL) == "" { + return false + } + proxyURL, err := s.cipher.Decrypt(value.EncryptedProxyURL) + return err == nil && strings.Contains(proxyURL, ProxyAccountPlaceholder) +} + +func NormalizeProxyURL(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + if len(value) > maxProxyURLBytes || strings.IndexFunc(value, func(character rune) bool { return character < 0x20 || character == 0x7f }) >= 0 { + return "", errors.New("代理地址过长或包含控制字符") + } + hasAccountPlaceholder := strings.Contains(value, ProxyAccountPlaceholder) + if strings.Count(value, ProxyAccountPlaceholder) > 1 { + return "", errors.New("代理地址最多包含一个 {account} 占位符") + } + if hasAccountPlaceholder && strings.Contains(value, proxyAccountSentinel) { + return "", errors.New("代理地址包含保留的账号占位符文本") + } + parseValue := strings.ReplaceAll(value, ProxyAccountPlaceholder, proxyAccountSentinel) + parsed, err := url.Parse(parseValue) + if err != nil || parsed.Host == "" || parsed.Hostname() == "" { + return "", errors.New("代理地址格式无效") + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https", "socks4", "socks4a", "socks5", "socks5h": + default: + return "", errors.New("代理地址协议必须是 HTTP、HTTPS、SOCKS4 或 SOCKS5") + } + if parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { + return "", errors.New("代理地址不能包含路径、查询参数或片段") + } + if hasAccountPlaceholder { + if parsed.User == nil || !strings.Contains(parsed.User.Username(), proxyAccountSentinel) { + return "", errors.New("{account} 只能用于代理认证用户名") + } + return strings.ReplaceAll(parsed.String(), proxyAccountSentinel, ProxyAccountPlaceholder), nil + } + return parsed.String(), nil +} + +func SanitizeCloudflareCookies(value string) string { + allowed := make([]string, 0, 4) + seen := make(map[string]struct{}) + for part := range strings.SplitSeq(value, ";") { + name, cookieValue, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok { + continue + } + name = strings.TrimSpace(name) + lower := strings.ToLower(name) + if lower != "cf_clearance" && lower != "__cf_bm" && lower != "_cfuvid" && !strings.HasPrefix(lower, "cf_chl_") { + continue + } + if _, exists := seen[lower]; exists { + continue + } + cookieValue = strings.TrimSpace(cookieValue) + if cookieValue == "" || len(cookieValue) > maxCloudflareCookieBytes || strings.IndexFunc(cookieValue, func(character rune) bool { return character < 0x20 || character == 0x7f }) >= 0 { + continue + } + seen[lower] = struct{}{} + allowed = append(allowed, lower+"="+cookieValue) + } + return strings.Join(allowed, "; ") +} diff --git a/backend/internal/application/egress/service_test.go b/backend/internal/application/egress/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e454d850b005014d90f0b219cf85e58bc5fa075c --- /dev/null +++ b/backend/internal/application/egress/service_test.go @@ -0,0 +1,193 @@ +package egress + +import ( + "errors" + "strings" + "testing" + "time" + + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestSanitizeCloudflareCookiesDropsControlsAndNonCloudflareValues(t *testing.T) { + value := SanitizeCloudflareCookies("CF_CLEARANCE=valid; __cf_bm=bad\r\nX-Leak: yes; sso=secret; cf_chl_test=ok") + if value != "cf_clearance=valid; cf_chl_test=ok" { + t.Fatalf("sanitized cookies = %q", value) + } + if strings.Contains(strings.ToLower(value), "sso") || strings.Contains(value, "\r") || strings.Contains(value, "\n") { + t.Fatalf("unsafe cookie value = %q", value) + } +} + +func TestNormalizeProxyURLValidatesStructure(t *testing.T) { + for _, raw := range []string{ + "http://user:password@127.0.0.1:8080", "https://proxy.example:8443", + "socks4://127.0.0.1:1080", "socks4a://proxy.example:1080", + "socks5://user:password@127.0.0.1:1080", "socks5h://user:password@proxy.example:1080", + } { + value, err := NormalizeProxyURL(raw) + if err != nil || value == "" { + t.Fatalf("valid proxy %q = %q, err = %v", raw, value, err) + } + } + for _, invalid := range []string{"file:///tmp/proxy", "https://", "http://proxy.example/path", "http://proxy.example\r\nX-Leak: yes"} { + if _, err := NormalizeProxyURL(invalid); err == nil { + t.Fatalf("invalid proxy accepted: %q", invalid) + } + } +} + +func TestNormalizeProxyURLAllowsAccountPlaceholderOnlyInUsername(t *testing.T) { + value, err := NormalizeProxyURL("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + if value != "socks5h://Default.%7Baccount%7D:token@resin:2260" && value != "socks5h://Default.{account}:token@resin:2260" { + t.Fatalf("normalized Resin proxy = %q", value) + } + if !strings.Contains(value, ProxyAccountPlaceholder) { + t.Fatalf("account placeholder was lost: %q", value) + } + for _, invalid := range []string{ + "socks5h://user:token@{account}:2260", + "socks5h://user:{account}@resin:2260", + "socks5h://{account}:{account}@resin:2260", + "socks5h://grok2api_account_placeholder:token@{account}:2260", + } { + if _, err := NormalizeProxyURL(invalid); err == nil { + t.Fatalf("invalid account placeholder accepted: %q", invalid) + } + } +} + +func TestServiceRejectsRemovedAllScope(t *testing.T) { + service := &Service{} + _, err := service.applyInput(domain.Node{}, Input{Name: "legacy", Scope: domain.Scope("all"), Enabled: true}, true) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("all scope error = %v", err) + } +} + +func TestBuildNodeAlwaysUsesProviderUserAgent(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + service := NewService(nil, cipher, "browser-agent") + value, err := service.applyInput(domain.Node{UserAgent: "legacy-build-agent"}, Input{ + Name: "build", Scope: domain.ScopeBuild, Enabled: true, UserAgent: "custom-build-agent", + }, false) + if err != nil { + t.Fatal(err) + } + if value.UserAgent != "" || service.publicNode(value).UserAgent != "" { + t.Fatalf("build node userAgent = %q", value.UserAgent) + } + if defaults := service.DefaultUserAgents(); defaults[string(domain.ScopeBuild)] != "" || defaults[string(domain.ScopeWeb)] != "browser-agent" || defaults[string(domain.ScopeConsole)] != "browser-agent" { + t.Fatalf("default user agents = %#v", defaults) + } +} + +func TestConsoleNodeUsesBrowserDefaultUserAgent(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + service := NewService(nil, cipher, "browser-agent") + value, err := service.applyInput(domain.Node{}, Input{Name: "console", Scope: domain.ScopeConsole, Enabled: true}, true) + if err != nil { + t.Fatal(err) + } + if value.UserAgent != "browser-agent" { + t.Fatalf("console node userAgent = %q", value.UserAgent) + } +} + +func TestPublicNodeReportsAccountBoundProxy(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + service := NewService(nil, cipher, "browser-agent") + cooldown := time.Now().UTC().Add(time.Minute) + public := service.publicNode(domain.Node{ + Scope: domain.ScopeWeb, EncryptedProxyURL: encryptedProxy, Health: 0.2, + FailureCount: 3, CooldownUntil: &cooldown, LastError: "legacy failure", + }) + if !public.AccountBoundProxy { + t.Fatal("Resin proxy was not reported as account-bound") + } + if !public.ProxyPool { + t.Fatal("account-bound proxy was not reported as a proxy pool") + } + if public.Health != 1 || public.FailureCount != 0 || public.CooldownUntil != nil || public.LastError != "" { + t.Fatalf("proxy pool exposed obsolete node health: %#v", public) + } + if service.publicNode(domain.Node{Scope: domain.ScopeWeb}).AccountBoundProxy { + t.Fatal("direct node was reported as account-bound") + } +} + +func TestApplyInputResetsHealthOnlyWhenEgressConfigurationChanges(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + service := NewService(nil, cipher, "browser-agent") + cooldown := time.Now().UTC().Add(time.Minute) + base := domain.Node{ + Name: "node", Scope: domain.ScopeWeb, Enabled: true, Health: 0.2, + FailureCount: 4, CooldownUntil: &cooldown, LastError: "transport error", + } + + renamed, err := service.applyInput(base, Input{Name: "renamed", Scope: domain.ScopeWeb, Enabled: true}, false) + if err != nil { + t.Fatal(err) + } + if renamed.Health != base.Health || renamed.FailureCount != base.FailureCount || renamed.CooldownUntil == nil || renamed.LastError != base.LastError { + t.Fatalf("name-only edit reset health: %#v", renamed) + } + legacyPool := base + legacyPool.ProxyPool = true + legacyPool.EncryptedProxyURL, err = cipher.Encrypt("socks5h://proxy.example:1080") + if err != nil { + t.Fatal(err) + } + preserved, err := service.applyInput(legacyPool, Input{Name: "renamed", Scope: domain.ScopeWeb, Enabled: true}, false) + if err != nil { + t.Fatal(err) + } + if !preserved.ProxyPool { + t.Fatal("an update without proxyPool disabled the existing mode") + } + + proxyURL := "socks5h://proxy.example:1080" + proxyPool := true + changed, err := service.applyInput(base, Input{Name: "node", Scope: domain.ScopeWeb, Enabled: true, ProxyPool: &proxyPool, ProxyURL: &proxyURL}, false) + if err != nil { + t.Fatal(err) + } + if changed.Health != 1 || changed.FailureCount != 0 || changed.CooldownUntil != nil || changed.LastError != "" || !changed.ProxyPool { + t.Fatalf("egress configuration did not reset health: %#v", changed) + } +} + +func TestProxyPoolRequiresConfiguredProxy(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + service := NewService(nil, cipher, "browser-agent") + proxyPool := true + _, err = service.applyInput(domain.Node{}, Input{ + Name: "pool", Scope: domain.ScopeBuild, Enabled: true, ProxyPool: &proxyPool, + }, true) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("proxy pool without a proxy error = %v", err) + } +} diff --git a/backend/internal/application/egress/subscription.go b/backend/internal/application/egress/subscription.go new file mode 100644 index 0000000000000000000000000000000000000000..2e32a8ae46978352d2f962b23ec49d75528185f1 --- /dev/null +++ b/backend/internal/application/egress/subscription.go @@ -0,0 +1,249 @@ +package egress + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + "unicode/utf8" +) + +const ( + maxSubscriptionBytes = 2 << 20 + maxSubscriptionEntries = 10000 + maxSubscriptionHops = 3 + subscriptionFetchTimeout = 20 * time.Second +) + +var blockedSubscriptionPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("2001:db8::/32"), +} + +type subscriptionEntry struct { + ProxyURL string + Key string +} + +func normalizeSubscriptionURL(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || len(value) > maxProxyURLBytes { + return "", errors.New("订阅地址为空或过长") + } + if strings.IndexFunc(value, func(character rune) bool { return character < 0x20 || character == 0x7f }) >= 0 { + return "", errors.New("订阅地址包含控制字符") + } + parsed, err := url.Parse(value) + if err != nil || parsed.Hostname() == "" { + return "", errors.New("订阅地址格式无效") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("订阅地址必须使用 HTTP 或 HTTPS") + } + if parsed.Fragment != "" { + return "", errors.New("订阅地址不能包含片段") + } + return parsed.String(), nil +} + +func fetchProxySubscription(ctx context.Context, value string) ([]byte, error) { + normalized, err := normalizeSubscriptionURL(value) + if err != nil { + return nil, err + } + transport := &http.Transport{ + Proxy: nil, + DialContext: publicDialContext(net.DefaultResolver), + ForceAttemptHTTP2: true, + MaxIdleConns: 2, + MaxIdleConnsPerHost: 1, + IdleConnTimeout: 15 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + } + defer transport.CloseIdleConnections() + client := &http.Client{ + Transport: transport, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= maxSubscriptionHops { + return errors.New("订阅重定向次数过多") + } + if _, err := normalizeSubscriptionURL(request.URL.String()); err != nil { + return errors.New("订阅重定向地址无效") + } + return nil + }, + } + requestCtx, cancel := context.WithTimeout(ctx, subscriptionFetchTimeout) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, normalized, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "text/plain, text/*;q=0.9, */*;q=0.1") + request.Header.Set("User-Agent", "grok2api-egress-subscription/1") + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("订阅服务返回 HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxSubscriptionBytes { + return nil, errors.New("订阅内容超过大小限制") + } + return body, nil +} + +// publicDialContext resolves every destination immediately before dialing. It +// rejects loopback, private, link-local, multicast, and carrier-grade NAT +// addresses, including redirect destinations, to avoid subscription SSRF. +func publicDialContext(resolver *net.Resolver) func(context.Context, string, string) (net.Conn, error) { + if resolver == nil { + resolver = net.DefaultResolver + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := resolvePublicAddresses(ctx, resolver, host) + if err != nil { + return nil, err + } + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second} + var lastErr error + for _, ip := range addresses { + connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return connection, nil + } + lastErr = dialErr + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("订阅地址没有可用的公网 IP") + } +} + +func resolvePublicAddresses(ctx context.Context, resolver *net.Resolver, host string) ([]netip.Addr, error) { + host = strings.Trim(strings.TrimSpace(host), "[]") + if parsed, err := netip.ParseAddr(host); err == nil { + if !isPublicAddress(parsed) { + return nil, errors.New("订阅地址不能指向内网") + } + return []netip.Addr{parsed.Unmap()}, nil + } + addresses, err := resolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, err + } + public := make([]netip.Addr, 0, len(addresses)) + for _, address := range addresses { + if isPublicAddress(address) { + public = append(public, address.Unmap()) + } + } + if len(public) == 0 { + return nil, errors.New("订阅地址不能指向内网") + } + return public, nil +} + +func isPublicAddress(value netip.Addr) bool { + value = value.Unmap() + if !value.IsValid() || !value.IsGlobalUnicast() || value.IsLoopback() || value.IsPrivate() || value.IsLinkLocalUnicast() || value.IsLinkLocalMulticast() || value.IsMulticast() || value.IsUnspecified() { + return false + } + for _, prefix := range blockedSubscriptionPrefixes { + if prefix.Contains(value) { + return false + } + } + return true +} + +func parseProxySubscription(value string) ([]subscriptionEntry, int, error) { + entries, skipped := parseProxyLines(value) + if len(entries) > 0 { + return entries, skipped, nil + } + compact := strings.Map(func(character rune) rune { + if character == ' ' || character == '\t' || character == '\r' || character == '\n' { + return -1 + } + return character + }, strings.TrimPrefix(value, "\ufeff")) + for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { + decoded, err := encoding.DecodeString(compact) + if err != nil || len(decoded) == 0 || len(decoded) > maxSubscriptionBytes { + continue + } + entries, decodedSkipped := parseProxyLines(string(decoded)) + if len(entries) > 0 { + // The original Base64 text is not an invalid proxy entry. Once it + // decodes to a valid list, report only invalid decoded entries. + return entries, decodedSkipped, nil + } + } + return nil, skipped, errors.New("订阅中没有可用的 HTTP 或 SOCKS 代理") +} + +func parseProxyLines(value string) ([]subscriptionEntry, int) { + value = strings.TrimPrefix(value, "\ufeff") + seen := make(map[string]struct{}) + entries := make([]subscriptionEntry, 0) + skipped := 0 + for line := range strings.SplitSeq(value, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + normalized, err := NormalizeProxyURL(line) + if err != nil { + skipped++ + continue + } + digest := sha256.Sum256([]byte(normalized)) + key := hex.EncodeToString(digest[:]) + if _, exists := seen[key]; exists { + skipped++ + continue + } + seen[key] = struct{}{} + entries = append(entries, subscriptionEntry{ProxyURL: normalized, Key: key}) + if len(entries) > maxSubscriptionEntries { + return nil, skipped + } + } + return entries, skipped +} + +func sourceNodeName(sourceName string, index int) string { + suffix := fmt.Sprintf(" %03d", index+1) + value := strings.TrimSpace(sourceName) + for len(value)+len(suffix) > 160 && value != "" { + _, size := utf8.DecodeLastRuneInString(value) + value = value[:len(value)-size] + } + return strings.TrimSpace(value) + suffix +} diff --git a/backend/internal/application/egress/subscription_test.go b/backend/internal/application/egress/subscription_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e64ee6daac865b41e98428636f165b2fe5aa231a --- /dev/null +++ b/backend/internal/application/egress/subscription_test.go @@ -0,0 +1,59 @@ +package egress + +import ( + "encoding/base64" + "net/netip" + "strings" + "testing" +) + +func TestParseProxySubscriptionAcceptsPlainAndBase64Lists(t *testing.T) { + plain, skipped, err := parseProxySubscription(strings.Join([]string{ + "# proxy list", + "http://user:pass@one.example:8080", + "socks5h://two.example:1080", + "http://user:pass@one.example:8080", + "not a proxy", + }, "\n")) + if err != nil { + t.Fatal(err) + } + if len(plain) != 2 || skipped != 2 { + t.Fatalf("plain entries=%d skipped=%d", len(plain), skipped) + } + for _, entry := range plain { + if entry.ProxyURL == "" || len(entry.Key) != 64 { + t.Fatalf("unsafe parsed entry: %#v", entry) + } + } + + encodedInput := base64.RawStdEncoding.EncodeToString([]byte("https://three.example:8443\nsocks4a://four.example:1080\n")) + encoded, encodedSkipped, err := parseProxySubscription(encodedInput) + if err != nil { + t.Fatal(err) + } + if len(encoded) != 2 || encodedSkipped != 0 { + t.Fatalf("base64 entries=%d skipped=%d", len(encoded), encodedSkipped) + } +} + +func TestParseProxySubscriptionRejectsNoUsableEntries(t *testing.T) { + if _, _, err := parseProxySubscription("# only comments\nfile:///tmp/proxies\n"); err == nil { + t.Fatal("invalid proxy subscription was accepted") + } +} + +func TestIsPublicAddressRejectsNonPublicRanges(t *testing.T) { + for _, raw := range []string{ + "0.0.0.1", "10.0.0.1", "100.64.0.1", "127.0.0.1", "169.254.10.1", + "192.0.0.1", "192.0.2.1", "198.18.0.1", "198.51.100.1", "203.0.113.1", "240.0.0.1", + "::1", "fc00::1", "2001:db8::1", "::ffff:127.0.0.1", + } { + if isPublicAddress(netip.MustParseAddr(raw)) { + t.Fatalf("non-public address accepted: %s", raw) + } + } + if !isPublicAddress(netip.MustParseAddr("1.1.1.1")) { + t.Fatal("public address rejected") + } +} diff --git a/backend/internal/application/egress/sync.go b/backend/internal/application/egress/sync.go new file mode 100644 index 0000000000000000000000000000000000000000..134451d8dea5502b0c991caa1706e1ed035446df --- /dev/null +++ b/backend/internal/application/egress/sync.go @@ -0,0 +1,74 @@ +package egress + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" +) + +var ErrSubscriptionSync = errors.New("代理订阅同步失败") + +func (s *Service) syncSource(ctx context.Context, operations OperationsRepository, source domain.SubscriptionSource) (ImportResult, error) { + now := time.Now().UTC() + nextSyncAt := now.Add(time.Duration(source.RefreshIntervalSeconds) * time.Second) + if source.RefreshIntervalSeconds < 60 { + nextSyncAt = now.Add(defaultProbeIntervalSeconds * time.Second) + } + recordFailure := func() { + // The source URL and any transport detail are deliberately omitted from + // persisted status and API errors; they may contain subscription tokens. + _ = operations.UpdateEgressSourceSync(context.WithoutCancel(ctx), source.ID, now, nextSyncAt, 0, "订阅拉取或解析失败") + } + if strings.TrimSpace(source.EncryptedURL) == "" { + recordFailure() + return ImportResult{}, ErrSubscriptionSync + } + urlValue, err := s.cipher.Decrypt(source.EncryptedURL) + if err != nil { + recordFailure() + return ImportResult{}, ErrSubscriptionSync + } + content, err := fetchProxySubscription(ctx, urlValue) + if err != nil { + recordFailure() + return ImportResult{}, ErrSubscriptionSync + } + entries, skipped, err := parseProxySubscription(string(content)) + if err != nil { + recordFailure() + return ImportResult{}, ErrSubscriptionSync + } + userAgent := "" + if source.Scope != domain.ScopeBuild { + s.mu.RLock() + userAgent = s.browserUA + s.mu.RUnlock() + } + nodes := make([]domain.Node, 0, len(entries)) + for index, entry := range entries { + encryptedProxy, encryptErr := s.cipher.Encrypt(entry.ProxyURL) + if encryptErr != nil { + recordFailure() + return ImportResult{}, fmt.Errorf("%w: 加密导入节点", ErrSubscriptionSync) + } + nodes = append(nodes, domain.Node{ + Name: sourceNodeName(source.Name, index), Scope: source.Scope, Enabled: true, + SourceID: source.ID, SourceKey: entry.Key, AccountCapacity: source.DefaultAccountCapacity, + EncryptedProxyURL: encryptedProxy, UserAgent: userAgent, Health: 1, ProbeStatus: domain.ProbeStatusUnknown, + }) + } + imported, err := operations.UpsertEgressNodesFromSource(ctx, source.ID, nodes) + if err != nil { + recordFailure() + return ImportResult{}, ErrSubscriptionSync + } + s.invalidateOperationsConfig() + if err := operations.UpdateEgressSourceSync(ctx, source.ID, now, nextSyncAt, imported, ""); err != nil { + return ImportResult{}, err + } + return ImportResult{Imported: imported, Skipped: skipped}, nil +} diff --git a/backend/internal/application/gateway/attempt.go b/backend/internal/application/gateway/attempt.go new file mode 100644 index 0000000000000000000000000000000000000000..0843bf8ef3a7f3f311f02ce371a2dfd48384ad9d --- /dev/null +++ b/backend/internal/application/gateway/attempt.go @@ -0,0 +1,383 @@ +package gateway + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "net/url" + "reflect" + "regexp" + "strings" + "time" + "unicode/utf8" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + neterrorpkg "github.com/chenyme/grok2api/backend/internal/pkg/neterror" +) + +type failureAttemptRecorder struct { + method string + path string + remainingBodyBudget int + attempts []audit.Attempt +} + +func newFailureAttemptRecorder(method, path string) *failureAttemptRecorder { + return &failureAttemptRecorder{method: method, path: sanitizeRequestPath(path), remainingBodyBudget: diagnosticTotalBodyLimit} +} + +const ( + diagnosticBodyLimit = 64 << 10 + diagnosticTotalBodyLimit = 256 << 10 + diagnosticTextLimit = 2048 + diagnosticHeadersLimit = 4 << 10 + diagnosticHeaderValueLimit = 512 + diagnosticErrorFrameLimit = 8 +) + +var ( + diagnosticAuthorizationPattern = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+`) + diagnosticCookiePattern = regexp.MustCompile(`(?i)\b(cookie|set-cookie)\b\s*[:=]\s*[^\r\n]+`) + diagnosticSecretPattern = regexp.MustCompile(`(?i)(["']?(?:authorization|proxy-authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)["']?\s*[:=]\s*["']?)[^"'\s,;}]+`) + diagnosticURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) +) + +func (r *failureAttemptRecorder) captureCredentialFailure(credential accountdomain.Credential, startedAt time.Time, force bool, err error) { + if err == nil { + return + } + stage := "credential_validation" + if force { + stage = "credential_refresh" + } + r.append(audit.Attempt{ + Source: audit.AttemptSourceCredential, + Stage: stage, + AccountID: auditAccountID(credential.ID), + AccountName: credential.Name, + StartedAt: startedAt.UTC(), + DurationMS: time.Since(startedAt).Milliseconds(), + TransportError: sanitizeDiagnosticText(err.Error(), diagnosticTextLimit), + ErrorChain: errorFrames(err), + }) +} + +func (r *failureAttemptRecorder) captureResponse(credential accountdomain.Credential, startedAt time.Time, response *provider.Response, requestErr error) error { + if requestErr != nil { + r.append(audit.Attempt{ + Source: audit.AttemptSourceTransport, + Stage: transportStage(requestErr), + AccountID: auditAccountID(credential.ID), + AccountName: credential.Name, + Method: r.method, + RequestPath: r.path, + UpstreamURL: sanitizeUpstreamURL(errorUpstreamURL(requestErr)), + StartedAt: startedAt.UTC(), + DurationMS: time.Since(startedAt).Milliseconds(), + TransportError: sanitizeDiagnosticText(requestErr.Error(), diagnosticTextLimit), + ErrorChain: errorFrames(requestErr), + }) + return requestErr + } + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + + statusCode := response.StatusCode + status := response.Status + headers := sanitizeDiagnosticHeaders(response.Header) + var body []byte + var bodyTruncated bool + if response.Diagnostic != nil { + statusCode = response.Diagnostic.StatusCode + status = response.Diagnostic.Status + headers = sanitizeDiagnosticHeaders(response.Diagnostic.Header) + body, bodyTruncated = r.captureBody(response.Diagnostic.Body, response.Diagnostic.BodyTruncated) + } else { + var err error + var replay io.ReadCloser + body, replay, bodyTruncated, err = readResponseBody(response.Body) + response.Body = replay + body, bodyTruncated = r.captureBody(body, bodyTruncated) + if err != nil { + r.append(audit.Attempt{ + Source: audit.AttemptSourceUpstreamHTTP, + Stage: "response_body", + AccountID: auditAccountID(credential.ID), + AccountName: credential.Name, + Method: r.method, + RequestPath: r.path, + UpstreamURL: sanitizeUpstreamURL(response.UpstreamURL), + StartedAt: startedAt.UTC(), + DurationMS: time.Since(startedAt).Milliseconds(), + UpstreamStatusCode: &statusCode, + UpstreamStatus: status, + ResponseHeaders: headers, + ResponseBody: body, + ResponseBodyTruncated: bodyTruncated, + TransportError: sanitizeDiagnosticText(err.Error(), diagnosticTextLimit), + ErrorChain: errorFrames(err), + }) + return err + } + } + r.append(audit.Attempt{ + Source: audit.AttemptSourceUpstreamHTTP, + Stage: "upstream_response", + AccountID: auditAccountID(credential.ID), + AccountName: credential.Name, + Method: r.method, + RequestPath: r.path, + UpstreamURL: sanitizeUpstreamURL(response.UpstreamURL), + StartedAt: startedAt.UTC(), + DurationMS: time.Since(startedAt).Milliseconds(), + UpstreamStatusCode: &statusCode, + UpstreamStatus: status, + ResponseHeaders: headers, + ResponseBody: body, + ResponseBodyTruncated: bodyTruncated, + }) + return nil +} + +func (r *failureAttemptRecorder) captureStreamFailure(credential accountdomain.Credential, startedAt time.Time, response *provider.Response, diagnostic StreamFailureDiagnostic) { + if response == nil { + return + } + statusCode := response.StatusCode + body, bodyTruncated := r.captureBody(diagnostic.Body, diagnostic.BodyTruncated) + r.append(audit.Attempt{ + Source: audit.AttemptSourceUpstreamHTTP, + Stage: "response_stream", + AccountID: auditAccountID(credential.ID), + AccountName: credential.Name, + Method: r.method, + RequestPath: r.path, + UpstreamURL: sanitizeUpstreamURL(response.UpstreamURL), + StartedAt: startedAt.UTC(), + DurationMS: time.Since(startedAt).Milliseconds(), + UpstreamStatusCode: &statusCode, + UpstreamStatus: response.Status, + ResponseHeaders: sanitizeDiagnosticHeaders(response.Header), + ResponseBody: body, + ResponseBodyTruncated: bodyTruncated, + }) +} + +func (r *failureAttemptRecorder) append(attempt audit.Attempt) { + attempt.Number = len(r.attempts) + 1 + r.attempts = append(r.attempts, attempt) +} + +// captureBody 在单次和单请求预算内保留可读的脱敏正文片段。 +func (r *failureAttemptRecorder) captureBody(body []byte, alreadyTruncated bool) ([]byte, bool) { + if len(body) == 0 { + return nil, alreadyTruncated + } + if !utf8.Valid(body) { + return nil, true + } + limit := min(diagnosticBodyLimit, r.remainingBodyBudget) + if limit <= 0 { + return nil, true + } + truncated := alreadyTruncated || len(body) > limit + if len(body) > limit { + body = body[:limit] + } + result := []byte(sanitizeDiagnosticText(string(body), limit)) + r.remainingBodyBudget -= len(result) + return result, truncated +} + +func (r *failureAttemptRecorder) snapshot() []audit.Attempt { + return append([]audit.Attempt(nil), r.attempts...) +} + +func auditAccountID(id uint64) *uint64 { + if id == 0 { + return nil + } + return &id +} + +type replayReadCloser struct { + io.Reader + source io.Closer +} + +func (r *replayReadCloser) Close() error { return r.source.Close() } + +// readResponseBody 只读取诊断上限,同时把已读取前缀接回原始响应供后续错误处理。 +func readResponseBody(body io.ReadCloser) ([]byte, io.ReadCloser, bool, error) { + if body == nil { + return nil, io.NopCloser(bytes.NewReader(nil)), false, nil + } + data, err := io.ReadAll(io.LimitReader(body, diagnosticBodyLimit+1)) + truncated := len(data) > diagnosticBodyLimit + if truncated || err != nil { + captured := data + if len(captured) > diagnosticBodyLimit { + captured = captured[:diagnosticBodyLimit] + } + replay := &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(data), body), source: body} + return captured, replay, truncated, err + } + closeErr := body.Close() + return data, io.NopCloser(bytes.NewReader(data)), false, closeErr +} + +func errorFrames(err error) []audit.ErrorFrame { + frames := make([]audit.ErrorFrame, 0, 4) + appendErrorFrames(&frames, err) + return frames +} + +func appendErrorFrames(frames *[]audit.ErrorFrame, err error) { + if err == nil || len(*frames) >= diagnosticErrorFrameLimit { + return + } + *frames = append(*frames, audit.ErrorFrame{Type: truncateDiagnosticText(reflect.TypeOf(err).String(), 256), Message: sanitizeDiagnosticText(err.Error(), 512)}) + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, nested := range joined.Unwrap() { + appendErrorFrames(frames, nested) + } + return + } + appendErrorFrames(frames, errors.Unwrap(err)) +} + +func sanitizeDiagnosticHeaders(headers http.Header) map[string][]string { + result := make(map[string][]string) + remaining := diagnosticHeadersLimit + for name, values := range headers { + if remaining <= 0 { + break + } + lowerName := strings.ToLower(name) + if !isAllowedDiagnosticHeader(lowerName) { + continue + } + cleanValues := make([]string, 0, min(len(values), 8)) + for _, value := range values { + if len(cleanValues) == 8 || remaining <= len(name) { + break + } + cleanValue := sanitizeDiagnosticText(value, min(diagnosticHeaderValueLimit, remaining-len(name))) + cleanValues = append(cleanValues, cleanValue) + remaining -= len(name) + len(cleanValue) + } + if len(cleanValues) > 0 { + result[http.CanonicalHeaderKey(name)] = cleanValues + } + } + return result +} + +func isAllowedDiagnosticHeader(name string) bool { + if strings.HasPrefix(name, "x-ratelimit-") || strings.HasPrefix(name, "ratelimit-") { + return true + } + switch name { + case "content-length", "content-type", "date", "retry-after", "server", "cf-ray", "request-id", "traceparent", "tracestate", "via", "x-correlation-id", "x-request-id", "x-grok2api-compatibility-warnings": + return true + default: + return false + } +} + +func sanitizeDiagnosticText(value string, limit int) string { + value = diagnosticCookiePattern.ReplaceAllString(value, "$1: [REDACTED]") + value = diagnosticAuthorizationPattern.ReplaceAllString(value, "$1 [REDACTED]") + value = diagnosticSecretPattern.ReplaceAllString(value, "$1[REDACTED]") + value = diagnosticURLPattern.ReplaceAllStringFunc(value, sanitizeUpstreamURL) + return truncateDiagnosticText(value, limit) +} + +func truncateDiagnosticText(value string, limit int) string { + if limit <= 0 { + return "" + } + if len(value) <= limit { + return value + } + for limit > 0 && !utf8.ValidString(value[:limit]) { + limit-- + } + return value[:limit] +} + +func sanitizeRequestPath(value string) string { + parsed, err := url.ParseRequestURI(value) + if err != nil { + return truncateDiagnosticText(strings.SplitN(value, "?", 2)[0], 2048) + } + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return truncateDiagnosticText(parsed.String(), 2048) +} + +func sanitizeUpstreamURL(value string) string { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + parsed.RawFragment = "" + return truncateDiagnosticText(parsed.String(), 4096) +} + +func errorUpstreamURL(err error) string { + var urlErr *url.Error + if errors.As(err, &urlErr) { + return urlErr.URL + } + return "" +} + +func transportStage(err error) string { + switch { + case neterrorpkg.IsResponseHeaderTimeout(err): + return "response_header_timeout" + case errors.Is(err, context.Canceled): + return "request_canceled" + case errors.Is(err, context.DeadlineExceeded): + return "request_timeout" + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return "dns_lookup" + } + var certificateError *tls.CertificateVerificationError + if errors.As(err, &certificateError) { + return "tls_verification" + } + var unknownAuthority x509.UnknownAuthorityError + if errors.As(err, &unknownAuthority) { + return "tls_verification" + } + var recordHeaderError tls.RecordHeaderError + if errors.As(err, &recordHeaderError) { + return "tls_handshake" + } + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return "network_timeout" + } + var operationError *net.OpError + if errors.As(err, &operationError) && operationError.Op != "" { + return operationError.Op + } + return "transport" +} diff --git a/backend/internal/application/gateway/attempt_test.go b/backend/internal/application/gateway/attempt_test.go new file mode 100644 index 0000000000000000000000000000000000000000..98f23d26adfeb023d79aa52ede33dae0af7cc081 --- /dev/null +++ b/backend/internal/application/gateway/attempt_test.go @@ -0,0 +1,138 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestFailureAttemptRecorderLimitsAndSanitizesHTTPResponse(t *testing.T) { + body := strings.Repeat("upstream failure\n", 8192) + recorder := newFailureAttemptRecorder(http.MethodPost, "/responses") + response := &provider.Response{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + Header: http.Header{"Content-Type": {"text/plain"}, "Set-Cookie": {"session=secret"}, "X-Request-ID": {"req-123"}}, + Body: io.NopCloser(strings.NewReader(body)), + UpstreamURL: "https://user:password@api.example.test/v1/responses?token=secret#debug", + } + if err := recorder.captureResponse(account.Credential{ID: 9, Name: "primary"}, time.Now(), response, nil); err != nil { + t.Fatal(err) + } + stored := recorder.snapshot() + if len(stored) != 1 || stored[0].Source != audit.AttemptSourceUpstreamHTTP || len(stored[0].ResponseBody) != diagnosticBodyLimit || !stored[0].ResponseBodyTruncated { + t.Fatalf("attempt = %#v", stored) + } + headers := http.Header(stored[0].ResponseHeaders) + if stored[0].UpstreamURL != "https://api.example.test/v1/responses" || headers.Get("Set-Cookie") != "" || headers.Get("X-Request-Id") != "req-123" { + t.Fatalf("sanitized attempt = %#v", stored[0]) + } + rebuilt, err := io.ReadAll(response.Body) + if err != nil || string(rebuilt) != body { + t.Fatalf("rebuilt body length = %d, err = %v", len(rebuilt), err) + } +} + +func TestFailureAttemptRecorderUsesProviderDiagnosticResponse(t *testing.T) { + recorder := newFailureAttemptRecorder(http.MethodPost, "/responses") + response := &provider.Response{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"normalized"}}`)), + UpstreamURL: "https://api.example.test/v1/responses", + Diagnostic: &provider.DiagnosticResponse{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + Header: http.Header{"Content-Type": {"text/plain"}, "Authorization": {"Bearer secret"}}, + Body: []byte("failure access_token=secret-token"), + }, + } + if err := recorder.captureResponse(account.Credential{ID: 9, Name: "primary"}, time.Now(), response, nil); err != nil { + t.Fatal(err) + } + stored := recorder.snapshot()[0] + if string(stored.ResponseBody) != "failure access_token=[REDACTED]" || http.Header(stored.ResponseHeaders).Get("Authorization") != "" { + t.Fatalf("attempt = %#v", stored) + } + converted, err := io.ReadAll(response.Body) + if err != nil || !strings.Contains(string(converted), "normalized") { + t.Fatalf("provider response body = %q, err = %v", converted, err) + } +} + +func TestFailureAttemptRecorderCapturesStreamFailure(t *testing.T) { + recorder := newFailureAttemptRecorder(http.MethodPost, "/responses") + response := &provider.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": {"text/event-stream"}, "Set-Cookie": {"session=secret"}}, + UpstreamURL: "https://user:password@api.example.test/v1/responses?token=secret", + } + recorder.captureStreamFailure( + account.Credential{ID: 9, Name: "primary"}, + time.Now().Add(-time.Second), + response, + StreamFailureDiagnostic{Body: []byte(`{"type":"response.failed","error":{"message":"access_token=secret-token"}}`)}, + ) + stored := recorder.snapshot() + if len(stored) != 1 || stored[0].Stage != "response_stream" || stored[0].UpstreamStatusCode == nil || *stored[0].UpstreamStatusCode != http.StatusOK { + t.Fatalf("attempt = %#v", stored) + } + if string(stored[0].ResponseBody) != `{"type":"response.failed","error":{"message":"access_token=[REDACTED]"}}` || stored[0].UpstreamURL != "https://api.example.test/v1/responses" || http.Header(stored[0].ResponseHeaders).Get("Set-Cookie") != "" { + t.Fatalf("sanitized attempt = %#v", stored[0]) + } +} + +func TestFailureAttemptRecorderClassifiesTransportErrorChain(t *testing.T) { + dnsErr := &net.DNSError{Err: "no such host", Name: "api.example.test", IsNotFound: true} + requestErr := &url.Error{Op: "Post", URL: "https://user:password@api.example.test/v1/responses?token=secret", Err: dnsErr} + recorder := newFailureAttemptRecorder(http.MethodPost, "/responses") + if err := recorder.captureResponse(account.Credential{ID: 3, Name: "primary"}, time.Now(), nil, requestErr); !errors.Is(err, dnsErr) { + t.Fatalf("capture error = %v", err) + } + stored := recorder.snapshot() + if len(stored) != 1 || stored[0].Stage != "dns_lookup" || stored[0].UpstreamURL != "https://api.example.test/v1/responses" || len(stored[0].ErrorChain) != 2 { + t.Fatalf("attempt = %#v", stored) + } + if transportStage(context.DeadlineExceeded) != "request_timeout" { + t.Fatalf("deadline stage = %s", transportStage(context.DeadlineExceeded)) + } +} + +func TestFailureAttemptRecorderBoundsTotalBodyAndErrorChain(t *testing.T) { + recorder := newFailureAttemptRecorder(http.MethodPost, "/responses") + for index := 0; index < 5; index++ { + response := &provider.Response{StatusCode: http.StatusBadGateway, Header: http.Header{"Content-Type": {"text/plain"}}, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", diagnosticBodyLimit)))} + if err := recorder.captureResponse(account.Credential{ID: uint64(index + 1)}, time.Now(), response, nil); err != nil { + t.Fatal(err) + } + } + stored := recorder.snapshot() + var captured int + for _, attempt := range stored { + captured += len(attempt.ResponseBody) + } + if captured != diagnosticTotalBodyLimit || len(stored[4].ResponseBody) != 0 || !stored[4].ResponseBodyTruncated { + t.Fatalf("captured = %d, final attempt = %#v", captured, stored[4]) + } + + var wrapped error = errors.New("root") + for index := 0; index < 20; index++ { + wrapped = fmt.Errorf("layer %d: %w", index, wrapped) + } + if frames := errorFrames(wrapped); len(frames) != diagnosticErrorFrameLimit { + t.Fatalf("error frames = %d", len(frames)) + } +} diff --git a/backend/internal/application/gateway/egress_audit.go b/backend/internal/application/gateway/egress_audit.go new file mode 100644 index 0000000000000000000000000000000000000000..fe546d9798f5749d44c067952ac3539e321caf38 --- /dev/null +++ b/backend/internal/application/gateway/egress_audit.go @@ -0,0 +1,55 @@ +package gateway + +import ( + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/domain/media" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" +) + +func applyAuditEgress(record *audit.Record, trace *infraegress.Trace, provider accountdomain.Provider) { + selection, ok := trace.Selection(primaryEgressScope(provider)) + if !ok { + return + } + record.EgressNodeName = selection.NodeName + record.EgressScope = string(selection.Scope) + if selection.Proxied { + record.EgressMode = audit.EgressModeProxy + } else { + record.EgressMode = audit.EgressModeDirect + } + if selection.NodeID != 0 { + id := selection.NodeID + record.EgressNodeID = &id + } +} + +func applyMediaJobEgress(job *media.Job, trace *infraegress.Trace, provider accountdomain.Provider) { + selection, ok := trace.Selection(primaryEgressScope(provider)) + if !ok { + return + } + job.EgressNodeName = selection.NodeName + job.EgressScope = string(selection.Scope) + job.EgressMode = string(audit.EgressModeDirect) + if selection.Proxied { + job.EgressMode = string(audit.EgressModeProxy) + } + if selection.NodeID != 0 { + id := selection.NodeID + job.EgressNodeID = &id + } +} + +func primaryEgressScope(provider accountdomain.Provider) egressdomain.Scope { + switch provider { + case accountdomain.ProviderWeb: + return egressdomain.ScopeWeb + case accountdomain.ProviderConsole: + return egressdomain.ScopeConsole + default: + return egressdomain.ScopeBuild + } +} diff --git a/backend/internal/application/gateway/failure.go b/backend/internal/application/gateway/failure.go new file mode 100644 index 0000000000000000000000000000000000000000..bff22047ad82fd19e6e0f0b34cc58221cc927f07 --- /dev/null +++ b/backend/internal/application/gateway/failure.go @@ -0,0 +1,288 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + "unicode" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" + neterrorpkg "github.com/chenyme/grok2api/backend/internal/pkg/neterror" +) + +// UpstreamFailure 保存可安全暴露给下游和审计的上游失败分类,不包含响应正文或凭据。 +type UpstreamFailure struct { + HTTPStatus int + Code string + PublicMessage string + UpstreamCode string + AccountID uint64 + AccountName string + AccountScoped bool + AccountBlocked bool + PermanentAccountDenial bool + // SafetyRejection marks a request-level content safety denial. It must not + // refresh OAuth, retry, switch accounts, cool down, or invalidate credentials. + SafetyRejection bool + QuotaExhausted bool + FreeQuotaExhausted bool + ModelQuotaExhausted bool + CredentialRejected bool + Fingerprint string + RetryAfter time.Duration + Cause error +} + +func (e *UpstreamFailure) Error() string { + if e == nil { + return "上游请求失败" + } + if e.UpstreamCode != "" { + return fmt.Sprintf("%s: %s", e.Code, e.UpstreamCode) + } + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Code, e.Cause) + } + return e.Code +} + +func (e *UpstreamFailure) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func (e *UpstreamFailure) AuditCode() string { + if e == nil { + return "upstream_error" + } + if suffix := normalizeFailureCode(e.UpstreamCode); suffix != "" { + return truncateFailureCode(e.Code + "_" + suffix) + } + return truncateFailureCode(e.Code) +} + +// ClientCredentialErrorCode 返回允许暴露给客户端的账号类上游错误码。 +// HTTP 状态和错误文案仍由传输层统一脱敏;这里只放行稳定、无凭据内容的机器码。 +func (e *UpstreamFailure) ClientCredentialErrorCode() string { + if e == nil { + return "upstream_unavailable" + } + return clientCredentialErrorCode(e.HTTPStatus, e.UpstreamCode) +} + +// ClientCredentialErrorCodeFromBody 从账号类上游错误正文中提取允许公开的机器码。 +// 用于上游响应已直接交给传输层、尚未构造 UpstreamFailure 的路径。 +func ClientCredentialErrorCodeFromBody(status int, body []byte) string { + upstreamCode, _, _ := extractUpstreamErrorMetadata(body) + return clientCredentialErrorCode(status, upstreamCode) +} + +func clientCredentialErrorCode(status int, upstreamCode string) string { + if status == http.StatusForbidden && normalizeFailureCode(upstreamCode) == "permission_denied" { + return "permission-denied" + } + return "upstream_unavailable" +} + +func newHTTPUpstreamFailure(status int, body []byte, accountID uint64, accountName string) *UpstreamFailure { + upstreamCode, upstreamType, upstreamMessage := extractUpstreamErrorMetadata(body) + failure := &UpstreamFailure{ + HTTPStatus: status, Code: "upstream_error", PublicMessage: "上游服务返回错误", + UpstreamCode: upstreamCode, AccountID: accountID, AccountName: accountName, + } + if status < 400 || status > 599 { + failure.HTTPStatus = http.StatusBadGateway + } + metadataText := strings.ToLower(strings.Join([]string{upstreamCode, upstreamType, upstreamMessage}, " ")) + switch status { + case http.StatusUnauthorized: + failure.Code = "upstream_unauthorized" + failure.PublicMessage = "上游账号认证失败" + failure.AccountScoped = true + failure.CredentialRejected = true + failure.AccountBlocked = isDefinitiveAccountBlock(metadataText) + case http.StatusPaymentRequired: + failure.Code = "upstream_payment_required" + failure.PublicMessage = "上游账号额度不足" + failure.AccountScoped = true + failure.QuotaExhausted = true + // spending-limit is account-scoped, but its paid/free recovery kind depends on + // the selected account's billing snapshot and must be decided by the selector. + failure.FreeQuotaExhausted = isFreeQuotaExhaustion(metadataText) + case http.StatusForbidden: + failure.Code = "upstream_forbidden" + failure.PublicMessage = "上游拒绝了该请求" + // Safety denials are request-scoped: inspect both structured metadata and the raw body + // so SAFETY_CHECK_TYPE_* markers still match when they only appear in nested text. + if isSafetyRejection(metadataText) || isSafetyRejection(string(body)) { + failure.SafetyRejection = true + break + } + failure.AccountBlocked = isDefinitiveAccountBlock(metadataText) + failure.PermanentAccountDenial = isPermanentAccountDenial(upstreamMessage) + failure.ModelQuotaExhausted = isModelQuotaExhaustion(metadataText) + failure.FreeQuotaExhausted = failure.ModelQuotaExhausted || isFreeQuotaExhaustion(metadataText) + failure.QuotaExhausted = failure.FreeQuotaExhausted || isPaidQuotaExhaustion(metadataText) + failure.CredentialRejected = !failure.QuotaExhausted && containsAny(metadataText, "authentication", "unauthorized", "invalid token", "token expired") + failure.AccountScoped = failure.AccountBlocked || failure.PermanentAccountDenial || failure.QuotaExhausted || failure.CredentialRejected || isAccountScopedForbidden(metadataText) + case http.StatusTooManyRequests: + failure.Code = "upstream_rate_limited" + failure.PublicMessage = "上游请求频率受限" + failure.AccountScoped = true + // Subscription-level free usage and explicit per-model free usage are + // distinct so the gateway can preserve their different recovery scopes. + failure.FreeQuotaExhausted = isFreeQuotaExhaustion(metadataText) + failure.ModelQuotaExhausted = isModelQuotaExhaustion(metadataText) + failure.QuotaExhausted = failure.FreeQuotaExhausted || isPaidQuotaExhaustion(metadataText) + default: + failure.Code = "upstream_server_error" + failure.PublicMessage = "上游服务暂时异常" + } + fingerprintPart := normalizeFailureCode(firstNonEmptyFailure(upstreamCode, upstreamType, upstreamMessage)) + if fingerprintPart == "" { + fingerprintPart = "unknown" + } + failure.Fingerprint = fmt.Sprintf("%d:%s", status, fingerprintPart) + return failure +} + +func newTransportUpstreamFailure(err error, accountID uint64, accountName string) *UpstreamFailure { + code, message := "upstream_network_error", "连接上游服务失败" + status := http.StatusBadGateway + if neterrorpkg.IsResponseHeaderTimeout(err) { + status, code, message = http.StatusGatewayTimeout, "upstream_header_timeout", "等待上游响应头超时" + } else if errors.Is(err, context.DeadlineExceeded) { + code, message = "upstream_timeout", "上游服务响应超时" + } + return &UpstreamFailure{ + HTTPStatus: status, Code: code, PublicMessage: message, + AccountID: accountID, AccountName: accountName, Fingerprint: code, Cause: err, + } +} + +func newCredentialUpstreamFailure(err error, accountID uint64, accountName string) *UpstreamFailure { + return &UpstreamFailure{ + HTTPStatus: http.StatusBadGateway, Code: "upstream_credential_unavailable", PublicMessage: "上游账号凭据不可用", + AccountID: accountID, AccountName: accountName, AccountScoped: true, Cause: err, + } +} + +func extractUpstreamErrorMetadata(body []byte) (string, string, string) { + if len(body) == 0 { + return "", "", "" + } + var payload any + if json.Unmarshal(body, &payload) != nil { + return "", "", strings.TrimSpace(string(body)) + } + root, ok := payload.(map[string]any) + if !ok { + return "", "", "" + } + if nested, ok := root["error"].(map[string]any); ok { + code := firstNonEmptyFailure(firstStringValue(nested, "code", "error_code"), firstStringValue(root, "code", "error_code")) + errorType := firstNonEmptyFailure(firstStringValue(nested, "type", "error_type"), firstStringValue(root, "type", "error_type")) + message := firstNonEmptyFailure(firstStringValue(nested, "message", "error"), firstStringValue(root, "message")) + return code, errorType, message + } + message := firstNonEmptyFailure(firstStringValue(root, "error"), firstStringValue(root, "message")) + return firstStringValue(root, "code", "error_code"), firstStringValue(root, "type", "error_type"), message +} + +func isAccountScopedForbidden(text string) bool { + // Do not match bare "permission" / permission-denied alone: those codes are shared by + // request-level policy denials. Account scope requires quota/billing/auth wording. + return containsAny(text, "quota", "billing", "subscription", "entitlement", "unauthorized", "authentication", "invalid token", "token expired", "usage-exhausted", "insufficient", "spending-limit") +} + +// isPermanentAccountDenial requires explicit account/model permission text. +// A bare permission-denied code without access-denied wording is not enough: +// content safety rejections and other policy 403s share that code. +func isPermanentAccountDenial(text string) bool { + text = strings.ToLower(strings.Trim(strings.TrimSpace(text), " .!\t\r\n")) + return strings.Contains(text, "access to the chat endpoint is denied") || text == "access denied" +} + +// isSafetyRejection identifies request-level content safety denials that must +// be returned to the client without account rotation or invalidation. +func isSafetyRejection(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + return strings.Contains(lower, "content violates usage guidelines") || + strings.Contains(lower, "safety_check_type_") +} + +func isDefinitiveAccountBlock(text string) bool { + return provider.IsDefinitiveAccountBlockText(text) +} + +func isPaidQuotaExhaustion(text string) bool { + return strings.Contains(text, "personal-team-blocked:spending-limit") +} + +func isFreeQuotaExhaustion(text string) bool { + return containsAny(text, "subscription:free-usage-exhausted", "used all the included free usage for model") +} + +func isModelQuotaExhaustion(text string) bool { + return strings.Contains(text, "used all the included free usage for model") +} + +func containsAny(text string, signals ...string) bool { + for _, signal := range signals { + if strings.Contains(text, signal) { + return true + } + } + return false +} + +func firstStringValue(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key].(string); ok { + return strings.TrimSpace(value) + } + } + return "" +} + +func firstNonEmptyFailure(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func normalizeFailureCode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var builder strings.Builder + for _, current := range value { + switch { + case unicode.IsLetter(current), unicode.IsDigit(current): + builder.WriteRune(current) + case current == '-', current == '_', current == '.', current == ':': + builder.WriteByte('_') + } + if builder.Len() >= 48 { + break + } + } + return strings.Trim(builder.String(), "_") +} + +func truncateFailureCode(value string) string { + if len(value) <= 100 { + return value + } + return value[:100] +} diff --git a/backend/internal/application/gateway/failure_test.go b/backend/internal/application/gateway/failure_test.go new file mode 100644 index 0000000000000000000000000000000000000000..28db2e75005ab68b53b6f4058d8831da74ce1d9e --- /dev/null +++ b/backend/internal/application/gateway/failure_test.go @@ -0,0 +1,279 @@ +package gateway + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +type responseHeaderTimeoutTestError struct{} + +func (responseHeaderTimeoutTestError) Error() string { + return "http2: timeout awaiting response headers" +} +func (responseHeaderTimeoutTestError) Timeout() bool { return true } +func (responseHeaderTimeoutTestError) Temporary() bool { return true } + +func TestTransportUpstreamFailureClassifiesResponseHeaderTimeout(t *testing.T) { + failure := newTransportUpstreamFailure(responseHeaderTimeoutTestError{}, 42, "build") + if failure.HTTPStatus != http.StatusGatewayTimeout || failure.Code != "upstream_header_timeout" || failure.PublicMessage != "等待上游响应头超时" || failure.AuditCode() != "upstream_header_timeout" { + t.Fatalf("failure = %#v", failure) + } + if stage := transportStage(responseHeaderTimeoutTestError{}); stage != "response_header_timeout" { + t.Fatalf("stage = %q", stage) + } + if isRetryableTransportFailure(accountdomain.ProviderBuild, responseHeaderTimeoutTestError{}) { + t.Fatal("a Build response-header timeout must not switch accounts") + } + if !isRetryableTransportFailure(accountdomain.ProviderWeb, responseHeaderTimeoutTestError{}) { + t.Fatal("the Build-specific retry veto must not change Web failover") + } + if !isRetryableTransportFailure(accountdomain.ProviderBuild, errors.New("connection reset by peer")) { + t.Fatal("ordinary pre-response transport failures must retain failover behavior") + } +} + +func TestHTTPUpstreamFailureClassifiesBuildForbiddenBodies(t *testing.T) { + tests := []struct { + name string + body string + accountScoped bool + permanentAccountDenial bool + safetyRejection bool + quotaExhausted bool + freeQuotaExhausted bool + modelQuotaExhausted bool + accountBlocked bool + upstreamCode string + }{ + { + name: "blocked account", body: `{"code":"unauthorized:blocked-user","error":"User is blocked"}`, + accountScoped: true, accountBlocked: true, upstreamCode: "unauthorized:blocked-user", + }, + { + // Web chat nested error; numeric code alone must not decide AccountBlocked. + name: "web nested blocked-user message", body: `{"error":{"code":7,"message":"User is blocked [WKE=unauthorized:blocked-user]","details":[]}}`, + accountScoped: true, accountBlocked: true, upstreamCode: "", // numeric JSON code is not stringified by extractors + }, + { + name: "forbidden without block text", body: `{"error":{"code":7,"message":"Something went wrong","details":[]}}`, + }, + { + name: "top-level permanent chat denial", body: `{"status_code":403,"code":"permission-denied","error":"Access to the chat endpoint is denied. Please update the permissions."}`, + accountScoped: true, permanentAccountDenial: true, upstreamCode: "permission-denied", + }, + { + name: "spending limit", body: `{"code":"personal-team-blocked:spending-limit","error":"quota exhausted"}`, + accountScoped: true, quotaExhausted: true, upstreamCode: "personal-team-blocked:spending-limit", + }, + { + name: "unknown policy rejection", body: `{"error":"upstream policy rejected request"}`, + }, + { + name: "free model quota", body: `{"error":"You've used all the included free usage for model grok-build"}`, + accountScoped: true, quotaExhausted: true, freeQuotaExhausted: true, modelQuotaExhausted: true, + }, + { + name: "safety usage guidelines", body: `{"code":"permission-denied","error":"Content violates usage guidelines. SAFETY_CHECK_TYPE_VIOLENCE"}`, + safetyRejection: true, upstreamCode: "permission-denied", + }, + { + name: "bare permission-denied without access text", body: `{"code":"permission-denied","error":"request rejected by policy"}`, + // Unknown request-level denial: not permanent, not account-scoped punishment. + upstreamCode: "permission-denied", + }, + { + name: "request-level access denied sentence", body: `{"code":"operation-denied","error":"Access denied because this operation is unavailable under ZDR"}`, + upstreamCode: "operation-denied", + }, + { + name: "exact access denied", body: `{"code":"operation-denied","error":"Access denied."}`, + accountScoped: true, permanentAccountDenial: true, upstreamCode: "operation-denied", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + failure := newHTTPUpstreamFailure(http.StatusForbidden, []byte(test.body), 42, "build") + if failure.HTTPStatus != http.StatusForbidden || failure.Code != "upstream_forbidden" || failure.AccountScoped != test.accountScoped || failure.AccountBlocked != test.accountBlocked || failure.PermanentAccountDenial != test.permanentAccountDenial || failure.SafetyRejection != test.safetyRejection || failure.QuotaExhausted != test.quotaExhausted || failure.FreeQuotaExhausted != test.freeQuotaExhausted || failure.ModelQuotaExhausted != test.modelQuotaExhausted || failure.UpstreamCode != test.upstreamCode { + t.Fatalf("failure = %#v", failure) + } + if test.upstreamCode == "permission-denied" && (failure.ClientCredentialErrorCode() != "permission-denied" || failure.AuditCode() != "upstream_forbidden_permission_denied") { + t.Fatalf("public=%q audit=%q", failure.ClientCredentialErrorCode(), failure.AuditCode()) + } + }) + } +} + +func TestHTTPUpstreamFailureLeavesPaymentRecoveryKindToBilling(t *testing.T) { + failure := newHTTPUpstreamFailure(http.StatusPaymentRequired, []byte(`{ + "code":"personal-team-blocked:spending-limit", + "error":"You have run out of credits" + }`), 42, "build") + if !failure.AccountScoped || !failure.QuotaExhausted || failure.FreeQuotaExhausted || failure.UpstreamCode != "personal-team-blocked:spending-limit" { + t.Fatalf("failure = %#v", failure) + } +} + +func TestRetryableResponseHonorsUpstreamRetryVeto(t *testing.T) { + response := &provider.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{"X-Should-Retry": {"false"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"invalid request history"}`)), + } + if isRetryableResponse(response, accountdomain.ProviderBuild) { + t.Fatal("x-should-retry:false 必须禁止换账号重试") + } + response.Header.Set("X-Should-Retry", "true") + if !isRetryableResponse(response, accountdomain.ProviderBuild) { + t.Fatal("x-should-retry:true 不应覆盖现有状态码重试策略") + } + response.Header.Set("X-Should-Retry", "unknown") + if !isRetryableResponse(response, accountdomain.ProviderBuild) { + t.Fatal("未知 x-should-retry 值应按未提供处理") + } +} + +func TestPaymentRequiredAlwaysRetriesDespiteUpstreamVeto(t *testing.T) { + response := &provider.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{"X-Should-Retry": {"false"}}, + Body: io.NopCloser(strings.NewReader(`{"code":"personal-team-blocked:spending-limit","error":"You have run out of credits"}`)), + } + if !isRetryableResponse(response, accountdomain.ProviderBuild) { + t.Fatal("402 spending-limit must force account rotation even when X-Should-Retry is false") + } + if isRetryableResponse(response, accountdomain.ProviderWeb) { + t.Fatal("non-Build 402 must continue honoring X-Should-Retry:false") + } +} + +func TestBuildForbiddenAlwaysEntersAccountFailureHandling(t *testing.T) { + response := &provider.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"X-Should-Retry": {"false"}}, + Body: io.NopCloser(strings.NewReader(`{"code":"permission-denied"}`)), + } + if !isRetryableResponse(response, accountdomain.ProviderBuild) { + t.Fatal("Build 403 must enter account failure handling even when X-Should-Retry is false") + } + if isRetryableResponse(response, accountdomain.ProviderWeb) { + t.Fatal("non-Build 403 must continue honoring X-Should-Retry:false") + } +} + +func TestBuildForbiddenReauthPolicyMatchesExactErrorCodes(t *testing.T) { + service := &Service{} + service.UpdateBuildForbiddenReauthPolicy(true, []string{"permission-denied", "team-access-denied"}) + + for _, code := range []string{"permission-denied", "TEAM-ACCESS-DENIED"} { + failure := &UpstreamFailure{HTTPStatus: http.StatusForbidden, UpstreamCode: code} + if !service.shouldInvalidateBuildForbidden(failure) { + t.Fatalf("configured code %q did not match", code) + } + } + for _, failure := range []*UpstreamFailure{ + {HTTPStatus: http.StatusForbidden, UpstreamCode: "permission_denied"}, + {HTTPStatus: http.StatusForbidden, UpstreamCode: "unconfigured-denial"}, + {HTTPStatus: http.StatusUnauthorized, UpstreamCode: "permission-denied"}, + {HTTPStatus: http.StatusInternalServerError, UpstreamCode: "permission-denied"}, + } { + if service.shouldInvalidateBuildForbidden(failure) { + t.Fatalf("unconfigured or ineligible failure matched: %#v", failure) + } + } + + service.UpdateBuildForbiddenReauthPolicy(false, []string{"permission-denied"}) + if service.shouldInvalidateBuildForbidden(&UpstreamFailure{HTTPStatus: http.StatusForbidden, UpstreamCode: "permission-denied"}) { + t.Fatal("disabled policy matched an error code") + } +} + +func TestHTTPUpstreamFailureClassifiesSubscriptionFreeUsageAsAccountQuota(t *testing.T) { + body := `{"code":"subscription:free-usage-exhausted","error":"tokens (actual/limit): 10/10"}` + failure := newHTTPUpstreamFailure(http.StatusTooManyRequests, []byte(body), 7, "build") + if !failure.AccountScoped || !failure.FreeQuotaExhausted || failure.ModelQuotaExhausted || !failure.QuotaExhausted { + t.Fatalf("failure = %#v", failure) + } +} + +func TestHTTPUpstreamFailureKeepsExplicitModelFreeUsageScoped(t *testing.T) { + body := `{"error":"You've used all the included free usage for model grok-build"}` + failure := newHTTPUpstreamFailure(http.StatusTooManyRequests, []byte(body), 7, "build") + if !failure.AccountScoped || !failure.FreeQuotaExhausted || !failure.ModelQuotaExhausted || !failure.QuotaExhausted { + t.Fatalf("failure = %#v", failure) + } +} + +func TestBuildRateLimitForcesAccountFailoverDespiteRetryVeto(t *testing.T) { + response := &provider.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"X-Should-Retry": {"false"}}, + Body: io.NopCloser(strings.NewReader(`{"code":"subscription:free-usage-exhausted"}`)), + } + if !isRetryableResponse(response, accountdomain.ProviderBuild) { + t.Fatal("Build free-usage 429 must force account rotation even when X-Should-Retry is false") + } + if isRetryableResponse(response, accountdomain.ProviderWeb) { + t.Fatal("non-Build 429 must continue honoring X-Should-Retry:false") + } +} + +func TestBuildForbiddenReauthPolicyIgnoresSafetyRejection(t *testing.T) { + service := &Service{} + service.UpdateBuildForbiddenReauthPolicy(true, []string{"permission-denied"}) + failure := &UpstreamFailure{HTTPStatus: http.StatusForbidden, UpstreamCode: "permission-denied", SafetyRejection: true} + if service.shouldInvalidateBuildForbidden(failure) { + t.Fatal("safety rejection must not match permission-denied invalidation policy") + } +} + +func TestTerminalRequestForbiddenScopesBarePermissionDeniedToBuild(t *testing.T) { + bare := &UpstreamFailure{HTTPStatus: http.StatusForbidden, UpstreamCode: "permission-denied"} + if !isTerminalRequestForbidden(accountdomain.ProviderBuild, bare) { + t.Fatal("Build bare permission-denied must remain a terminal request failure") + } + for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} { + if isTerminalRequestForbidden(providerValue, bare) { + t.Fatalf("%s bare permission-denied must retain egress recovery", providerValue) + } + } + + safety := &UpstreamFailure{HTTPStatus: http.StatusForbidden, UpstreamCode: "permission-denied", SafetyRejection: true} + for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderWeb, accountdomain.ProviderConsole} { + if !isTerminalRequestForbidden(providerValue, safety) { + t.Fatalf("%s safety rejection must remain terminal", providerValue) + } + } +} + +func TestUnclassifiedFreeBuildForbiddenDoesNotOverrideKnownFailures(t *testing.T) { + credential := accountdomain.Credential{Provider: accountdomain.ProviderBuild} + unknown := &UpstreamFailure{HTTPStatus: http.StatusForbidden} + if !isUnclassifiedFreeBuildForbidden(http.StatusForbidden, credential, nil, unknown, false) { + t.Fatal("unknown Free Build 403 must retain the short cooldown fallback") + } + known := []*UpstreamFailure{ + {HTTPStatus: http.StatusForbidden, AccountScoped: true, PermanentAccountDenial: true}, + {HTTPStatus: http.StatusForbidden, AccountScoped: true, QuotaExhausted: true}, + {HTTPStatus: http.StatusForbidden, AccountScoped: true, CredentialRejected: true}, + {HTTPStatus: http.StatusForbidden, AccountScoped: true, AccountBlocked: true}, + {HTTPStatus: http.StatusForbidden, SafetyRejection: true}, + {HTTPStatus: http.StatusForbidden, UpstreamCode: "permission-denied"}, + } + for _, failure := range known { + if isUnclassifiedFreeBuildForbidden(http.StatusForbidden, credential, nil, failure, false) { + t.Fatalf("known failure was flattened into generic Free 403 handling: %#v", failure) + } + } + if isUnclassifiedFreeBuildForbidden(http.StatusForbidden, credential, nil, unknown, true) { + t.Fatal("configured invalidation must take precedence over generic Free 403 handling") + } + if isUnclassifiedFreeBuildForbidden(http.StatusForbidden, accountdomain.Credential{Provider: accountdomain.ProviderBuild, BuildSuperEntitled: true}, nil, unknown, false) { + t.Fatal("Super Build 403 must not enter the Free fallback") + } +} diff --git a/backend/internal/application/gateway/finalization.go b/backend/internal/application/gateway/finalization.go new file mode 100644 index 0000000000000000000000000000000000000000..60ffcecc26dacc959e5dde79388672b3f7109c44 --- /dev/null +++ b/backend/internal/application/gateway/finalization.go @@ -0,0 +1,49 @@ +package gateway + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" +) + +const ( + finalizationHealthBudget = 750 * time.Millisecond + finalizationOwnershipBudget = 1500 * time.Millisecond + finalizationQuotaBudget = time.Second + finalizationAuditBudget = 3 * time.Second + finalizationMetadataBudget = 500 * time.Millisecond +) + +type finalizationBudget struct { + deadline time.Time + operation string + provider string +} + +func newFinalizationBudget(operation, provider string) finalizationBudget { + return finalizationBudget{ + deadline: time.Now().Add(finalizationTimeout), + operation: operation, + provider: provider, + } +} + +func (b finalizationBudget) run(stage string, limit time.Duration, action func(context.Context) error) error { + started := time.Now() + deadline := b.deadline + if candidate := started.Add(limit); candidate.Before(deadline) { + deadline = candidate + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + err := action(ctx) + cancel() + outcome := "success" + if err != nil { + outcome = "failed" + } + perfmetrics.Default.ObserveDuration("finalization_stage_duration_us", perfmetrics.Labels{ + Subsystem: "gateway", Operation: b.operation, Provider: b.provider, Stage: stage, Outcome: outcome, + }, time.Since(started)) + return err +} diff --git a/backend/internal/application/gateway/image.go b/backend/internal/application/gateway/image.go new file mode 100644 index 0000000000000000000000000000000000000000..f70e619bc44598af35d5e3e056d1f63dcbafdcb4 --- /dev/null +++ b/backend/internal/application/gateway/image.go @@ -0,0 +1,315 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +// ImageGenerationInput 表示图片生成用例已经完成协议校验后的输入。 +type ImageGenerationInput struct { + RequestID string + ClientKey clientkey.Key + PublicModel string + Prompt string + Count int + Size string + AspectRatio string + Resolution string + ResponseFormat string + Streaming bool + PartialImages int +} + +// ImageEditInput 表示图片编辑用例已经完成协议校验后的输入。 +type ImageEditInput struct { + RequestID string + ClientKey clientkey.Key + PublicModel string + Prompt string + ImageURLs []string + Count int + Size string + AspectRatio string + Resolution string + ResponseFormat string + Streaming bool + PartialImages int +} + +type imageProviderSupport func(accountdomain.Provider) bool + +type imageExecution func(context.Context, accountdomain.Provider, accountdomain.Credential, string) (*provider.Response, error) + +// GenerateImage 选择支持图片生成的路由和账号,并返回可统一审计的上游响应。 +func (s *Service) GenerateImage(ctx context.Context, input ImageGenerationInput) (*Result, error) { + return s.executeImage(ctx, input.RequestID, input.ClientKey, input.PublicModel, audit.OperationImage, modeldomain.CapabilityImage, func(providerValue accountdomain.Provider) bool { + _, ok := s.providers.ImageGeneration(providerValue) + return ok + }, func(executionCtx context.Context, providerValue accountdomain.Provider, credential accountdomain.Credential, upstream string) (*provider.Response, error) { + adapter, ok := s.providers.ImageGeneration(providerValue) + if !ok { + return nil, ErrNoAvailableAccount + } + return adapter.GenerateImage(executionCtx, provider.ImageGenerationRequest{ + Credential: credential, Model: upstream, Prompt: input.Prompt, Count: input.Count, + Size: input.Size, AspectRatio: input.AspectRatio, Resolution: input.Resolution, + ResponseFormat: input.ResponseFormat, Streaming: input.Streaming, PartialImages: input.PartialImages, + }) + }, input.Streaming, input.Resolution, input.Count, 0) +} + +// EditImage 选择支持图片编辑的路由和账号,并返回可统一审计的上游响应。 +func (s *Service) EditImage(ctx context.Context, input ImageEditInput) (*Result, error) { + return s.executeImage(ctx, input.RequestID, input.ClientKey, input.PublicModel, audit.OperationImageEdit, modeldomain.CapabilityImageEdit, func(providerValue accountdomain.Provider) bool { + _, ok := s.providers.ImageEdit(providerValue) + return ok + }, func(executionCtx context.Context, providerValue accountdomain.Provider, credential accountdomain.Credential, upstream string) (*provider.Response, error) { + adapter, ok := s.providers.ImageEdit(providerValue) + if !ok { + return nil, ErrNoAvailableAccount + } + return adapter.EditImage(executionCtx, provider.ImageEditRequest{ + Credential: credential, Model: upstream, Prompt: input.Prompt, + ImageURLs: input.ImageURLs, Count: input.Count, Size: input.Size, AspectRatio: input.AspectRatio, + Resolution: input.Resolution, ResponseFormat: input.ResponseFormat, + Streaming: input.Streaming, PartialImages: input.PartialImages, + }) + }, input.Streaming, input.Resolution, input.Count, len(input.ImageURLs)) +} + +func (s *Service) executeImage( + ctx context.Context, + requestID string, + key clientkey.Key, + publicModel string, + operation audit.Operation, + capability modeldomain.Capability, + supports imageProviderSupport, + execute imageExecution, + streaming bool, + resolution string, + requestedCount int, + inputImageCount int, +) (*Result, error) { + ctx, egressTrace := infraegress.WithTrace(ctx) + startedAt := time.Now() + eventID := newAuditEventID() + routes, err := s.models.GetByPublicIDCandidates(ctx, publicModel) + if err != nil { + return nil, ErrModelNotFound + } + route, err := s.selectMediaRoute(routes, key, capability, supports) + if err != nil { + return nil, err + } + externalModel := modeldomain.ExternalPublicID(route.Provider, route.PublicID) + auditBase := audit.Record{ + EventID: eventID, RequestID: requestID, ClientKeyID: key.ID, ClientKeyName: key.Name, + ModelRouteID: route.ID, ModelPublicID: externalModel, ModelUpstreamModel: modeldomain.DisplayUpstreamModel(route.Provider, route.UpstreamModel), + Provider: string(route.Provider), Operation: operation, UsageSource: audit.UsageSourceNone, Streaming: streaming, + } + if operation == audit.OperationImageEdit { + auditBase.MediaInputImages = int64(max(0, inputImageCount)) + } + if err := s.checkLedgerReady(); err != nil { + return nil, err + } + writeFailureAudit := func(statusCode int, errorCode string, credential *accountdomain.Credential) { + record := auditBase + record.StatusCode = statusCode + record.ErrorCode = errorCode + record.DurationMS = time.Since(startedAt).Milliseconds() + record.CreatedAt = time.Now().UTC() + if credential != nil { + accountID := credential.ID + record.AccountID = &accountID + record.AccountName = credential.Name + } + applyAuditEgress(&record, egressTrace, route.Provider) + persistCtx, cancel := context.WithTimeout(context.Background(), finalizationTimeout) + defer cancel() + if auditErr := s.audits.Create(persistCtx, record); auditErr != nil { + s.logger.Error("request_usage_write_failed", "event_id", record.EventID, "request_id", requestID, "error", auditErr) + } + } + pricingModel := s.providers.PricingModel(route.Provider, route.UpstreamModel) + var reservation audit.PricingResult + var priced bool + switch operation { + case audit.OperationImage: + reservation, priced = audit.EstimateOfficialImageCost(pricingModel, resolution, requestedCount) + case audit.OperationImageEdit: + reservation, priced = audit.EstimateOfficialImageEditCost(pricingModel, resolution, requestedCount, inputImageCount) + } + reserved := false + if priced { + reserved, err = s.clientKeys.ReserveBilling(ctx, key, eventID, reservation.CostInUSDTicks, mediaBillingReservationTTL) + if err != nil { + return nil, err + } + } + finalizationOwnsReservation := false + defer func() { + if reserved && !finalizationOwnsReservation { + s.cancelBillingReservation(eventID) + } + }() + quotaMode := s.providers.QuotaMode(route.Provider, route.UpstreamModel) + attemptPolicy := newRoutingAttemptPolicy(int(s.maxAttempts.Load())) + excluded := make(map[uint64]bool) + var lease *accountLease + var credential accountdomain.Credential + var response *provider.Response + var lastCredentialFailure *accountdomain.Credential + var lastCredentialError error + for attempt := 0; attemptPolicy.allows(attempt); attempt++ { + lease, err = s.selector.AcquireForKey(ctx, route.Provider, route.ID, route.UpstreamModel, quotaMode, "", excluded, false, key.AccountScope()) + if err != nil { + errorCode := "upstream_unavailable" + var selectionFailure *SelectionUnavailableError + if errors.As(err, &selectionFailure) { + errorCode = selectionFailure.Code() + } + writeFailureAudit(http.StatusServiceUnavailable, errorCode, lastCredentialFailure) + return nil, fmt.Errorf("%w: %w", ErrNoAvailableAccount, err) + } + excluded[lease.Credential.ID] = true + credential, err = s.accounts.EnsureCredential(ctx, lease.Credential, false) + if err != nil { + s.logger.Error("image_credential_failed", "event_id", eventID, "request_id", requestID, "model", externalModel, "provider", route.Provider, "account_id", lease.Credential.ID, "error", err) + failedCredential := lease.Credential + lastCredentialFailure = &failedCredential + lastCredentialError = err + lease.Release() + continue + } + lease.markSelectorUpstreamStarted() + response, err = execute(ctx, route.Provider, credential, route.UpstreamModel) + if err != nil { + s.logger.Error("image_upstream_failed", "event_id", eventID, "request_id", requestID, "model", externalModel, "provider", route.Provider, "account_id", credential.ID, "error", err) + if isSSOCredentialRejected(err, credential) { + s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider)) + failedCredential := credential + lastCredentialFailure = &failedCredential + lastCredentialError = provider.ErrUnauthorized + lease.Release() + continue + } + if !provider.IsMediaPostProcessingError(err) { + s.selector.MarkFailure(ctx, credential, 0, 0) + } + lease.Release() + errorCode := "upstream_unavailable" + if provider.IsMediaPostProcessingError(err) { + errorCode = "media_postprocessing_failed" + } + writeFailureAudit(http.StatusBadGateway, errorCode, &credential) + return nil, err + } + if response.StatusCode == http.StatusUnauthorized && credential.AuthType == accountdomain.AuthTypeSSO { + _, _ = readRetryableBody(response.Body) + s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider)) + failedCredential := credential + lastCredentialFailure = &failedCredential + lastCredentialError = provider.ErrUnauthorized + response = nil + lease.Release() + continue + } + if s.providers.RetryForbiddenAsEgress(credential.Provider) && response.StatusCode == http.StatusForbidden && attempt == 0 && attemptPolicy.hasNext(attempt) { + _, _ = readRetryableBody(response.Body) + lease.Release() + delete(excluded, credential.ID) + continue + } + if quotaKind, _ := s.providers.QuotaKind(credential.Provider); quotaKind == provider.QuotaRemoteWindow && response.StatusCode == http.StatusTooManyRequests && lease.QuotaMode != "" { + retryAfter := parseRetryAfter(response.Header.Get("Retry-After"), time.Now().UTC()) + exhausted, reconcileErr := s.accounts.ReconcileWebRateLimit(ctx, credential.ID, lease.QuotaMode, retryAfter) + s.selector.MarkQuotaStateChanged(credential.Provider) + if reconcileErr != nil || !exhausted { + s.selector.MarkFailure(ctx, credential, response.StatusCode, retryAfter) + } + if attemptPolicy.hasNext(attempt) { + _, _ = readRetryableBody(response.Body) + lease.Release() + continue + } + } + break + } + if response == nil { + writeFailureAudit(http.StatusServiceUnavailable, "upstream_unavailable", lastCredentialFailure) + if lastCredentialError == nil { + lastCredentialError = ErrNoAvailableAccount + } + return nil, fmt.Errorf("%w: %w", ErrNoAvailableAccount, lastCredentialError) + } + effectiveQuotaMode := lease.QuotaMode + accountID := credential.ID + var once sync.Once + finalize := func(_ Usage, _ string, errorCode string) { + once.Do(func() { + successful := response.StatusCode >= 200 && response.StatusCode < 300 && errorCode == "" + lease.completeSelectorObservation(successful) + lease.Release() + budget := newFinalizationBudget(string(operation), string(route.Provider)) + record := auditBase + record.AccountID, record.AccountName, record.StatusCode = &accountID, credential.Name, response.StatusCode + record.ErrorCode = errorCode + record.DurationMS, record.CreatedAt = time.Since(startedAt).Milliseconds(), time.Now().UTC() + applyAuditEgress(&record, egressTrace, route.Provider) + if successful { + record.MediaOutputImages = int64(max(0, requestedCount)) + var pricing audit.PricingResult + var priced bool + switch operation { + case audit.OperationImage: + pricing, priced = audit.EstimateOfficialImageCost(pricingModel, resolution, requestedCount) + case audit.OperationImageEdit: + pricing, priced = audit.EstimateOfficialImageEditCost(pricingModel, resolution, requestedCount, inputImageCount) + } + if priced { + record.EstimatedCostInUSDTicks = pricing.CostInUSDTicks + record.PricingModel = pricing.Model + record.PricingVersion = audit.OfficialPricingAsOf + } + } + quotaKind, _ := s.providers.QuotaKind(route.Provider) + if successful && quotaKind == provider.QuotaRemoteWindow && effectiveQuotaMode != "" { + if effectiveQuotaMode != "weekly" { + units := max(1, response.QuotaUnits) + var updated bool + err := budget.run("quota_decrement", finalizationQuotaBudget, func(stageCtx context.Context) error { + var decrementErr error + updated, decrementErr = s.accounts.DecrementWebQuota(stageCtx, accountID, effectiveQuotaMode, units) + return decrementErr + }) + if err != nil { + s.logger.Warn("web_quota_decrement_failed", "account_id", accountID, "mode", effectiveQuotaMode, "units", units, "error", err) + } else if updated { + s.selector.ConsumeQuota(route.Provider, accountID, effectiveQuotaMode, units) + } + } + s.accounts.QueueQuotaRefresh(accountID, effectiveQuotaMode) + } + if err := budget.run("audit", finalizationAuditBudget, func(stageCtx context.Context) error { + return s.audits.Create(stageCtx, record) + }); err != nil { + s.logger.Error("request_usage_write_failed", "event_id", record.EventID, "request_id", requestID, "error", err) + } + }) + } + finalizationOwnsReservation = true + return &Result{StatusCode: response.StatusCode, Status: response.Status, Header: response.Header, Body: &finalizingBody{ReadCloser: response.Body, finalize: func() { finalize(Usage{}, "", "stream_closed") }}, Finalize: finalize}, nil +} diff --git a/backend/internal/application/gateway/model_alias_test.go b/backend/internal/application/gateway/model_alias_test.go new file mode 100644 index 0000000000000000000000000000000000000000..34be339d72d238f8760c6d1388229cfb50bc8c8e --- /dev/null +++ b/backend/internal/application/gateway/model_alias_test.go @@ -0,0 +1,199 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/console" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestRewriteAliasedModelAppliesOperationEffort(t *testing.T) { + publicModel := "grok-4.3" + tests := []struct { + name string + operation audit.Operation + assert func(*testing.T, map[string]any) + }{ + {name: "responses", operation: audit.OperationResponses, assert: func(t *testing.T, payload map[string]any) { + reasoning, _ := payload["reasoning"].(map[string]any) + if reasoning["effort"] != "high" { + t.Fatalf("reasoning = %#v", reasoning) + } + }}, + {name: "chat", operation: audit.OperationChat, assert: func(t *testing.T, payload map[string]any) { + if payload["reasoning_effort"] != "high" { + t.Fatalf("reasoning_effort = %#v", payload["reasoning_effort"]) + } + }}, + {name: "messages", operation: audit.OperationMessages, assert: func(t *testing.T, payload map[string]any) { + config, _ := payload["output_config"].(map[string]any) + if config["effort"] != "high" { + t.Fatalf("output_config = %#v", config) + } + thinking, _ := payload["thinking"].(map[string]any) + if thinking["type"] != "adaptive" { + t.Fatalf("thinking = %#v", thinking) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, err := rewriteAliasedModel([]byte(`{"model":"grok-4.3-high"}`), publicModel, "high", test.operation) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload["model"] != publicModel { + t.Fatalf("model = %#v", payload["model"]) + } + test.assert(t, payload) + }) + } +} + +func TestRewriteAliasedMessagesPinsAndDisablesReasoningEndToEnd(t *testing.T) { + request := []byte(`{ + "model":"grok-4.3-high", + "max_tokens":64, + "messages":[{"role":"user","content":"hello"}], + "thinking":{"type":"disabled"}, + "output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}} + }`) + rewritten, err := rewriteAliasedModel(request, "grok-4.3", "high", audit.OperationMessages) + if err != nil { + t.Fatal(err) + } + converted, options, err := conversation.ConvertRequestWithOptions(rewritten, "grok-4.3", conversation.OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + reasoning, _ := payload["reasoning"].(map[string]any) + if reasoning["effort"] != "high" || !options.AnthropicThinking { + t.Fatalf("reasoning = %#v, options = %#v", reasoning, options) + } + text, _ := payload["text"].(map[string]any) + if text["format"] == nil { + t.Fatalf("output format was not preserved: %#v", payload) + } + + rewritten, err = rewriteAliasedModel(rewritten, "grok-4.3", "none", audit.OperationMessages) + if err != nil { + t.Fatal(err) + } + converted, options, err = conversation.ConvertRequestWithOptions(rewritten, "grok-4.3", conversation.OperationMessages) + if err != nil { + t.Fatal(err) + } + payload = nil + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if payload["reasoning"] != nil || options.AnthropicThinking { + t.Fatalf("reasoning should be disabled: payload = %#v, options = %#v", payload, options) + } + text, _ = payload["text"].(map[string]any) + if text["format"] == nil { + t.Fatalf("output format was not preserved after disabling reasoning: %#v", payload) + } +} + +type aliasRouteResolver struct { + byPublic map[string][]modeldomain.Route + byUp map[string]modeldomain.Route +} + +func (r *aliasRouteResolver) Get(context.Context, uint64) (modeldomain.Route, error) { + return modeldomain.Route{}, repository.ErrNotFound +} +func (r *aliasRouteResolver) GetByPublicID(context.Context, string) (modeldomain.Route, error) { + return modeldomain.Route{}, repository.ErrNotFound +} +func (r *aliasRouteResolver) GetByPublicIDCandidates(_ context.Context, publicID string) ([]modeldomain.Route, error) { + for _, candidate := range modeldomain.PublicIDCandidates(publicID) { + if routes, ok := r.byPublic[candidate]; ok { + return routes, nil + } + } + if routes, ok := r.byPublic[publicID]; ok { + return routes, nil + } + return nil, repository.ErrNotFound +} +func (r *aliasRouteResolver) GetByProviderUpstream(_ context.Context, providerValue account.Provider, upstreamModel string) (modeldomain.Route, error) { + key := string(providerValue) + "/" + upstreamModel + if route, ok := r.byUp[key]; ok { + return route, nil + } + return modeldomain.Route{}, repository.ErrNotFound +} + +func TestResolvePublicModelRoutesGatesDynamicAliasesAndPreservesCompatibility(t *testing.T) { + route := modeldomain.Route{ + ID: 1, PublicID: "Build/grok-4.5", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: modeldomain.CapabilityResponses, Enabled: true, + } + service := &Service{ + models: &aliasRouteResolver{ + byPublic: map[string][]modeldomain.Route{"Build/grok-4.5": {route}}, + }, + providers: provider.NewRegistry(console.NewAdapter(console.Config{}, nil, nil)), + } + + // Base model always works. + routes, effort, err := service.resolvePublicModelRoutes(context.Background(), "grok-4.5", false) + if err != nil || len(routes) != 1 || effort != "" { + t.Fatalf("base resolve = %#v, %q, %v", routes, effort, err) + } + + // Effort alias rejected when key disabled aliases. + if _, _, err := service.resolvePublicModelRoutes(context.Background(), "grok-4.5-low", false); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("expected not found without aliases, got %v", err) + } + + // Effort alias accepted when enabled and only real levels work. + routes, effort, err = service.resolvePublicModelRoutes(context.Background(), "grok-4.5-low", true) + if err != nil || len(routes) != 1 || effort != "low" { + t.Fatalf("alias resolve = %#v, %q, %v", routes, effort, err) + } + if _, _, err := service.resolvePublicModelRoutes(context.Background(), "grok-4.5-none", true); err == nil { + t.Fatal("grok-4.5-none must be rejected: grok-4.5 cannot disable reasoning") + } + + // Registered aliases existed before the per-key switch and remain callable for compatibility. + consoleRoute := modeldomain.Route{ + ID: 2, PublicID: "Console/grok-4.3", Provider: account.ProviderConsole, UpstreamModel: "grok-4.3", + Capability: modeldomain.CapabilityResponses, Enabled: true, + } + service.models = &aliasRouteResolver{ + byPublic: map[string][]modeldomain.Route{"Console/grok-4.3": {consoleRoute}}, + byUp: map[string]modeldomain.Route{string(account.ProviderConsole) + "/grok-4.3": consoleRoute}, + } + routes, effort, err = service.resolvePublicModelRoutes(context.Background(), "grok-4.3-high", false) + if err != nil || len(routes) != 1 || effort != "high" { + t.Fatalf("legacy console alias resolve = %#v, %q, %v", routes, effort, err) + } + + // Newly generated aliases still require an opted-in key. + if _, _, err := service.resolvePublicModelRoutes(context.Background(), "grok-4.3-none", false); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("dynamic console alias should be gated, got %v", err) + } + routes, effort, err = service.resolvePublicModelRoutes(context.Background(), "grok-4.3-none", true) + if err != nil || len(routes) != 1 || effort != "none" { + t.Fatalf("dynamic console alias resolve = %#v, %q, %v", routes, effort, err) + } +} diff --git a/backend/internal/application/gateway/prompt_cache.go b/backend/internal/application/gateway/prompt_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..debce7704cd96e81c4e904d6c5ba4c9ed7afc9b8 --- /dev/null +++ b/backend/internal/application/gateway/prompt_cache.go @@ -0,0 +1,254 @@ +package gateway + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +const buildSessionIdentityVersion = "v3" + +type buildSessionIdentity struct { + // upstreamID is sent as prompt_cache_key and x-grok-conv-id and must remain stable across turns. + upstreamID string + // affinityKey controls account stickiness and is isolated by model to avoid cross-model collisions. + affinityKey string + // replayKey is derived only from explicit client session signals; soft anchors must not drive encrypted reasoning replay. + replayKey string + // soft indicates a fallback identity derived from message content when no explicit session is available. + soft bool +} + +// resolveBuildSessionIdentity derives a stable Grok Build session identity: +// 1. Prefer explicit client session signals, isolated by client key, provider, and model. +// 2. Fall back to system/instructions and the first user message when no explicit signal exists. +// 3. Return an empty identity when no signal exists; never generate a random session ID per request. +func resolveBuildSessionIdentity(clientKeyID uint64, provider accountdomain.Provider, upstreamModel, explicitKey, sessionSeed string, body []byte) buildSessionIdentity { + // Prefer Claude Code and Codex session signals extracted by the transport layer. + // body.prompt_cache_key is only a fallback when no stronger header or session signal exists. + seed := strings.TrimSpace(sessionSeed) + if seed == "" { + seed = strings.TrimSpace(explicitKey) + } + model := strings.ToLower(strings.TrimSpace(upstreamModel)) + if clientKeyID == 0 || provider == "" || model == "" { + return buildSessionIdentity{} + } + if seed != "" { + upstreamSource := fmt.Sprintf("grok2api:build-session:%s:%d:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, seed) + affinitySource := fmt.Sprintf("grok2api:build-affinity:%s:%d:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, seed) + replaySource := fmt.Sprintf("grok2api:build-replay:%s:%d:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, seed) + return buildSessionIdentity{ + upstreamID: digestUUID(upstreamSource), + affinityKey: hexDigest(affinitySource), + replayKey: hexDigest(replaySource), + } + } + // Fall back to a message-prefix hash to keep account affinity and session IDs stable without client session signals. + system, firstUser, _ := extractMessageAnchors(body) + firstUser = truncateAnchor(firstUser, 200) + system = truncateAnchor(system, 100) + if firstUser == "" { + return buildSessionIdentity{} + } + upstreamSource := fmt.Sprintf("grok2api:build-soft-session:%s:%d:%s:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, system, firstUser) + affinitySource := fmt.Sprintf("grok2api:build-soft-affinity:%s:%d:%s:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, system, firstUser) + return buildSessionIdentity{ + upstreamID: digestUUID(upstreamSource), + affinityKey: hexDigest(affinitySource), + soft: true, + } +} + +func digestUUID(source string) string { + digest := sha256.Sum256([]byte(source)) + hexID := hex.EncodeToString(digest[:16]) + return fmt.Sprintf("%s-%s-%s-%s-%s", hexID[0:8], hexID[8:12], hexID[12:16], hexID[16:20], hexID[20:32]) +} + +func hexDigest(source string) string { + digest := sha256.Sum256([]byte(source)) + return hex.EncodeToString(digest[:]) +} + +func truncateAnchor(value string, maxRunes int) string { + value = strings.TrimSpace(value) + if value == "" || maxRunes <= 0 { + return value + } + if utf8.RuneCountInString(value) <= maxRunes { + return value + } + runes := []rune(value) + return string(runes[:maxRunes]) +} + +// extractMessageAnchors extracts stable prefix anchors from Chat, Messages, and Responses request bodies. +// It uses only system, the first user message, and an optional first assistant message to avoid hash drift across turns. +func extractMessageAnchors(body []byte) (system, firstUser, firstAssistant string) { + if len(body) == 0 { + return "", "", "" + } + var root map[string]json.RawMessage + if json.Unmarshal(body, &root) != nil { + return "", "", "" + } + // Top-level system or instructions fields provide a stable system anchor for OpenAI Responses and Chat. + if raw, ok := root["instructions"]; ok { + system = flattenMessageContent(raw) + } + if system == "" { + if raw, ok := root["system"]; ok { + system = flattenMessageContent(raw) + } + } + if raw, ok := root["messages"]; ok { + msgSystem, msgUser, msgAssistant := anchorsFromRoleMessages(raw) + if system == "" { + system = msgSystem + } + firstUser, firstAssistant = msgUser, msgAssistant + if firstUser != "" { + return system, firstUser, firstAssistant + } + } + if raw, ok := root["input"]; ok { + inSystem, inUser, inAssistant := anchorsFromResponsesInput(raw) + if system == "" { + system = inSystem + } + if firstUser == "" { + firstUser = inUser + } + if firstAssistant == "" { + firstAssistant = inAssistant + } + } + return system, firstUser, firstAssistant +} + +func anchorsFromRoleMessages(raw json.RawMessage) (system, firstUser, firstAssistant string) { + var messages []map[string]json.RawMessage + if json.Unmarshal(raw, &messages) != nil { + return "", "", "" + } + for _, msg := range messages { + var role string + _ = json.Unmarshal(msg["role"], &role) + content := flattenMessageContent(msg["content"]) + if content == "" { + continue + } + switch strings.ToLower(strings.TrimSpace(role)) { + case "system": + if system == "" { + system = content + } + case "user": + if firstUser == "" { + firstUser = content + } + case "assistant": + if firstAssistant == "" { + firstAssistant = content + } + } + if system != "" && firstUser != "" && firstAssistant != "" { + break + } + } + return system, firstUser, firstAssistant +} + +func anchorsFromResponsesInput(raw json.RawMessage) (system, firstUser, firstAssistant string) { + // Shorthand form: input is a direct string. + var asString string + if json.Unmarshal(raw, &asString) == nil { + return "", strings.TrimSpace(asString), "" + } + var items []map[string]json.RawMessage + if json.Unmarshal(raw, &items) != nil { + return "", "", "" + } + for _, item := range items { + var typeName, role string + _ = json.Unmarshal(item["type"], &typeName) + _ = json.Unmarshal(item["role"], &role) + typeName = strings.TrimSpace(typeName) + role = strings.ToLower(strings.TrimSpace(role)) + // Top-level instructions handle the system anchor; this branch extracts messages. + if typeName != "" && typeName != "message" { + continue + } + content := flattenMessageContent(item["content"]) + if content == "" { + // Support content objects whose text field is a string. + var text string + if json.Unmarshal(item["text"], &text) == nil { + content = strings.TrimSpace(text) + } + } + if content == "" { + continue + } + switch role { + case "system", "developer": + if system == "" { + system = content + } + case "user": + if firstUser == "" { + firstUser = content + } + case "assistant": + if firstAssistant == "" { + firstAssistant = content + } + default: + // Treat role-less plain-text input items as user input. + if role == "" && firstUser == "" && (typeName == "" || typeName == "message") { + firstUser = content + } + } + if firstUser != "" && firstAssistant != "" { + break + } + } + // Use top-level instructions as a system fallback. + return system, firstUser, firstAssistant +} + +func flattenMessageContent(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var asString string + if json.Unmarshal(raw, &asString) == nil { + return strings.TrimSpace(asString) + } + var parts []map[string]json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return "" + } + var builder strings.Builder + for _, part := range parts { + var partType string + _ = json.Unmarshal(part["type"], &partType) + switch strings.TrimSpace(partType) { + case "", "text", "input_text", "output_text": + var text string + if json.Unmarshal(part["text"], &text) == nil && strings.TrimSpace(text) != "" { + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(strings.TrimSpace(text)) + } + } + } + return builder.String() +} diff --git a/backend/internal/application/gateway/prompt_cache_test.go b/backend/internal/application/gateway/prompt_cache_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f64fb82cf0c9086e9a79374267df89ed08458528 --- /dev/null +++ b/backend/internal/application/gateway/prompt_cache_test.go @@ -0,0 +1,110 @@ +package gateway + +import ( + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestResolveBuildSessionIdentityIsStableAndTenantIsolated(t *testing.T) { + base := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil) + if len(base.upstreamID) != 36 || len(base.affinityKey) != 64 || len(base.replayKey) != 64 || base != resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil) { + t.Fatalf("unstable identity = %#v", base) + } + for name, value := range map[string]buildSessionIdentity{ + "client": resolveBuildSessionIdentity(8, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil), + "provider": resolveBuildSessionIdentity(7, accountdomain.ProviderConsole, "grok-4.5", "", "session-1", nil), + "session": resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-2", nil), + } { + if value.upstreamID == base.upstreamID || value.affinityKey == base.affinityKey { + t.Fatalf("%s was not isolated: %#v vs %#v", name, value, base) + } + } +} + +func TestResolveBuildSessionIdentitySeparatesAffinityFromUpstreamSession(t *testing.T) { + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil) + otherModel := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.3", "", "session-1", nil) + if first.upstreamID == "" || first.upstreamID == otherModel.upstreamID || first.replayKey == otherModel.replayKey { + t.Fatalf("model-specific upstream session was not isolated: first=%#v other=%#v", first, otherModel) + } + if first.affinityKey == otherModel.affinityKey { + t.Fatalf("model-specific account affinity was not isolated: %#v", first) + } +} + +func TestResolveBuildSoftSessionIdentityIsModelScoped(t *testing.T) { + body := []byte(`{"instructions":"stable system","input":[{"role":"user","content":"hello"}]}`) + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", body) + otherModel := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.3", "", "", body) + if first.upstreamID == "" || first.affinityKey == "" || !first.soft || !otherModel.soft { + t.Fatalf("soft identity missing: first=%#v other=%#v", first, otherModel) + } + if first.upstreamID == otherModel.upstreamID || first.affinityKey == otherModel.affinityKey { + t.Fatalf("soft identity must be model scoped: first=%#v other=%#v", first, otherModel) + } +} + +func TestResolveBuildSessionIdentityPrefersSessionSignal(t *testing.T) { + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "session-1", nil) + second := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "session-2", nil) + if first.upstreamID == "" || first == second { + t.Fatalf("session signal did not take precedence: first=%#v second=%#v", first, second) + } + clientKeyOnly := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "", nil) + if clientKeyOnly.upstreamID == "" || clientKeyOnly == first { + t.Fatalf("explicit key fallback was not isolated: session=%#v explicit=%#v", first, clientKeyOnly) + } + if value := resolveBuildSessionIdentity(0, accountdomain.ProviderBuild, "grok-4.5", "client-key", "", nil); value != (buildSessionIdentity{}) { + t.Fatal("identity without client ownership should be empty") + } + if value := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", nil); value != (buildSessionIdentity{}) { + t.Fatal("identity without an explicit key or session or body should be empty") + } +} + +func TestResolveBuildSessionIdentitySoftFromMessagesIsStableAcrossTurns(t *testing.T) { + turn1 := []byte(`{"messages":[{"role":"system","content":"rules"},{"role":"user","content":"hello world"}]}`) + turn2 := []byte(`{"messages":[{"role":"system","content":"rules"},{"role":"user","content":"hello world"},{"role":"assistant","content":"hi"},{"role":"user","content":"next"}]}`) + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", turn1) + second := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", turn2) + if !first.soft || first.upstreamID == "" { + t.Fatalf("expected soft identity, got %#v", first) + } + if first.replayKey != "" { + t.Fatalf("soft identity must not enable reasoning replay: %#v", first) + } + if first.upstreamID != second.upstreamID || first.affinityKey != second.affinityKey { + t.Fatalf("soft identity drifted across turns: first=%#v second=%#v", first, second) + } + // Different first user messages must remain isolated. + other := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"messages":[{"role":"user","content":"different"}]}`)) + if other.upstreamID == first.upstreamID { + t.Fatal("different first user shared soft upstream id") + } +} + +func TestResolveBuildSessionIdentitySoftFromResponsesInput(t *testing.T) { + body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"cache me"}]}]}`) + first := resolveBuildSessionIdentity(9, accountdomain.ProviderBuild, "grok-4.5", "", "", body) + second := resolveBuildSessionIdentity(9, accountdomain.ProviderBuild, "grok-4.5", "", "", body) + if first.upstreamID == "" || first != second || !first.soft { + t.Fatalf("responses soft identity unstable: %#v %#v", first, second) + } + if first.replayKey != "" { + t.Fatalf("responses soft identity must not enable reasoning replay: %#v", first) + } +} + +func TestResolveBuildSessionIdentityUsesInstructionsAsSystemAnchor(t *testing.T) { + // Responses commonly uses top-level instructions instead of messages[system]. + a := resolveBuildSessionIdentity(3, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"instructions":"stable system","input":[{"role":"user","content":"hello"}]}`)) + b := resolveBuildSessionIdentity(3, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"instructions":"stable system","input":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"next"}]}`)) + c := resolveBuildSessionIdentity(3, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"instructions":"other system","input":[{"role":"user","content":"hello"}]}`)) + if !a.soft || a.upstreamID == "" || a.upstreamID != b.upstreamID { + t.Fatalf("instructions soft session unstable: %#v %#v", a, b) + } + if a.upstreamID == c.upstreamID { + t.Fatal("different instructions should isolate soft session") + } +} diff --git a/backend/internal/application/gateway/response_media_audit.go b/backend/internal/application/gateway/response_media_audit.go new file mode 100644 index 0000000000000000000000000000000000000000..d135f3db9b5eb81eb2719879de8f20073e913196 --- /dev/null +++ b/backend/internal/application/gateway/response_media_audit.go @@ -0,0 +1,475 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "strings" +) + +type responseMediaSummary struct { + InputImages int64 + ImageBytes int64 + ContentArrays int64 + TextBytes int64 +} + +func (summary *responseMediaSummary) add(value responseMediaSummary) { + summary.InputImages += value.InputImages + summary.ImageBytes += value.ImageBytes + summary.ContentArrays += value.ContentArrays + summary.TextBytes += value.TextBytes +} + +type mediaJSONKind uint8 + +const ( + mediaJSONOther mediaJSONKind = iota + mediaJSONString + mediaJSONObjectKind + mediaJSONArrayKind +) + +type mediaJSONValue struct { + kind mediaJSONKind + stringBytes int64 + object *mediaJSONObject + array *mediaJSONArray +} + +type mediaJSONObject struct { + typeName string + role string + + textBytes int64 + imageURLBytes int64 + urlBytes int64 + dataBytes int64 + sourceType string + sourceBytes int64 + + content *mediaJSONValue + output *mediaJSONValue + input *mediaJSONValue + messages *mediaJSONValue +} + +type mediaJSONArray struct { + contentItems responseMediaSummary + inputItems responseMediaSummary + messageItems responseMediaSummary +} + +func summarizeResponseMedia(body []byte) (responseMediaSummary, error) { + if !mayContainResponseMedia(body) { + return responseMediaSummary{}, nil + } + decoder := json.NewDecoder(bytes.NewReader(body)) + value, err := decodeMediaJSONValue(decoder) + if err != nil { + return responseMediaSummary{}, err + } + if token, err := decoder.Token(); err != io.EOF { + if err == nil { + return responseMediaSummary{}, fmt.Errorf("unexpected trailing JSON token %v", token) + } + return responseMediaSummary{}, err + } + if value.object == nil { + return responseMediaSummary{}, nil + } + var summary responseMediaSummary + summary.add(statsForRootInput(value.object.input)) + summary.add(statsForMessages(value.object.messages)) + return summary, nil +} + +// mayContainResponseMedia 是推理热路径上的低成本预筛选。绝大多数纯文本请求 +// 不再创建 JSON decoder;命中仅代表值得精确扫描,最终统计仍由结构化解析决定。 +func mayContainResponseMedia(body []byte) bool { + return bytes.Contains(body, []byte("image")) +} + +func logResponseMediaSummary(logger *slog.Logger, requestID string, summary responseMediaSummary) { + if logger == nil || summary.InputImages == 0 { + return + } + logger.Debug( + "request_media_input_summary", + "request_id", requestID, + "media_input_images", summary.InputImages, + "media_input_image_bytes", summary.ImageBytes, + "media_content_arrays", summary.ContentArrays, + "media_text_bytes", summary.TextBytes, + ) +} + +func decodeMediaJSONValue(decoder *json.Decoder) (*mediaJSONValue, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + switch value := token.(type) { + case json.Delim: + switch value { + case '{': + object, err := decodeMediaJSONObject(decoder) + return &mediaJSONValue{kind: mediaJSONObjectKind, object: object}, err + case '[': + array, err := decodeMediaJSONArray(decoder) + return &mediaJSONValue{kind: mediaJSONArrayKind, array: array}, err + default: + return nil, fmt.Errorf("unexpected JSON delimiter %q", value) + } + case string: + return &mediaJSONValue{kind: mediaJSONString, stringBytes: int64(len(value))}, nil + default: + return &mediaJSONValue{kind: mediaJSONOther}, nil + } +} + +func decodeMediaJSONObject(decoder *json.Decoder) (*mediaJSONObject, error) { + object := &mediaJSONObject{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := keyToken.(string) + if !ok { + return nil, fmt.Errorf("JSON object key is not a string") + } + switch key { + case "type": + object.typeName, err = decodeMediaShortString(decoder) + case "role": + object.role, err = decodeMediaShortString(decoder) + case "text": + object.textBytes, err = decodeMediaStringBytes(decoder) + case "image_url": + object.imageURLBytes, err = decodeMediaImageReference(decoder) + case "url": + object.urlBytes, err = decodeMediaDataURIBytes(decoder) + case "data": + object.dataBytes, err = decodeMediaBase64Bytes(decoder) + case "source": + var value *mediaJSONValue + value, err = decodeMediaJSONValue(decoder) + if value != nil && value.object != nil { + object.sourceType = value.object.typeName + object.sourceBytes = value.object.dataBytes + } + case "content": + object.content, err = decodeMediaJSONValue(decoder) + case "output": + object.output, err = decodeMediaJSONValue(decoder) + case "input": + object.input, err = decodeMediaJSONValue(decoder) + case "messages": + object.messages, err = decodeMediaJSONValue(decoder) + default: + err = skipMediaJSONValue(decoder) + } + if err != nil { + return nil, err + } + } + if token, err := decoder.Token(); err != nil { + return nil, err + } else if token != json.Delim('}') { + return nil, fmt.Errorf("unexpected JSON object terminator %v", token) + } + return object, nil +} + +func decodeMediaJSONArray(decoder *json.Decoder) (*mediaJSONArray, error) { + array := &mediaJSONArray{} + for decoder.More() { + value, err := decodeMediaJSONValue(decoder) + if err != nil { + return nil, err + } + array.contentItems.add(statsForContentBlock(value)) + array.inputItems.add(statsForInputItem(value)) + array.messageItems.add(statsForMessage(value)) + } + if token, err := decoder.Token(); err != nil { + return nil, err + } else if token != json.Delim(']') { + return nil, fmt.Errorf("unexpected JSON array terminator %v", token) + } + return array, nil +} + +func decodeMediaShortString(decoder *json.Decoder) (string, error) { + token, err := decoder.Token() + if err != nil { + return "", err + } + value, ok := token.(string) + if !ok { + if delimiter, ok := token.(json.Delim); ok { + if err := skipMediaJSONContainer(decoder, delimiter); err != nil { + return "", err + } + } + return "", nil + } + if len(value) > 64 { + return "", nil + } + return value, nil +} + +func decodeMediaStringBytes(decoder *json.Decoder) (int64, error) { + token, err := decoder.Token() + if err != nil { + return 0, err + } + if value, ok := token.(string); ok { + return int64(len(value)), nil + } + if delimiter, ok := token.(json.Delim); ok { + return 0, skipMediaJSONContainer(decoder, delimiter) + } + return 0, nil +} + +func decodeMediaDataURIBytes(decoder *json.Decoder) (int64, error) { + token, err := decoder.Token() + if err != nil { + return 0, err + } + if value, ok := token.(string); ok { + return dataURIImageBytes(value), nil + } + if delimiter, ok := token.(json.Delim); ok { + return 0, skipMediaJSONContainer(decoder, delimiter) + } + return 0, nil +} + +func decodeMediaBase64Bytes(decoder *json.Decoder) (int64, error) { + token, err := decoder.Token() + if err != nil { + return 0, err + } + if value, ok := token.(string); ok { + return decodedBase64Bytes(value), nil + } + if delimiter, ok := token.(json.Delim); ok { + return 0, skipMediaJSONContainer(decoder, delimiter) + } + return 0, nil +} + +func decodeMediaImageReference(decoder *json.Decoder) (int64, error) { + token, err := decoder.Token() + if err != nil { + return 0, err + } + if value, ok := token.(string); ok { + return dataURIImageBytes(value), nil + } + if delimiter, ok := token.(json.Delim); ok { + if delimiter == '{' { + object, err := decodeMediaJSONObject(decoder) + if err != nil { + return 0, err + } + return max(object.urlBytes, object.imageURLBytes), nil + } + return 0, skipMediaJSONContainer(decoder, delimiter) + } + return 0, nil +} + +func skipMediaJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + if delimiter, ok := token.(json.Delim); ok { + return skipMediaJSONContainer(decoder, delimiter) + } + return nil +} + +func skipMediaJSONContainer(decoder *json.Decoder, opening json.Delim) error { + if opening != '{' && opening != '[' { + return fmt.Errorf("unexpected JSON delimiter %q", opening) + } + for decoder.More() { + if opening == '{' { + if _, err := decoder.Token(); err != nil { + return err + } + } + if err := skipMediaJSONValue(decoder); err != nil { + return err + } + } + _, err := decoder.Token() + return err +} + +func statsForRootInput(value *mediaJSONValue) responseMediaSummary { + if value == nil { + return responseMediaSummary{} + } + if value.kind == mediaJSONString { + return responseMediaSummary{TextBytes: value.stringBytes} + } + if value.array != nil { + return value.array.inputItems + } + return statsForInputItem(value) +} + +func statsForMessages(value *mediaJSONValue) responseMediaSummary { + if value == nil { + return responseMediaSummary{} + } + if value.array != nil { + return value.array.messageItems + } + return statsForMessage(value) +} + +func statsForInputItem(value *mediaJSONValue) responseMediaSummary { + if value == nil { + return responseMediaSummary{} + } + if value.kind == mediaJSONString { + return responseMediaSummary{TextBytes: value.stringBytes} + } + if value.object == nil { + return responseMediaSummary{} + } + switch value.object.typeName { + case "message": + return statsForContentField(value.object.content) + case "function_call_output": + return statsForContentField(value.object.output) + case "input_text", "input_image", "image_url", "image", "text", "tool_result": + return statsForContentBlock(value) + default: + if value.object.role != "" { + return statsForContentField(value.object.content) + } + return responseMediaSummary{} + } +} + +func statsForMessage(value *mediaJSONValue) responseMediaSummary { + if value == nil || value.object == nil { + return responseMediaSummary{} + } + if value.object.role == "" && value.object.typeName != "message" { + return responseMediaSummary{} + } + return statsForContentField(value.object.content) +} + +func statsForContentField(value *mediaJSONValue) responseMediaSummary { + if value == nil { + return responseMediaSummary{} + } + if value.kind == mediaJSONString { + return responseMediaSummary{TextBytes: value.stringBytes} + } + if value.array == nil { + return responseMediaSummary{} + } + summary := value.array.contentItems + summary.ContentArrays++ + return summary +} + +func statsForContentBlock(value *mediaJSONValue) responseMediaSummary { + if value == nil { + return responseMediaSummary{} + } + if value.kind == mediaJSONString { + return responseMediaSummary{TextBytes: value.stringBytes} + } + if value.object == nil { + return responseMediaSummary{} + } + object := value.object + switch object.typeName { + case "input_text", "text", "output_text": + return responseMediaSummary{TextBytes: object.textBytes} + case "input_image": + return responseMediaSummary{InputImages: 1, ImageBytes: object.imageURLBytes} + case "image_url": + return responseMediaSummary{InputImages: 1, ImageBytes: max(object.imageURLBytes, object.urlBytes)} + case "image": + imageBytes := object.imageURLBytes + if object.sourceType == "base64" { + imageBytes = object.sourceBytes + } + return responseMediaSummary{InputImages: 1, ImageBytes: imageBytes} + case "tool_result": + return statsForContentField(object.content) + default: + return responseMediaSummary{} + } +} + +func dataURIImageBytes(value string) int64 { + comma := strings.IndexByte(value, ',') + if comma <= 0 { + return 0 + } + header := strings.ToLower(value[:comma]) + if !strings.HasPrefix(header, "data:image/") || !strings.Contains(header, ";base64") { + return 0 + } + return decodedBase64Bytes(value[comma+1:]) +} + +func decodedBase64Bytes(value string) int64 { + var symbols int64 + var padding int64 + seenPadding := false + for index := 0; index < len(value); index++ { + character := value[index] + switch { + case character == ' ' || character == '\n' || character == '\r' || character == '\t': + continue + case character == '=': + seenPadding = true + padding++ + if padding > 2 { + return 0 + } + case (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || character == '+' || character == '/' || character == '-' || character == '_': + if seenPadding { + return 0 + } + default: + return 0 + } + symbols++ + } + if symbols == 0 { + return 0 + } + if padding > 0 && symbols%4 != 0 { + return 0 + } + switch remainder := symbols % 4; remainder { + case 0: + return symbols/4*3 - padding + case 2: + return symbols/4*3 + 1 + case 3: + return symbols/4*3 + 2 + default: + return 0 + } +} diff --git a/backend/internal/application/gateway/response_media_audit_test.go b/backend/internal/application/gateway/response_media_audit_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2cf463d77774c52d9e5883e389460b1b5ad48f01 --- /dev/null +++ b/backend/internal/application/gateway/response_media_audit_test.go @@ -0,0 +1,127 @@ +package gateway + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSummarizeResponseMediaScansMessagesAndFunctionOutputs(t *testing.T) { + summary, err := summarizeResponseMedia([]byte(`{ + "input":[ + {"type":"message","role":"user","content":[ + {"type":"input_text","text":"hello"}, + {"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="} + ]}, + {"type":"function_call_output","call_id":"call_1","output":[ + {"type":"input_text","text":"tool"}, + {"type":"input_image","image_url":"data:image/jpeg;base64,QUJD"}, + {"type":"input_image","file_id":"file_1"} + ]} + ] + }`)) + if err != nil { + t.Fatal(err) + } + want := responseMediaSummary{InputImages: 3, ImageBytes: 8, ContentArrays: 2, TextBytes: 9} + if summary != want { + t.Fatalf("summary = %#v, want %#v", summary, want) + } +} + +func TestSummarizeResponseMediaSupportsChatAndAnthropicBlocks(t *testing.T) { + summary, err := summarizeResponseMedia([]byte(`{ + "messages":[ + {"role":"user","content":[ + {"type":"text","text":"chat"}, + {"type":"image_url","image_url":{"url":"data:image/webp;base64,QUJDRA=="}} + ]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"tool_1","content":[ + {"type":"text","text":"anthropic"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}} + ]} + ]} + ] + }`)) + if err != nil { + t.Fatal(err) + } + want := responseMediaSummary{InputImages: 2, ImageBytes: 9, ContentArrays: 3, TextBytes: 13} + if summary != want { + t.Fatalf("summary = %#v, want %#v", summary, want) + } +} + +func TestSummarizeResponseMediaDoesNotInterpretArbitraryImageContent(t *testing.T) { + summary, err := summarizeResponseMedia([]byte(`{ + "input":[{"type":"function_call_output","call_id":"call_1","output":{"ImageContent":{"data":"aGVsbG8="}}}] + }`)) + if err != nil { + t.Fatal(err) + } + if summary != (responseMediaSummary{}) { + t.Fatalf("summary = %#v", summary) + } +} + +func TestResponseMediaSummaryLogContainsOnlyMetadata(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + logResponseMediaSummary(logger, "req-media", responseMediaSummary{ + InputImages: 42, ImageBytes: 33_030_144, ContentArrays: 42, TextBytes: 840, + }) + logged := output.String() + for _, expected := range []string{ + "request_media_input_summary", "request_id=req-media", "media_input_images=42", + "media_input_image_bytes=33030144", "media_content_arrays=42", "media_text_bytes=840", + } { + if !strings.Contains(logged, expected) { + t.Fatalf("log missing %q: %s", expected, logged) + } + } + for _, forbidden := range []string{"base64", "Authorization", "Cookie", "account", `C:\\`, `D:\\`, `E:\\`} { + if strings.Contains(logged, forbidden) { + t.Fatalf("log contains forbidden value %q: %s", forbidden, logged) + } + } +} + +func TestPureTextContentArraysSkipMediaAuditLog(t *testing.T) { + body := []byte(`{ + "input":[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"describe this image"}]}, + {"type":"function_call_output","call_id":"call_1","output":[{"type":"input_text","text":"done"}]} + ] + }`) + summary, err := summarizeResponseMedia(body) + if err != nil { + t.Fatal(err) + } + if summary.InputImages != 0 || summary.ContentArrays != 2 { + t.Fatalf("summary = %#v", summary) + } + + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + logResponseMediaSummary(logger, "req-text", summary) + if output.Len() != 0 { + t.Fatalf("unexpected media audit log: %s", output.String()) + } +} + +func TestDecodedBase64Bytes(t *testing.T) { + for encoded, want := range map[string]int64{ + "aGVsbG8=": 5, + "QUJD": 3, + "QUI": 2, + "QQ": 1, + "bad!": 0, + "QQ=A": 0, + } { + if got := decodedBase64Bytes(encoded); got != want { + t.Fatalf("decodedBase64Bytes(%q) = %d, want %d", encoded, got, want) + } + } +} diff --git a/backend/internal/application/gateway/responses_compaction.go b/backend/internal/application/gateway/responses_compaction.go new file mode 100644 index 0000000000000000000000000000000000000000..44e241781fc97ea994b2b9a4bdeb084b29830d72 --- /dev/null +++ b/backend/internal/application/gateway/responses_compaction.go @@ -0,0 +1,26 @@ +package gateway + +import ( + "encoding/json" + "strings" +) + +// isResponsesCompactionRequest detects Codex remote compaction v2 without +// retaining or logging the request body. The Provider adapter remains the +// authority for validating that the trigger appears exactly once and last. +func isResponsesCompactionRequest(body []byte) bool { + var payload struct { + Input []struct { + Type string `json:"type"` + } `json:"input"` + } + if json.Unmarshal(body, &payload) != nil { + return false + } + for _, item := range payload.Input { + if strings.EqualFold(strings.TrimSpace(item.Type), "compaction_trigger") { + return true + } + } + return false +} diff --git a/backend/internal/application/gateway/responses_compaction_test.go b/backend/internal/application/gateway/responses_compaction_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b7d60f63a9c6584513e40264fd859fd4f18172d7 --- /dev/null +++ b/backend/internal/application/gateway/responses_compaction_test.go @@ -0,0 +1,24 @@ +package gateway + +import "testing" + +func TestIsResponsesCompactionRequest(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {name: "remote v2", body: `{"input":[{"role":"user","content":"continue"},{"type":"compaction_trigger"}]}`, want: true}, + {name: "not terminal", body: `{"input":[{"type":"compaction_trigger"},{"role":"user","content":"continue"}]}`, want: true}, + {name: "normal responses", body: `{"input":[{"role":"user","content":"continue"}]}`}, + {name: "string input", body: `{"input":"continue"}`}, + {name: "invalid json", body: `{`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isResponsesCompactionRequest([]byte(test.body)); got != test.want { + t.Fatalf("isResponsesCompactionRequest() = %t, want %t", got, test.want) + } + }) + } +} diff --git a/backend/internal/application/gateway/selector.go b/backend/internal/application/gateway/selector.go new file mode 100644 index 0000000000000000000000000000000000000000..143ddd5ee6d91446380ea17b7ed823857a38b311 --- /dev/null +++ b/backend/internal/application/gateway/selector.go @@ -0,0 +1,1376 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/pkg/resultcache" + "github.com/chenyme/grok2api/backend/internal/repository" + "golang.org/x/sync/singleflight" +) + +type accountLease struct { + Credential account.Credential + Billing *account.Billing + QuotaProbe bool + QuotaProbeKind account.QuotaRecoveryKind + QuotaMode string + selectorObservation *selectorLeaseObservation + release func() +} + +const quotaProbeLease = 5 * time.Minute +const successPersistInterval = 30 * time.Second +const candidateCacheTTL = time.Second +const concurrencySnapshotTTL = 25 * time.Millisecond +const maxConcurrencySnapshots = 256 + +const modelAccessDeniedCooldown = 5 * time.Minute + +const defaultFreeQuotaRecoveryPause = 24 * time.Hour + +var errRoutingCredentialStale = errors.New("routing credential is no longer available") + +type quotaRecoveryHints struct { + Billing *account.Billing +} + +type candidateSnapshot struct { + values []account.RoutingCandidate + byAccount map[uint64]int + expiresAt time.Time +} + +func newCandidateSnapshot(values []account.RoutingCandidate, expiresAt time.Time) candidateSnapshot { + byAccount := make(map[uint64]int, len(values)) + for index, value := range values { + if _, exists := byAccount[value.Credential.ID]; !exists { + byAccount[value.Credential.ID] = index + } + } + return candidateSnapshot{values: values, byAccount: byAccount, expiresAt: expiresAt} +} + +type candidateCacheKey struct { + provider account.Provider + modelRouteID uint64 + upstreamModel string + quotaMode string +} + +type routingBaseCacheKey struct { + provider account.Provider + quotaMode string +} + +type routingOverlayCacheKey struct { + provider account.Provider + modelRouteID uint64 + upstreamModel string +} + +type routingLayerVersion struct { + global uint64 + provider uint64 +} + +type routingBaseSnapshot struct { + values []account.RoutingAccountBase + version routingLayerVersion + expiresAt time.Time +} + +type routingOverlaySnapshot struct { + value account.RoutingOverlaySnapshot + version routingLayerVersion + expiresAt time.Time +} + +type SelectionUnavailableReason string + +const ( + SelectionNoAccounts SelectionUnavailableReason = "no_accounts" + SelectionUnsupportedModel SelectionUnavailableReason = "unsupported_model" + SelectionCooling SelectionUnavailableReason = "cooling" + SelectionModelCooling SelectionUnavailableReason = "model_cooling" + SelectionQuotaExhausted SelectionUnavailableReason = "quota_exhausted" + SelectionSaturated SelectionUnavailableReason = "saturated" +) + +// SelectionUnavailableError 保留选号失败的真实原因,避免所有情况都退化成模糊的 503。 +type SelectionUnavailableError struct { + Reason SelectionUnavailableReason + RetryAfter time.Duration + Scope clientkeydomain.AccountScope +} + +func (e *SelectionUnavailableError) Error() string { + if e == nil { + return "没有可用上游账号" + } + prefix := "" + if e.Scope.IsRestricted() { + prefix = "Client Key 限定范围" + } + switch e.Reason { + case SelectionUnsupportedModel: + if prefix != "" { + return prefix + "不支持该模型" + } + return "当前账号池不支持该模型" + case SelectionCooling: + if prefix != "" { + return prefix + "中的可用账号正在冷却" + } + return "可用上游账号正在冷却" + case SelectionModelCooling: + if prefix != "" { + return prefix + "中可用账号的目标模型正在冷却" + } + return "可用上游账号的目标模型正在冷却" + case SelectionQuotaExhausted: + if prefix != "" { + return prefix + "中的可用账号额度等待恢复" + } + return "可用上游账号额度等待恢复" + case SelectionSaturated: + if prefix != "" { + return prefix + "中的可用账号均达到并发上限" + } + return "可用上游账号均达到并发上限" + default: + if prefix != "" { + return prefix + "当前没有可用上游账号" + } + return "没有可用上游账号" + } +} + +// HTTPStatus returns the client-facing status for a routing refusal. +func (e *SelectionUnavailableError) HTTPStatus() int { + if e != nil { + switch e.Reason { + case SelectionCooling, SelectionModelCooling, SelectionQuotaExhausted: + return http.StatusTooManyRequests + } + } + return http.StatusServiceUnavailable +} + +// Code returns the stable diagnostic code for a routing refusal. +func (e *SelectionUnavailableError) Code() string { + if e != nil { + switch e.Reason { + case SelectionCooling: + return "upstream_cooling" + case SelectionModelCooling: + return "upstream_model_cooling" + case SelectionQuotaExhausted: + return "upstream_quota_exhausted" + case SelectionSaturated: + return "upstream_saturated" + case SelectionUnsupportedModel: + return "upstream_model_unavailable" + case SelectionNoAccounts: + if e.Scope.IsRestricted() { + return "client_key_account_scope_unavailable" + } + } + } + return "upstream_unavailable" +} + +func (l *accountLease) Release() { + if l == nil { + return + } + if l.selectorObservation != nil { + l.selectorObservation.completeRelease() + } + if l.release != nil { + l.release() + l.release = nil + } +} + +func (l *accountLease) markSelectorUpstreamStarted() { + if l != nil && l.selectorObservation != nil { + l.selectorObservation.upstreamStarted.Store(true) + } +} + +func (l *accountLease) completeSelectorObservation(success bool) { + if l != nil && l.selectorObservation != nil { + l.selectorObservation.complete(success) + } +} + +// Selector 实现可替换的 balanced 账号选择策略。 +type Selector struct { + accounts repository.AccountRepository + concurrency repository.ConcurrencyLimiter + sticky repository.StickySessionRepository + stickyTTL time.Duration + cooldownBase time.Duration + cooldownMax time.Duration + capacityWait time.Duration + preferFreeBuild bool + segmentedConfig segmentedSelectorConfig + segmentedState segmentedSelectorState + configMu sync.RWMutex + candidateMu sync.Mutex + selectionMu sync.RWMutex + leaseWakeMu sync.Mutex + leaseWake chan struct{} + lastSelectedAt map[uint64]time.Time + lastSuccessAt map[uint64]time.Time + candidates map[candidateCacheKey]candidateSnapshot + routingBases map[routingBaseCacheKey]routingBaseSnapshot + routingOverlays map[routingOverlayCacheKey]routingOverlaySnapshot + routingAccountProvider map[uint64]account.Provider + baseGlobalVersion uint64 + overlayGlobalVersion uint64 + baseProviderVersion map[account.Provider]uint64 + overlayProviderVersion map[account.Provider]uint64 + candidateLoads singleflight.Group + concurrencySnapshots *resultcache.Cache[[32]byte, map[string]int] + tierOrders interface { + TierOrder(account.Provider, string) []account.WebTier + } +} + +func NewSelector(accounts repository.AccountRepository, concurrency repository.ConcurrencyLimiter, sticky repository.StickySessionRepository, tierOrders interface { + TierOrder(account.Provider, string) []account.WebTier +}, stickyTTL, cooldownBase, cooldownMax time.Duration, capacityWait ...time.Duration) *Selector { + wait := time.Duration(0) + if len(capacityWait) > 0 && capacityWait[0] > 0 { + wait = capacityWait[0] + } + return &Selector{accounts: accounts, concurrency: concurrency, sticky: sticky, tierOrders: tierOrders, stickyTTL: stickyTTL, cooldownBase: cooldownBase, cooldownMax: cooldownMax, capacityWait: wait, leaseWake: make(chan struct{}), lastSelectedAt: make(map[uint64]time.Time), lastSuccessAt: make(map[uint64]time.Time), candidates: make(map[candidateCacheKey]candidateSnapshot), routingBases: make(map[routingBaseCacheKey]routingBaseSnapshot), routingOverlays: make(map[routingOverlayCacheKey]routingOverlaySnapshot), routingAccountProvider: make(map[uint64]account.Provider), baseProviderVersion: make(map[account.Provider]uint64), overlayProviderVersion: make(map[account.Provider]uint64), concurrencySnapshots: resultcache.New[[32]byte, map[string]int](maxConcurrencySnapshots, concurrencySnapshotTTL)} +} + +func (s *Selector) UpdateConfig(stickyTTL, cooldownBase, cooldownMax time.Duration, capacityWait ...time.Duration) { + s.configMu.Lock() + s.stickyTTL = stickyTTL + s.cooldownBase = cooldownBase + s.cooldownMax = cooldownMax + if len(capacityWait) > 0 { + s.capacityWait = max(time.Duration(0), capacityWait[0]) + } + s.configMu.Unlock() +} + +// UpdatePreferFreeBuild 热更新 Build Free 账号优先策略。 +func (s *Selector) UpdatePreferFreeBuild(value bool) { + s.configMu.Lock() + s.preferFreeBuild = value + s.configMu.Unlock() +} + +// UpdateSegmentedSelector changes the large-pool bounded planner policy. +func (s *Selector) UpdateSegmentedSelector(enabled bool, minCandidates, windowSize int) { + s.configMu.Lock() + s.segmentedConfig = normalizeSegmentedSelectorConfig(segmentedSelectorConfig{ + enabled: enabled, minCandidates: minCandidates, windowSize: windowSize, + }) + s.configMu.Unlock() +} + +func (s *Selector) routingConfig() (time.Duration, time.Duration, time.Duration, time.Duration) { + s.configMu.RLock() + defer s.configMu.RUnlock() + return s.stickyTTL, s.cooldownBase, s.cooldownMax, s.capacityWait +} + +func (s *Selector) preferFreeBuildEnabled() bool { + s.configMu.RLock() + defer s.configMu.RUnlock() + return s.preferFreeBuild +} + +func (s *Selector) Acquire(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode, affinityKey string, excluded map[uint64]bool, allowQuotaProbe bool) (*accountLease, error) { + return s.acquire(ctx, provider, modelRouteID, upstreamModel, quotaMode, affinityKey, excluded, allowQuotaProbe, clientkeydomain.AccountScope{}) +} + +func (s *Selector) AcquireForKey(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode, affinityKey string, excluded map[uint64]bool, allowQuotaProbe bool, scope clientkeydomain.AccountScope) (*accountLease, error) { + return s.acquire(ctx, provider, modelRouteID, upstreamModel, quotaMode, affinityKey, excluded, allowQuotaProbe, scope) +} + +func (s *Selector) acquire(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode, affinityKey string, excluded map[uint64]bool, allowQuotaProbe bool, requestedScope clientkeydomain.AccountScope) (lease *accountLease, err error) { + accountScope, scopeValid := clientkeydomain.NormalizeAccountScope(requestedScope) + defer annotateSelectionAccountScope(&err, accountScope) + if !scopeValid || !accountScope.AllowsProvider(provider) { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts, Scope: accountScope} + } + now := time.Now().UTC() + stickyKey := stickySessionKey(affinityKey) + values, err := s.loadCandidates(ctx, provider, modelRouteID, upstreamModel, quotaMode, now) + if err != nil { + return nil, err + } + // 仅保留候选下标,避免每个请求复制包含凭据、计费和额度结构的完整账号切片。 + normalCandidates := make([]int, 0, len(values)) + probeCandidates := make([]int, 0, len(values)) + supportedCandidates := 0 + consideredCandidates := 0 + coolingCandidates := 0 + modelCoolingCandidates := 0 + quotaCandidates := 0 + var earliestRetry time.Time + for index, candidate := range values { + value := candidate.Credential + if !accountScopeAllowsCandidate(provider, accountScope, candidate) { + continue + } + if excluded[value.ID] || value.AuthStatus != account.AuthStatusActive { + continue + } + consideredCandidates++ + if candidate.ModelCapabilityKnown && !candidate.SupportsModel { + continue + } + supportedCandidates++ + if candidate.ModelQuotaBlock != nil && now.Before(candidate.ModelQuotaBlock.CooldownUntil) { + modelCoolingCandidates++ + earliestRetry = earlierFuture(earliestRetry, candidate.ModelQuotaBlock.CooldownUntil, now) + continue + } + if value.CooldownUntil != nil && now.Before(*value.CooldownUntil) { + coolingCandidates++ + earliestRetry = earlierFuture(earliestRetry, *value.CooldownUntil, now) + continue + } + quotaRecovery := candidate.QuotaRecovery + if quotaRecovery != nil && quotaRecovery.Status != account.QuotaRecoveryStatusActive { + if allowQuotaProbe && quotaRecovery.NextProbeAt != nil && !now.Before(*quotaRecovery.NextProbeAt) { + probeCandidates = append(probeCandidates, index) + } else { + quotaCandidates++ + if quotaRecovery.NextProbeAt != nil { + earliestRetry = earlierFuture(earliestRetry, *quotaRecovery.NextProbeAt, now) + } + } + continue + } + if candidate.Billing != nil && candidate.Billing.IsExhausted(value.MinimumRemaining) { + quotaCandidates++ + continue + } + if candidate.QuotaWindow != nil && candidate.QuotaWindow.Remaining <= 0 { + quotaCandidates++ + if candidate.QuotaWindow.ResetAt != nil { + earliestRetry = earlierFuture(earliestRetry, *candidate.QuotaWindow.ResetAt, now) + } + continue + } + normalCandidates = append(normalCandidates, index) + } + if len(normalCandidates) == 0 && len(probeCandidates) == 0 { + reason := SelectionNoAccounts + switch { + case consideredCandidates > 0 && supportedCandidates == 0: + reason = SelectionUnsupportedModel + case modelCoolingCandidates > 0: + reason = SelectionModelCooling + case coolingCandidates > 0: + reason = SelectionCooling + case quotaCandidates > 0: + reason = SelectionQuotaExhausted + } + return nil, &SelectionUnavailableError{Reason: reason, RetryAfter: retryDelay(now, earliestRetry)} + } + if len(probeCandidates) > 0 { + staleClaims := 0 + capacityMisses := 0 + plan, err := s.planCandidateIndexes(ctx, values, probeCandidates, now, s.resolveTierOrder(provider, upstreamModel)) + if err != nil { + return nil, err + } + for candidate, ok := plan.Next(); ok; candidate, ok = plan.Next() { + lease, err := s.claimAccountSlot(ctx, candidate.Credential) + if err != nil { + if errors.Is(err, errRoutingCredentialStale) { + staleClaims++ + continue + } + return nil, err + } + if lease == nil { + capacityMisses++ + continue + } + claimed, err := s.accounts.ClaimQuotaProbe(ctx, candidate.Credential.ID, now, now.Add(quotaProbeLease)) + if err != nil || !claimed { + lease.Release() + if err != nil { + return nil, err + } + continue + } + lease.QuotaProbe = true + lease.QuotaProbeKind = candidate.QuotaRecovery.Kind + lease.Billing = candidate.Billing + return lease, nil + } + if len(normalCandidates) == 0 && staleClaims > 0 && capacityMisses == 0 { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + } + var saturatedStickyID uint64 + if stickyKey != "" { + stickyID, ok, err := s.sticky.Get(ctx, stickyKey, now) + if err != nil { + return nil, fmt.Errorf("读取会话粘滞状态: %w", err) + } + if ok { + candidate, eligible := routingCandidateByID(values, normalCandidates, stickyID) + if eligible { + stickyTTL, _, _, _ := s.routingConfig() + boundID, bindErr := s.sticky.Bind(ctx, stickyKey, stickyID, now, now.Add(stickyTTL)) + if bindErr != nil { + return nil, fmt.Errorf("刷新会话粘滞状态: %w", bindErr) + } + if boundID != stickyID { + candidate, eligible = routingCandidateByID(values, normalCandidates, boundID) + stickyID = boundID + } + if eligible { + lease, acquireErr := s.acquirePinnedCapacity(ctx, candidate.Credential) + if acquireErr == nil { + lease.Billing = candidate.Billing + lease.QuotaMode = effectiveQuotaMode(candidate, quotaMode) + return lease, nil + } + if errors.Is(acquireErr, errRoutingCredentialStale) { + _ = s.sticky.DeleteByAccount(ctx, stickyID) + } else if !isSelectionUnavailable(acquireErr, SelectionSaturated) { + return nil, acquireErr + } else { + saturatedStickyID = stickyID + } + } + } + } + } + // 粘性账号仅因并发满载而暂时不可用时,先等待该账号;超时后允许本次请求临时借用 + // 其他账号,但不覆盖原绑定,避免并行请求让活跃会话在账号池中来回抖动。 + if saturatedStickyID != 0 { + plan, err := s.planCandidateIndexes(ctx, values, normalCandidates, time.Now().UTC(), s.resolveTierOrder(provider, upstreamModel)) + if err != nil { + return nil, err + } + for candidate, ok := plan.Next(); ok; candidate, ok = plan.Next() { + if candidate.Credential.ID == saturatedStickyID { + continue + } + lease, claimErr := s.claimAccountSlot(ctx, candidate.Credential) + if claimErr != nil { + if errors.Is(claimErr, errRoutingCredentialStale) { + continue + } + return nil, claimErr + } + if lease == nil { + continue + } + lease.Billing = candidate.Billing + lease.QuotaMode = effectiveQuotaMode(candidate, quotaMode) + return lease, nil + } + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + if stickyKey == "" { + activeRequest := s.nextSegmentedActiveRequest(provider, upstreamModel, quotaMode, len(normalCandidates)) + if activeRequest != nil { + return s.acquireSegmentedCandidates(ctx, values, normalCandidates, quotaMode, s.resolveTierOrder(provider, upstreamModel), *activeRequest) + } + } + _, _, _, capacityWait := s.routingConfig() + waitDeadline := time.Now().Add(capacityWait) + for { + currentTime := time.Now().UTC() + staleClaims := 0 + capacityMisses := 0 + plan, err := s.planCandidateIndexes(ctx, values, normalCandidates, currentTime, s.resolveTierOrder(provider, upstreamModel)) + if err != nil { + return nil, err + } + for candidate, ok := plan.Next(); ok; candidate, ok = plan.Next() { + lease, err := s.claimAccountSlot(ctx, candidate.Credential) + if err != nil { + if errors.Is(err, errRoutingCredentialStale) { + staleClaims++ + continue + } + return nil, err + } + if lease == nil { + capacityMisses++ + continue + } + if stickyKey != "" { + stickyTTL, _, _, _ := s.routingConfig() + boundID, bindErr := s.sticky.Bind(ctx, stickyKey, candidate.Credential.ID, currentTime, currentTime.Add(stickyTTL)) + if bindErr != nil { + lease.Release() + return nil, fmt.Errorf("写入会话粘滞状态: %w", bindErr) + } + if boundID != candidate.Credential.ID { + if boundCandidate, eligible := routingCandidateByID(values, normalCandidates, boundID); eligible { + boundLease, boundErr := s.acquirePinnedCapacity(ctx, boundCandidate.Credential) + if boundErr == nil { + lease.Release() + boundLease.Billing = boundCandidate.Billing + boundLease.QuotaMode = effectiveQuotaMode(boundCandidate, quotaMode) + return boundLease, nil + } + if errors.Is(boundErr, errRoutingCredentialStale) { + _ = s.sticky.DeleteByAccount(ctx, boundID) + if err := s.sticky.Set(ctx, stickyKey, candidate.Credential.ID, currentTime.Add(stickyTTL)); err != nil { + lease.Release() + return nil, fmt.Errorf("重建会话粘滞状态: %w", err) + } + } else if !isSelectionUnavailable(boundErr, SelectionSaturated) { + lease.Release() + return nil, boundErr + } + // 已绑定账号满载时保留原绑定,本次请求使用已获取的临时账号。 + } else if err := s.sticky.Set(ctx, stickyKey, candidate.Credential.ID, currentTime.Add(stickyTTL)); err != nil { + lease.Release() + return nil, fmt.Errorf("重建会话粘滞状态: %w", err) + } + } + } + lease.Billing = candidate.Billing + lease.QuotaMode = effectiveQuotaMode(candidate, quotaMode) + return lease, nil + } + if staleClaims > 0 && capacityMisses == 0 { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + if capacityWait <= 0 { + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + retry, err := s.awaitLeaseRetry(ctx, waitDeadline) + if err != nil { + return nil, err + } + if !retry { + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + } +} + +// stickySessionKey 将调用方粘滞 identity 压缩为固定长度,仅用于账号粘滞索引。 +func stickySessionKey(value string) string { + if value == "" { + return "" + } + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} + +func routingCandidateByID(values []account.RoutingCandidate, indexes []int, accountID uint64) (account.RoutingCandidate, bool) { + for _, index := range indexes { + candidate := values[index] + if candidate.Credential.ID == accountID { + return candidate, true + } + } + return account.RoutingCandidate{}, false +} + +func isSelectionUnavailable(err error, reason SelectionUnavailableReason) bool { + var unavailable *SelectionUnavailableError + return errors.As(err, &unavailable) && unavailable.Reason == reason +} + +// AcquirePinned 为 previous_response_id 等账号归属请求获取指定账号租约。 +func (s *Selector) AcquirePinned(ctx context.Context, provider account.Provider, accountID, modelRouteID uint64, upstreamModel, quotaMode string, inference bool) (*accountLease, error) { + return s.acquirePinned(ctx, provider, accountID, modelRouteID, upstreamModel, quotaMode, inference, clientkeydomain.AccountScope{}) +} + +func (s *Selector) AcquirePinnedForKey(ctx context.Context, provider account.Provider, accountID, modelRouteID uint64, upstreamModel, quotaMode string, inference bool, scope clientkeydomain.AccountScope) (*accountLease, error) { + return s.acquirePinned(ctx, provider, accountID, modelRouteID, upstreamModel, quotaMode, inference, scope) +} + +func (s *Selector) acquirePinned(ctx context.Context, provider account.Provider, accountID, modelRouteID uint64, upstreamModel, quotaMode string, inference bool, requestedScope clientkeydomain.AccountScope) (lease *accountLease, err error) { + accountScope, scopeValid := clientkeydomain.NormalizeAccountScope(requestedScope) + defer annotateSelectionAccountScope(&err, accountScope) + if !scopeValid || !accountScope.AllowsProvider(provider) { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts, Scope: accountScope} + } + now := time.Now().UTC() + values, err := s.loadCandidates(ctx, provider, modelRouteID, upstreamModel, quotaMode, now) + if err != nil { + return nil, err + } + for _, candidate := range values { + value := candidate.Credential + if value.ID != accountID { + continue + } + if !accountScopeAllowsCandidate(provider, accountScope, candidate) { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + if !value.Enabled || value.AuthStatus != account.AuthStatusActive { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + if inference { + if candidate.ModelCapabilityKnown && !candidate.SupportsModel { + return nil, &SelectionUnavailableError{Reason: SelectionUnsupportedModel} + } + if candidate.ModelQuotaBlock != nil && now.Before(candidate.ModelQuotaBlock.CooldownUntil) { + return nil, &SelectionUnavailableError{Reason: SelectionModelCooling, RetryAfter: retryDelay(now, candidate.ModelQuotaBlock.CooldownUntil)} + } + if value.CooldownUntil != nil && now.Before(*value.CooldownUntil) { + return nil, &SelectionUnavailableError{Reason: SelectionCooling, RetryAfter: retryDelay(now, *value.CooldownUntil)} + } + if recovery := candidate.QuotaRecovery; recovery != nil && recovery.Status != account.QuotaRecoveryStatusActive { + if recovery.NextProbeAt == nil || now.Before(*recovery.NextProbeAt) { + var retryAfter time.Duration + if recovery.NextProbeAt != nil { + retryAfter = retryDelay(now, *recovery.NextProbeAt) + } + return nil, &SelectionUnavailableError{Reason: SelectionQuotaExhausted, RetryAfter: retryAfter} + } + lease, err := s.acquirePinnedCapacity(ctx, value) + if err != nil { + if errors.Is(err, errRoutingCredentialStale) { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + return nil, err + } + claimed, err := s.accounts.ClaimQuotaProbe(ctx, value.ID, now, now.Add(quotaProbeLease)) + if err != nil || !claimed { + lease.Release() + if err != nil { + return nil, err + } + return nil, fmt.Errorf("绑定的上游账号恢复探测已被占用") + } + lease.QuotaProbe = true + lease.QuotaProbeKind = recovery.Kind + lease.Billing = candidate.Billing + return lease, nil + } + if candidate.Billing != nil && candidate.Billing.IsExhausted(value.MinimumRemaining) { + return nil, &SelectionUnavailableError{Reason: SelectionQuotaExhausted} + } + if candidate.QuotaWindow != nil && candidate.QuotaWindow.Remaining <= 0 { + var retryAfter time.Duration + if candidate.QuotaWindow.ResetAt != nil { + retryAfter = retryDelay(now, *candidate.QuotaWindow.ResetAt) + } + return nil, &SelectionUnavailableError{Reason: SelectionQuotaExhausted, RetryAfter: retryAfter} + } + } + lease, err := s.acquirePinnedCapacity(ctx, value) + if err != nil { + if errors.Is(err, errRoutingCredentialStale) { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + return nil, err + } + lease.Billing = candidate.Billing + lease.QuotaMode = effectiveQuotaMode(candidate, quotaMode) + return lease, nil + } + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} +} + +func accountScopeAllowsCandidate(provider account.Provider, scope clientkeydomain.AccountScope, candidate account.RoutingCandidate) bool { + if provider == account.ProviderConsole { + return true + } + tier := clientkeydomain.AccountTierUnknown + switch provider { + case account.ProviderBuild: + if candidate.IsKnownFreeBuild() { + tier = clientkeydomain.AccountTierFree + } else if account.IsBuildSuper(candidate.Credential, candidate.Billing) { + tier = clientkeydomain.AccountTierSuper + } + case account.ProviderWeb: + switch candidate.Credential.WebTier { + case account.WebTierBasic: + tier = clientkeydomain.AccountTierFree + case account.WebTierSuper, account.WebTierHeavy: + tier = clientkeydomain.AccountTierSuper + } + } + switch tier { + case clientkeydomain.AccountTierFree: + return scope.Tiers&clientkeydomain.TierScopeFree != 0 + case clientkeydomain.AccountTierSuper: + return scope.Tiers&clientkeydomain.TierScopeSuper != 0 + default: + return scope.Tiers&clientkeydomain.TierScopeUnknown != 0 + } +} + +func annotateSelectionAccountScope(err *error, scope clientkeydomain.AccountScope) { + if err == nil || *err == nil || !scope.IsRestricted() { + return + } + var unavailable *SelectionUnavailableError + if errors.As(*err, &unavailable) { + unavailable.Scope = scope + } +} + +func effectiveQuotaMode(candidate account.RoutingCandidate, fallback string) string { + if candidate.QuotaWindow != nil && candidate.QuotaWindow.Mode == "weekly" { + return "weekly" + } + return fallback +} + +func (s *Selector) MarkSuccess(ctx context.Context, credential account.Credential) { + s.markSuccess(ctx, credential, true) +} + +func (s *Selector) markSuccess(ctx context.Context, credential account.Credential, quotaProbe bool) { + now := time.Now().UTC() + persist := credential.FailureCount > 0 || credential.CooldownUntil != nil || credential.LastError != "" + s.selectionMu.Lock() + if last := s.lastSuccessAt[credential.ID]; last.IsZero() || now.Sub(last) >= successPersistInterval { + persist = true + } + if persist { + s.lastSuccessAt[credential.ID] = now + } + s.selectionMu.Unlock() + if persist { + _ = s.accounts.UpdateHealth(ctx, credential.ID, 0, nil, "", true) + } + if quotaProbe { + _ = s.accounts.ClearQuotaRecovery(ctx, credential.ID) + } + if quotaProbe || credential.FailureCount > 0 || credential.CooldownUntil != nil || credential.LastError != "" { + s.invalidateCandidates(credential.Provider) + } +} + +func (s *Selector) MarkFreeQuotaExhausted(ctx context.Context, credential account.Credential, used, limit int64) { + now := time.Now().UTC() + nextProbeAt := now.Add(defaultFreeQuotaRecoveryPause) + s.markFreeQuotaExhaustedAt(ctx, credential, used, limit, now, nextProbeAt) +} + +func (s *Selector) markFreeQuotaExhaustedAt(ctx context.Context, credential account.Credential, used, limit int64, now, nextProbeAt time.Time) { + _ = s.accounts.SaveQuotaRecovery(ctx, account.QuotaRecovery{ + AccountID: credential.ID, Kind: account.QuotaRecoveryKindFree, Status: account.QuotaRecoveryStatusExhausted, + ConfirmedUsed: used, ConfirmedLimit: limit, ExhaustedAt: &now, + NextProbeAt: &nextProbeAt, LastConfirmedAt: &now, UpdatedAt: now, + }) + _ = s.sticky.DeleteByAccount(ctx, credential.ID) + s.invalidateCandidates(credential.Provider) +} + +func (s *Selector) MarkModelQuotaExhausted(ctx context.Context, credential account.Credential, billing *account.Billing, upstreamModel string, retryAfter time.Duration) { + upstreamModel = strings.TrimSpace(upstreamModel) + if upstreamModel == "" { + s.MarkFreeQuotaExhausted(ctx, credential, 0, 0) + return + } + knownFreeBuild := (account.RoutingCandidate{Credential: credential, Billing: billing}).IsKnownFreeBuild() + if knownFreeBuild || retryAfter <= 0 { + retryAfter = defaultFreeQuotaRecoveryPause + } + until := time.Now().UTC().Add(retryAfter) + _ = s.accounts.UpsertModelQuotaBlock(ctx, account.ModelQuotaBlock{ + AccountID: credential.ID, UpstreamModel: upstreamModel, Reason: "model_quota_depleted", CooldownUntil: until, UpdatedAt: time.Now().UTC(), + }) + // The model block makes affected bindings ineligible and they are rebound on + // the next request. Preserve unrelated model/session affinity for this account. + s.invalidateCandidates(credential.Provider) +} + +// MarkModelAccessDenied isolates a permission failure to the rejected model. +// Build OAuth accounts may still have valid video access when a chat endpoint +// returns 403, so a model denial must not invalidate the whole credential. +func (s *Selector) MarkModelAccessDenied(ctx context.Context, credential account.Credential, upstreamModel string, retryAfter time.Duration) { + upstreamModel = strings.TrimSpace(upstreamModel) + if upstreamModel == "" { + return + } + if retryAfter <= 0 { + retryAfter = modelAccessDeniedCooldown + } + now := time.Now().UTC() + _ = s.accounts.UpsertModelQuotaBlock(ctx, account.ModelQuotaBlock{ + AccountID: credential.ID, UpstreamModel: upstreamModel, Reason: "model_access_denied", + CooldownUntil: now.Add(retryAfter), UpdatedAt: now, + }) + s.invalidateCandidates(credential.Provider) +} + +// MarkPaymentQuotaExhausted removes a spending-limited account from routing. +// Paid accounts follow their upstream billing period; Free or unknown accounts +// use the fixed local recovery window. +func (s *Selector) MarkPaymentQuotaExhausted(ctx context.Context, credential account.Credential, hints quotaRecoveryHints) { + now := time.Now().UTC() + if hints.Billing != nil && hints.Billing.IsPaid() { + if periodEnd, ok := hints.Billing.PeriodEnd(); ok && periodEnd.After(now) { + _ = s.accounts.SaveQuotaRecovery(ctx, account.QuotaRecovery{ + AccountID: credential.ID, Kind: account.QuotaRecoveryKindPaid, Status: account.QuotaRecoveryStatusExhausted, + ExhaustedAt: &now, NextProbeAt: &periodEnd, LastConfirmedAt: &now, UpdatedAt: now, + }) + _ = s.sticky.DeleteByAccount(ctx, credential.ID) + s.invalidateCandidates(credential.Provider) + return + } + } + s.MarkFreeQuotaExhausted(ctx, credential, 0, 0) +} + +// MarkQuotaStateChanged 在 Billing 探测改变持久化额度状态后立即失效候选快照。 +func (s *Selector) MarkQuotaStateChanged(provider account.Provider) { s.invalidateCandidates(provider) } + +// ConsumeQuota 将成功请求的本地额度变化应用到候选快照,避免为单账号变化清空整个 Provider 缓存。 +func (s *Selector) ConsumeQuota(provider account.Provider, accountID uint64, mode string, amount int) { + if accountID == 0 || mode == "" || mode == "weekly" || amount <= 0 { + return + } + s.candidateMu.Lock() + defer s.candidateMu.Unlock() + for key, snapshot := range s.candidates { + if key.provider != provider { + continue + } + index, found := snapshot.byAccount[accountID] + if !found || index >= len(snapshot.values) { + continue + } + candidate := snapshot.values[index] + if candidate.QuotaWindow == nil || candidate.QuotaWindow.Mode != mode { + continue + } + next := append([]account.RoutingCandidate(nil), snapshot.values...) + window := *next[index].QuotaWindow + window.Remaining = max(0, window.Remaining-amount) + window.UpdatedAt = time.Now().UTC() + next[index].QuotaWindow = &window + snapshot.values = next + s.candidates[key] = snapshot + } + for key, snapshot := range s.routingBases { + if key.provider != provider { + continue + } + index := -1 + for candidateIndex, base := range snapshot.values { + if base.Credential.ID == accountID { + index = candidateIndex + break + } + } + if index < 0 || snapshot.values[index].QuotaWindow == nil || snapshot.values[index].QuotaWindow.Mode != mode { + continue + } + next := append([]account.RoutingAccountBase(nil), snapshot.values...) + window := *next[index].QuotaWindow + window.Remaining = max(0, window.Remaining-amount) + window.UpdatedAt = time.Now().UTC() + next[index].QuotaWindow = &window + snapshot.values = next + s.routingBases[key] = snapshot + } +} + +func (s *Selector) MarkFailure(ctx context.Context, credential account.Credential, status int, retryAfter time.Duration) { + _ = s.markFailure(ctx, credential, credential.FailureCount+1, status, retryAfter) +} + +// MarkFailureAfterSuccess records a stream failure from a fresh health baseline. +// The upstream already returned a successful response header, so failures that +// preceded this request must not be carried into the new cooldown calculation. +func (s *Selector) MarkFailureAfterSuccess(ctx context.Context, credential account.Credential, status int, retryAfter time.Duration) error { + return s.markFailure(ctx, credential, 1, status, retryAfter) +} + +func (s *Selector) markFailure(ctx context.Context, credential account.Credential, failureCount, status int, retryAfter time.Duration) error { + _, cooldownBase, cooldownMax, _ := s.routingConfig() + cooldown := cooldownBase + for i := 1; i < failureCount && cooldown < cooldownMax; i++ { + cooldown *= 2 + } + if cooldown > cooldownMax { + cooldown = cooldownMax + } + if retryAfter > cooldown { + cooldown = retryAfter + } + until := time.Now().UTC().Add(cooldown) + healthErr := s.accounts.UpdateHealth(ctx, credential.ID, failureCount, &until, fmt.Sprintf("upstream status %d", status), false) + s.invalidateCandidates(credential.Provider) + if status == 401 || status == 402 || status == 403 || status == 429 { + _ = s.sticky.DeleteByAccount(ctx, credential.ID) + } + return healthErr +} + +func (s *Selector) loadCandidates(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode string, now time.Time) ([]account.RoutingCandidate, error) { + if _, ok := s.accounts.(repository.RoutingLayerRepository); ok { + return s.loadLayeredCandidates(ctx, provider, modelRouteID, upstreamModel, quotaMode, now) + } + return s.loadCombinedCandidates(ctx, provider, modelRouteID, upstreamModel, quotaMode, now) +} + +func (s *Selector) loadCombinedCandidates(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode string, now time.Time) ([]account.RoutingCandidate, error) { + key := candidateCacheKey{provider: provider, modelRouteID: modelRouteID, upstreamModel: upstreamModel, quotaMode: quotaMode} + s.candidateMu.Lock() + if snapshot, ok := s.candidates[key]; ok && now.Before(snapshot.expiresAt) { + s.candidateMu.Unlock() + return snapshot.values, nil + } + s.candidateMu.Unlock() + loadKey := fmt.Sprintf("%s\x00%d\x00%s\x00%s", provider, modelRouteID, upstreamModel, quotaMode) + loaded, err, _ := s.candidateLoads.Do(loadKey, func() (any, error) { + checkTime := time.Now().UTC() + s.candidateMu.Lock() + if snapshot, ok := s.candidates[key]; ok && checkTime.Before(snapshot.expiresAt) { + s.candidateMu.Unlock() + return snapshot.values, nil + } + s.candidateMu.Unlock() + values, err := s.accounts.ListRoutingCandidates(ctx, provider, modelRouteID, upstreamModel, quotaMode) + if err != nil { + return nil, err + } + s.candidateMu.Lock() + s.candidates[key] = newCandidateSnapshot(values, checkTime.Add(candidateCacheTTL)) + s.candidateMu.Unlock() + return values, nil + }) + if err != nil { + return nil, err + } + return loaded.([]account.RoutingCandidate), nil +} + +func (s *Selector) loadLayeredCandidates(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode string, now time.Time) ([]account.RoutingCandidate, error) { + key := candidateCacheKey{provider: provider, modelRouteID: modelRouteID, upstreamModel: upstreamModel, quotaMode: quotaMode} + s.candidateMu.Lock() + if snapshot, ok := s.candidates[key]; ok && now.Before(snapshot.expiresAt) { + s.candidateMu.Unlock() + return snapshot.values, nil + } + s.candidateMu.Unlock() + loadKey := fmt.Sprintf("assembled\x00%s\x00%d\x00%s\x00%s", provider, modelRouteID, upstreamModel, quotaMode) + loaded, err, _ := s.candidateLoads.Do(loadKey, func() (any, error) { + checkTime := time.Now().UTC() + s.candidateMu.Lock() + if snapshot, ok := s.candidates[key]; ok && checkTime.Before(snapshot.expiresAt) { + s.candidateMu.Unlock() + return snapshot.values, nil + } + s.candidateMu.Unlock() + layered := s.accounts.(repository.RoutingLayerRepository) + for attempt := 0; attempt < 4; attempt++ { + bases, baseVersion, loadErr := s.loadRoutingBases(ctx, layered, provider, quotaMode, checkTime) + if loadErr != nil { + return nil, loadErr + } + overlay, overlayVersion, loadErr := s.loadRoutingOverlay(ctx, layered, provider, modelRouteID, upstreamModel, checkTime) + if loadErr != nil { + return nil, loadErr + } + if !s.routingVersionsStable(provider, baseVersion, overlayVersion) { + checkTime = time.Now().UTC() + continue + } + values := assembleRoutingCandidates(provider, bases, overlay) + s.candidateMu.Lock() + stable := baseVersion == s.routingBaseVersionLocked(provider) && overlayVersion == s.routingOverlayVersionLocked(provider) + if stable { + s.candidates[key] = newCandidateSnapshot(values, checkTime.Add(candidateCacheTTL)) + } + s.candidateMu.Unlock() + if stable { + return values, nil + } + checkTime = time.Now().UTC() + } + // Sustained account synchronization must not turn cache churn into user-facing + // failures. Fall back to the established authoritative combined query. + return s.accounts.ListRoutingCandidates(ctx, provider, modelRouteID, upstreamModel, quotaMode) + }) + if err != nil { + return nil, err + } + return loaded.([]account.RoutingCandidate), nil +} + +func (s *Selector) loadRoutingBases(ctx context.Context, layered repository.RoutingLayerRepository, provider account.Provider, quotaMode string, now time.Time) ([]account.RoutingAccountBase, routingLayerVersion, error) { + key := routingBaseCacheKey{provider: provider, quotaMode: quotaMode} + version := s.routingBaseVersion(provider) + s.candidateMu.Lock() + if snapshot, ok := s.routingBases[key]; ok && now.Before(snapshot.expiresAt) && snapshot.version == version { + values := snapshot.values + s.candidateMu.Unlock() + return values, version, nil + } + s.candidateMu.Unlock() + loadKey := "base\x00" + string(provider) + "\x00" + quotaMode + loaded, err, _ := s.candidateLoads.Do(loadKey, func() (any, error) { + checkTime := time.Now().UTC() + checkVersion := s.routingBaseVersion(provider) + s.candidateMu.Lock() + if snapshot, ok := s.routingBases[key]; ok && checkTime.Before(snapshot.expiresAt) && snapshot.version == checkVersion { + values := snapshot.values + s.candidateMu.Unlock() + return routingBaseLoadResult{values: values, version: checkVersion}, nil + } + s.candidateMu.Unlock() + values, loadErr := layered.ListRoutingAccountBases(ctx, provider, quotaMode) + if loadErr != nil { + return nil, loadErr + } + s.candidateMu.Lock() + currentVersion := s.routingBaseVersionLocked(provider) + if currentVersion == checkVersion { + s.routingBases[key] = routingBaseSnapshot{values: values, version: checkVersion, expiresAt: checkTime.Add(candidateCacheTTL)} + for accountID, cachedProvider := range s.routingAccountProvider { + if cachedProvider == provider { + delete(s.routingAccountProvider, accountID) + } + } + for _, value := range values { + s.routingAccountProvider[value.Credential.ID] = provider + } + } + s.candidateMu.Unlock() + return routingBaseLoadResult{values: values, version: checkVersion}, nil + }) + if err != nil { + return nil, routingLayerVersion{}, err + } + result := loaded.(routingBaseLoadResult) + return result.values, result.version, nil +} + +func (s *Selector) loadRoutingOverlay(ctx context.Context, layered repository.RoutingLayerRepository, provider account.Provider, modelRouteID uint64, upstreamModel string, now time.Time) (account.RoutingOverlaySnapshot, routingLayerVersion, error) { + key := routingOverlayCacheKey{provider: provider, modelRouteID: modelRouteID, upstreamModel: upstreamModel} + version := s.routingOverlayVersion(provider) + s.candidateMu.Lock() + if snapshot, ok := s.routingOverlays[key]; ok && now.Before(snapshot.expiresAt) && snapshot.version == version { + value := snapshot.value + s.candidateMu.Unlock() + return value, version, nil + } + s.candidateMu.Unlock() + loadKey := fmt.Sprintf("overlay\x00%s\x00%d\x00%s", provider, modelRouteID, upstreamModel) + loaded, err, _ := s.candidateLoads.Do(loadKey, func() (any, error) { + checkTime := time.Now().UTC() + checkVersion := s.routingOverlayVersion(provider) + s.candidateMu.Lock() + if snapshot, ok := s.routingOverlays[key]; ok && checkTime.Before(snapshot.expiresAt) && snapshot.version == checkVersion { + value := snapshot.value + s.candidateMu.Unlock() + return routingOverlayLoadResult{value: value, version: checkVersion}, nil + } + s.candidateMu.Unlock() + value, loadErr := layered.ListRoutingAccountOverlays(ctx, provider, modelRouteID, upstreamModel) + if loadErr != nil { + return nil, loadErr + } + s.candidateMu.Lock() + currentVersion := s.routingOverlayVersionLocked(provider) + if currentVersion == checkVersion { + s.routingOverlays[key] = routingOverlaySnapshot{value: value, version: checkVersion, expiresAt: checkTime.Add(candidateCacheTTL)} + } + s.candidateMu.Unlock() + return routingOverlayLoadResult{value: value, version: checkVersion}, nil + }) + if err != nil { + return account.RoutingOverlaySnapshot{}, routingLayerVersion{}, err + } + result := loaded.(routingOverlayLoadResult) + return result.value, result.version, nil +} + +func (s *Selector) routingBaseVersion(provider account.Provider) routingLayerVersion { + s.candidateMu.Lock() + defer s.candidateMu.Unlock() + return s.routingBaseVersionLocked(provider) +} + +func (s *Selector) routingBaseVersionLocked(provider account.Provider) routingLayerVersion { + return routingLayerVersion{global: s.baseGlobalVersion, provider: s.baseProviderVersion[provider]} +} + +func (s *Selector) routingOverlayVersion(provider account.Provider) routingLayerVersion { + s.candidateMu.Lock() + defer s.candidateMu.Unlock() + return s.routingOverlayVersionLocked(provider) +} + +func (s *Selector) routingOverlayVersionLocked(provider account.Provider) routingLayerVersion { + return routingLayerVersion{global: s.overlayGlobalVersion, provider: s.overlayProviderVersion[provider]} +} + +func (s *Selector) routingVersionsStable(provider account.Provider, base, overlay routingLayerVersion) bool { + s.candidateMu.Lock() + defer s.candidateMu.Unlock() + return base == s.routingBaseVersionLocked(provider) && overlay == s.routingOverlayVersionLocked(provider) +} + +// ApplyInvalidation advances local layer generations before any remote publish. +func (s *Selector) ApplyInvalidation(event repository.InvalidationEvent) { + if !event.Valid() { + return + } + layer := event.Layer() + if layer != repository.InvalidationLayerRoute && layer != repository.InvalidationLayerBase && layer != repository.InvalidationLayerOverlay { + return + } + s.candidateMu.Lock() + provider := event.Provider + if provider == "" && event.AccountID != 0 { + provider = s.routingAccountProvider[event.AccountID] + if provider == "" { + for key, snapshot := range s.candidates { + if _, ok := snapshot.byAccount[event.AccountID]; ok { + provider = key.provider + break + } + } + } + } + base := layer == repository.InvalidationLayerBase + overlay := layer == repository.InvalidationLayerOverlay || layer == repository.InvalidationLayerRoute + if base { + if provider == "" { + s.baseGlobalVersion++ + clearRoutingBases(s.routingBases, "") + } else { + s.baseProviderVersion[provider]++ + clearRoutingBases(s.routingBases, provider) + } + } + if overlay { + if provider == "" { + s.overlayGlobalVersion++ + clearRoutingOverlays(s.routingOverlays, "") + } else { + s.overlayProviderVersion[provider]++ + clearRoutingOverlays(s.routingOverlays, provider) + } + } + for key := range s.candidates { + if provider == "" || key.provider == provider { + delete(s.candidates, key) + } + } + s.candidateMu.Unlock() +} + +func clearRoutingBases(values map[routingBaseCacheKey]routingBaseSnapshot, provider account.Provider) { + for key := range values { + if provider == "" || key.provider == provider { + delete(values, key) + } + } +} + +func clearRoutingOverlays(values map[routingOverlayCacheKey]routingOverlaySnapshot, provider account.Provider) { + for key := range values { + if provider == "" || key.provider == provider { + delete(values, key) + } + } +} + +type routingBaseLoadResult struct { + values []account.RoutingAccountBase + version routingLayerVersion +} + +type routingOverlayLoadResult struct { + value account.RoutingOverlaySnapshot + version routingLayerVersion +} + +func assembleRoutingCandidates(provider account.Provider, bases []account.RoutingAccountBase, overlay account.RoutingOverlaySnapshot) []account.RoutingCandidate { + byAccount := make(map[uint64]account.RoutingAccountOverlay, len(overlay.Values)) + for _, value := range overlay.Values { + byAccount[value.AccountID] = value + } + sharedSuperBuildModel := false + if provider == account.ProviderBuild && !overlay.HasBindings { + for _, base := range bases { + value, exists := byAccount[base.Credential.ID] + if exists && value.SupportsModel && account.IsBuildSuper(base.Credential, base.Billing) { + sharedSuperBuildModel = true + break + } + } + } + result := make([]account.RoutingCandidate, 0, len(bases)) + for _, base := range bases { + overlayValue := byAccount[base.Credential.ID] + if overlay.HasBindings && !overlayValue.Bound { + continue + } + known, supports := overlayValue.ModelCapabilityKnown, overlayValue.SupportsModel + if overlay.HasBindings { + known, supports = true, true + } else if sharedSuperBuildModel && account.IsBuildSuper(base.Credential, base.Billing) { + known, supports = true, true + } + result = append(result, account.RoutingCandidate{ + Credential: base.Credential, Billing: base.Billing, QuotaWindow: base.QuotaWindow, QuotaRecovery: base.QuotaRecovery, + ModelQuotaBlock: overlayValue.ModelQuotaBlock, ModelCapabilityKnown: known, SupportsModel: supports, + }) + } + return result +} + +func (s *Selector) invalidateCandidates(provider account.Provider) { + s.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: provider}) + s.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountCapabilityChanged, Provider: provider}) +} + +func (s *Selector) claimAccountSlot(ctx context.Context, value account.Credential) (*accountLease, error) { + limit := value.MaxConcurrent + if limit <= 0 { + limit = account.DefaultMaxConcurrent + } + release, acquired, err := s.concurrency.Acquire(ctx, accountConcurrencyKey(value.ID), limit) + if err != nil { + return nil, fmt.Errorf("获取账号并发租约: %w", err) + } + if !acquired { + return nil, nil + } + releaseSlot := func() { + release() + s.announceLeaseReturn() + } + if s.accounts != nil { + material, loadErr := s.accounts.GetCredentialMaterial(ctx, value.ID, value.Provider) + if loadErr != nil { + releaseSlot() + if errors.Is(loadErr, repository.ErrNotFound) { + s.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: value.Provider, AccountID: value.ID}) + return nil, errRoutingCredentialStale + } + return nil, fmt.Errorf("加载账号执行凭据: %w", loadErr) + } + hydrated, matched := material.ApplyTo(value) + if !matched { + releaseSlot() + s.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: value.Provider, AccountID: value.ID}) + return nil, errRoutingCredentialStale + } + value = hydrated + } + s.selectionMu.Lock() + s.lastSelectedAt[value.ID] = time.Now().UTC() + s.selectionMu.Unlock() + return &accountLease{Credential: value, release: func() { + releaseSlot() + }}, nil +} + +func (s *Selector) acquirePinnedCapacity(ctx context.Context, value account.Credential) (*accountLease, error) { + _, _, _, capacityWait := s.routingConfig() + deadline := time.Now().Add(capacityWait) + for { + lease, err := s.claimAccountSlot(ctx, value) + if err != nil || lease != nil { + return lease, err + } + if capacityWait <= 0 { + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + retry, err := s.awaitLeaseRetry(ctx, deadline) + if err != nil { + return nil, err + } + if !retry { + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + } +} + +func (s *Selector) leaseReturnNotice() <-chan struct{} { + s.leaseWakeMu.Lock() + defer s.leaseWakeMu.Unlock() + if s.leaseWake == nil { + s.leaseWake = make(chan struct{}) + } + return s.leaseWake +} + +func (s *Selector) announceLeaseReturn() { + s.leaseWakeMu.Lock() + if s.leaseWake != nil { + close(s.leaseWake) + } + s.leaseWake = make(chan struct{}) + s.leaseWakeMu.Unlock() +} + +// awaitLeaseRetry 在本实例归还租约时立即重试;短轮询用于感知其他实例释放的共享并发名额。 +func (s *Selector) awaitLeaseRetry(ctx context.Context, deadline time.Time) (bool, error) { + remaining := time.Until(deadline) + if remaining <= 0 { + return false, nil + } + notice := s.leaseReturnNotice() + timer := time.NewTimer(min(remaining, 100*time.Millisecond)) + defer timer.Stop() + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-notice: + return true, nil + case <-timer.C: + return time.Now().Before(deadline), nil + } +} + +func earlierFuture(current, candidate, now time.Time) time.Time { + if candidate.IsZero() || !now.Before(candidate) { + return current + } + if current.IsZero() || candidate.Before(current) { + return candidate + } + return current +} + +func retryDelay(now, retryAt time.Time) time.Duration { + if retryAt.IsZero() || !now.Before(retryAt) { + return 0 + } + return retryAt.Sub(now) +} + +func (s *Selector) resolveTierOrder(provider account.Provider, upstreamModel string) []account.WebTier { + if s.tierOrders == nil { + return nil + } + return s.tierOrders.TierOrder(provider, upstreamModel) +} + +func tierOrderRank(order []account.WebTier, tier account.WebTier) int { + for index, value := range order { + if value == tier { + return index + } + } + return len(order) +} diff --git a/backend/internal/application/gateway/selector_layered_benchmark_test.go b/backend/internal/application/gateway/selector_layered_benchmark_test.go new file mode 100644 index 0000000000000000000000000000000000000000..507fc2b555d42b4b5bd2d24babee85d6780c9ac4 --- /dev/null +++ b/backend/internal/application/gateway/selector_layered_benchmark_test.go @@ -0,0 +1,68 @@ +package gateway + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" +) + +func BenchmarkSelectorMultiModelCandidateLoad(b *testing.B) { + const accountCount = 300 + models := []string{ + "benchmark-model-1", "benchmark-model-2", "benchmark-model-3", "benchmark-model-4", + "benchmark-model-5", "benchmark-model-6", "benchmark-model-7", "benchmark-model-8", + } + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(b.TempDir(), "selector-layered-benchmark.db")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + b.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + routes := relational.NewModelRepository(database) + credentials := make([]account.Credential, accountCount) + for index := range credentials { + credentials[index] = account.Credential{ + Provider: account.ProviderBuild, Name: fmt.Sprintf("benchmark-%04d", index), + SourceKey: fmt.Sprintf("benchmark-source-%04d", index), EncryptedAccessToken: "encrypted", + AuthStatus: account.AuthStatusActive, Priority: account.DefaultPriority, MaxConcurrent: account.DefaultMaxConcurrent, + } + } + created, err := accounts.UpsertManyByIdentity(ctx, credentials) + if err != nil { + b.Fatal(err) + } + syncedAt := time.Now().UTC() + for _, value := range created { + if err := routes.ReplaceAccountCapabilities(ctx, value.ID, models, syncedAt); err != nil { + b.Fatal(err) + } + } + + for _, modelCount := range []int{2, 8} { + b.Run(fmt.Sprintf("models_%d", modelCount), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for range b.N { + selector := NewSelector(accounts, nil, nil, nil, time.Hour, time.Second, time.Minute) + for _, upstreamModel := range models[:modelCount] { + candidates, loadErr := selector.loadCandidates(ctx, account.ProviderBuild, 0, upstreamModel, "", time.Now().UTC()) + if loadErr != nil { + b.Fatal(loadErr) + } + if len(candidates) != accountCount { + b.Fatalf("candidates = %d, want %d", len(candidates), accountCount) + } + } + } + }) + } +} diff --git a/backend/internal/application/gateway/selector_layered_test.go b/backend/internal/application/gateway/selector_layered_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a5bf4df73d44ba926792aae94472e306e847a399 --- /dev/null +++ b/backend/internal/application/gateway/selector_layered_test.go @@ -0,0 +1,465 @@ +package gateway + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type layeredAccountRepository struct { + repository.AccountRepository + mu sync.Mutex + baseCalls int + overlayCalls map[string]int + bases []account.RoutingAccountBase + nextBases []account.RoutingAccountBase + overlays map[string]account.RoutingOverlaySnapshot + routeOverlays map[uint64]account.RoutingOverlaySnapshot + firstBaseStart chan struct{} + firstBaseReady chan struct{} + baseHook func() + combined []account.RoutingCandidate + combinedCalls int + materialErrors map[uint64]error + materials map[uint64]account.CredentialMaterial + materialCalls []uint64 +} + +func (r *layeredAccountRepository) ListRoutingAccountBases(context.Context, account.Provider, string) ([]account.RoutingAccountBase, error) { + r.mu.Lock() + r.baseCalls++ + call := r.baseCalls + values := r.bases + if call > 1 && r.nextBases != nil { + values = r.nextBases + } + start, ready := r.firstBaseStart, r.firstBaseReady + hook := r.baseHook + r.mu.Unlock() + if hook != nil { + hook() + } + if call == 1 && start != nil { + close(start) + <-ready + } + return values, nil +} + +func (r *layeredAccountRepository) ListRoutingCandidates(context.Context, account.Provider, uint64, string, string) ([]account.RoutingCandidate, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.combinedCalls++ + return r.combined, nil +} + +func (r *layeredAccountRepository) GetCredentialMaterial(_ context.Context, accountID uint64, provider account.Provider) (account.CredentialMaterial, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.materialCalls = append(r.materialCalls, accountID) + if err := r.materialErrors[accountID]; err != nil { + return account.CredentialMaterial{}, err + } + if material, ok := r.materials[accountID]; ok { + return material, nil + } + return account.CredentialMaterial{AccountID: accountID, Provider: provider, AuthType: account.AuthTypeOAuth, EncryptedAccessToken: "encrypted"}, nil +} + +func (r *layeredAccountRepository) ListRoutingAccountOverlays(_ context.Context, _ account.Provider, modelRouteID uint64, upstreamModel string) (account.RoutingOverlaySnapshot, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.overlayCalls == nil { + r.overlayCalls = make(map[string]int) + } + r.overlayCalls[upstreamModel]++ + if modelRouteID > 0 && r.routeOverlays != nil { + return r.routeOverlays[modelRouteID], nil + } + return r.overlays[upstreamModel], nil +} + +func (r *layeredAccountRepository) callCounts(model string) (int, int) { + r.mu.Lock() + defer r.mu.Unlock() + return r.baseCalls, r.overlayCalls[model] +} + +func TestSelectorLayeredCacheReusesBaseAcrossModels(t *testing.T) { + repo := newLayeredRepositoryFixture() + selector := NewSelector(repo, nil, nil, nil, time.Hour, time.Second, time.Minute) + now := time.Now().UTC() + for _, model := range []string{"model-a", "model-b"} { + values, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 0, model, "", now) + if err != nil || len(values) != 1 || values[0].Credential.ID != 1 { + t.Fatalf("model %s candidates = %#v, err = %v", model, values, err) + } + } + baseCalls, modelACalls := repo.callCounts("model-a") + _, modelBCalls := repo.callCounts("model-b") + if baseCalls != 1 || modelACalls != 1 || modelBCalls != 1 { + t.Fatalf("base=%d model-a=%d model-b=%d", baseCalls, modelACalls, modelBCalls) + } + + selector.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountBillingChanged, Provider: account.ProviderBuild}) + if _, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 0, "model-a", "", now); err != nil { + t.Fatal(err) + } + baseCalls, modelACalls = repo.callCounts("model-a") + if baseCalls != 2 || modelACalls != 1 { + t.Fatalf("base invalidation reloaded base=%d overlay=%d", baseCalls, modelACalls) + } + + selector.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountCapabilityChanged, Provider: account.ProviderBuild}) + if _, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 0, "model-a", "", now); err != nil { + t.Fatal(err) + } + baseCalls, modelACalls = repo.callCounts("model-a") + if baseCalls != 2 || modelACalls != 2 { + t.Fatalf("overlay invalidation reloaded base=%d overlay=%d", baseCalls, modelACalls) + } +} + +func TestSelectorLayeredCacheSeparatesRoutesSharingUpstream(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.bases = []account.RoutingAccountBase{ + {Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive}}, + {Credential: account.Credential{ID: 2, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive}}, + } + repo.routeOverlays = map[uint64]account.RoutingOverlaySnapshot{ + 101: {HasBindings: true, Values: []account.RoutingAccountOverlay{{AccountID: 1, Bound: true, ModelCapabilityKnown: true, SupportsModel: true}}}, + 202: {HasBindings: true, Values: []account.RoutingAccountOverlay{{AccountID: 2, Bound: true, ModelCapabilityKnown: true, SupportsModel: true}}}, + } + selector := NewSelector(repo, nil, nil, nil, time.Hour, time.Second, time.Minute) + now := time.Now().UTC() + first, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 101, "shared-model", "", now) + if err != nil || len(first) != 1 || first[0].Credential.ID != 1 { + t.Fatalf("route 101 candidates = %#v, err = %v", first, err) + } + second, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 202, "shared-model", "", now) + if err != nil || len(second) != 1 || second[0].Credential.ID != 2 { + t.Fatalf("route 202 candidates = %#v, err = %v", second, err) + } +} + +func TestSelectorLayeredLoadRetriesInsteadOfMixingVersions(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.nextBases = []account.RoutingAccountBase{{Credential: account.Credential{ID: 2, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive}}} + repo.overlays["model-a"] = account.RoutingOverlaySnapshot{Values: []account.RoutingAccountOverlay{ + {AccountID: 1, ModelCapabilityKnown: true, SupportsModel: true}, + {AccountID: 2, ModelCapabilityKnown: true, SupportsModel: true}, + }} + repo.firstBaseStart = make(chan struct{}) + repo.firstBaseReady = make(chan struct{}) + selector := NewSelector(repo, nil, nil, nil, time.Hour, time.Second, time.Minute) + type result struct { + values []account.RoutingCandidate + err error + } + resultCh := make(chan result, 1) + go func() { + values, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 0, "model-a", "", time.Now().UTC()) + resultCh <- result{values: values, err: err} + }() + <-repo.firstBaseStart + selector.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild}) + close(repo.firstBaseReady) + value := <-resultCh + if value.err != nil || len(value.values) != 1 || value.values[0].Credential.ID != 2 { + t.Fatalf("candidates = %#v, err = %v", value.values, value.err) + } + baseCalls, _ := repo.callCounts("model-a") + if baseCalls != 2 { + t.Fatalf("base calls = %d, want retry", baseCalls) + } +} + +func TestSelectorAppliesOutOfOrderInvalidationsSafely(t *testing.T) { + selector := NewSelector(nil, nil, nil, nil, time.Hour, time.Second, time.Minute) + event := repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild, Revision: 2} + selector.ApplyInvalidation(event) + first := selector.routingBaseVersion(account.ProviderBuild) + event.Revision = 1 + selector.ApplyInvalidation(event) + second := selector.routingBaseVersion(account.ProviderBuild) + if first.provider != 1 || second.provider != 2 { + t.Fatalf("versions first=%#v second=%#v", first, second) + } +} + +func TestSelectorIgnoresClientKeyInvalidation(t *testing.T) { + selector := NewSelector(nil, nil, nil, nil, time.Hour, time.Second, time.Minute) + expiresAt := time.Now().Add(time.Hour) + selector.routingBases[routingBaseCacheKey{provider: account.ProviderBuild}] = routingBaseSnapshot{expiresAt: expiresAt} + selector.routingOverlays[routingOverlayCacheKey{provider: account.ProviderBuild}] = routingOverlaySnapshot{expiresAt: expiresAt} + selector.candidates[candidateCacheKey{provider: account.ProviderBuild}] = candidateSnapshot{expiresAt: expiresAt} + + selector.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged, ClientKeyID: 42}) + + if len(selector.routingBases) != 1 || len(selector.routingOverlays) != 1 || len(selector.candidates) != 1 { + t.Fatalf("client-key event invalidated selector caches: bases=%d overlays=%d candidates=%d", len(selector.routingBases), len(selector.routingOverlays), len(selector.candidates)) + } +} + +func TestSelectorScopesAccountInvalidationToCachedProvider(t *testing.T) { + selector := NewSelector(nil, nil, nil, nil, time.Hour, time.Second, time.Minute) + expiresAt := time.Now().Add(time.Hour) + selector.routingBases[routingBaseCacheKey{provider: account.ProviderBuild}] = routingBaseSnapshot{expiresAt: expiresAt} + selector.routingBases[routingBaseCacheKey{provider: account.ProviderWeb}] = routingBaseSnapshot{expiresAt: expiresAt} + selector.routingAccountProvider[42] = account.ProviderBuild + + selector.ApplyInvalidation(repository.InvalidationEvent{ + Kind: repository.InvalidationAccountBillingChanged, AccountID: 42, + }) + + if _, ok := selector.routingBases[routingBaseCacheKey{provider: account.ProviderBuild}]; ok { + t.Fatal("Build routing base survived its account invalidation") + } + if _, ok := selector.routingBases[routingBaseCacheKey{provider: account.ProviderWeb}]; !ok { + t.Fatal("unrelated Web routing base was invalidated") + } + if selector.baseGlobalVersion != 0 || selector.baseProviderVersion[account.ProviderBuild] != 1 || selector.baseProviderVersion[account.ProviderWeb] != 0 { + t.Fatalf("base versions = global:%d providers:%v", selector.baseGlobalVersion, selector.baseProviderVersion) + } +} + +func TestSelectorFallsBackToGlobalInvalidationForUnknownAccount(t *testing.T) { + selector := NewSelector(nil, nil, nil, nil, time.Hour, time.Second, time.Minute) + expiresAt := time.Now().Add(time.Hour) + selector.routingBases[routingBaseCacheKey{provider: account.ProviderBuild}] = routingBaseSnapshot{expiresAt: expiresAt} + selector.routingBases[routingBaseCacheKey{provider: account.ProviderWeb}] = routingBaseSnapshot{expiresAt: expiresAt} + + selector.ApplyInvalidation(repository.InvalidationEvent{ + Kind: repository.InvalidationAccountBillingChanged, AccountID: 99, + }) + + if len(selector.routingBases) != 0 || selector.baseGlobalVersion != 1 { + t.Fatalf("unknown account did not trigger safe global invalidation: bases=%d global=%d", len(selector.routingBases), selector.baseGlobalVersion) + } +} + +func TestSelectorFallsBackWhenLayerVersionsKeepChanging(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.combined = []account.RoutingCandidate{{Credential: account.Credential{ID: 9, Provider: account.ProviderBuild}}} + selector := NewSelector(repo, nil, nil, nil, time.Hour, time.Second, time.Minute) + repo.baseHook = func() { + selector.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild}) + } + values, err := selector.loadCandidates(context.Background(), account.ProviderBuild, 0, "model-a", "", time.Now().UTC()) + if err != nil || len(values) != 1 || values[0].Credential.ID != 9 { + t.Fatalf("fallback candidates = %#v, err = %v", values, err) + } + repo.mu.Lock() + defer repo.mu.Unlock() + if repo.baseCalls != 4 || repo.combinedCalls != 1 { + t.Fatalf("base calls=%d combined calls=%d", repo.baseCalls, repo.combinedCalls) + } +} + +func TestSelectorHydratesOnlyClaimedCredentialAndSkipsStaleCandidate(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.bases = []account.RoutingAccountBase{ + {Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 20}}, + {Credential: account.Credential{ID: 2, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 10}}, + } + repo.materialErrors = map[uint64]error{1: repository.ErrNotFound} + repo.materials = map[uint64]account.CredentialMaterial{ + 2: {AccountID: 2, Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, EncryptedAccessToken: "selected-secret"}, + } + selector := NewSelector(repo, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model-a", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 2 || lease.Credential.EncryptedAccessToken != "selected-secret" { + t.Fatalf("lease credential = %#v", lease.Credential) + } + if !reflect.DeepEqual(repo.materialCalls, []uint64{1, 2}) { + t.Fatalf("material calls = %v, want [1 2]", repo.materialCalls) + } +} + +func TestSelectorReportsNoAccountsWhenEveryCredentialIsStale(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.materialErrors = map[uint64]error{1: repository.ErrNotFound} + selector := NewSelector(repo, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model-a", "", "", nil, false) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionNoAccounts { + t.Fatalf("error = %v, want no accounts", err) + } +} + +func TestSelectorPinnedCredentialDoesNotSwitchWhenMaterialIsStale(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.bases = append(repo.bases, + account.RoutingAccountBase{Credential: account.Credential{ID: 2, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive}}, + ) + repo.materialErrors = map[uint64]error{1: repository.ErrNotFound} + selector := NewSelector(repo, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + + _, err := selector.AcquirePinned(context.Background(), account.ProviderBuild, 1, 0, "model-a", "", true) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionNoAccounts { + t.Fatalf("error = %v, want no accounts", err) + } + if !reflect.DeepEqual(repo.materialCalls, []uint64{1}) { + t.Fatalf("material calls = %v, want pinned account only", repo.materialCalls) + } +} + +func TestSelectorRebindsStickySessionAfterCredentialBecomesStale(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.bases = []account.RoutingAccountBase{ + {Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 20}}, + {Credential: account.Credential{ID: 2, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 10}}, + } + repo.materialErrors = map[uint64]error{1: repository.ErrNotFound} + sticky := memory.NewStickyStore() + affinity := "stale-session" + if err := sticky.Set(context.Background(), stickySessionKey(affinity), 1, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + selector := NewSelector(repo, memory.NewConcurrencyLimiter(), sticky, nil, time.Hour, time.Second, time.Minute) + + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model-a", "", affinity, nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 2 { + t.Fatalf("selected account = %d, want fallback account 2", lease.Credential.ID) + } + boundID, ok, err := sticky.Get(context.Background(), stickySessionKey(affinity), time.Now()) + if err != nil || !ok || boundID != 2 { + t.Fatalf("sticky binding = %d, ok=%v, err=%v", boundID, ok, err) + } +} + +func TestSelectorReleasesCapacityWhenCredentialHydrationFails(t *testing.T) { + repo := newLayeredRepositoryFixture() + loadErr := errors.New("credential storage unavailable") + repo.materialErrors = map[uint64]error{1: loadErr} + limiter := memory.NewConcurrencyLimiter() + selector := NewSelector(repo, limiter, memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model-a", "", "", nil, false) + if !errors.Is(err, loadErr) { + t.Fatalf("error = %v, want credential storage error", err) + } + current, currentErr := limiter.Current(context.Background(), repository.AccountConcurrencyKey(1)) + if currentErr != nil { + t.Fatal(currentErr) + } + if current != 0 { + t.Fatalf("current concurrency = %d, want released slot", current) + } +} + +func TestSelectorRejectsCrossProviderCredentialMaterial(t *testing.T) { + repo := newLayeredRepositoryFixture() + repo.materials = map[uint64]account.CredentialMaterial{ + 1: {AccountID: 1, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, EncryptedAccessToken: "wrong-provider-secret"}, + } + limiter := memory.NewConcurrencyLimiter() + selector := NewSelector(repo, limiter, memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model-a", "", "", nil, false) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionNoAccounts { + t.Fatalf("error = %v, want no accounts", err) + } + current, currentErr := limiter.Current(context.Background(), repository.AccountConcurrencyKey(1)) + if currentErr != nil { + t.Fatal(currentErr) + } + if current != 0 { + t.Fatalf("current concurrency = %d, want released slot", current) + } +} + +func TestLayeredRoutingMatchesCombinedRepositoryResult(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "layered-equivalence.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + models := relational.NewModelRepository(database) + first, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "first", SourceKey: "first", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + second, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "second", SourceKey: "second", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Millisecond) + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: second.ID, MonthlyLimit: 100, Used: 10, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, first.ID, []string{"other-model"}, now); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, second.ID, []string{"model-a"}, now); err != nil { + t.Fatal(err) + } + if _, err := models.Create(ctx, model.Route{ + PublicID: "model-a", Provider: account.ProviderBuild, UpstreamModel: "model-a", Capability: model.CapabilityResponses, Enabled: true, + }, []uint64{second.ID}); err != nil { + t.Fatal(err) + } + if err := accounts.UpsertModelQuotaBlock(ctx, account.ModelQuotaBlock{ + AccountID: second.ID, UpstreamModel: "model-a", Reason: "test", CooldownUntil: now.Add(time.Hour), UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + combined, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "model-a", "") + if err != nil { + t.Fatal(err) + } + bases, err := accounts.ListRoutingAccountBases(ctx, account.ProviderBuild, "") + if err != nil { + t.Fatal(err) + } + overlay, err := accounts.ListRoutingAccountOverlays(ctx, account.ProviderBuild, 0, "model-a") + if err != nil { + t.Fatal(err) + } + layered := assembleRoutingCandidates(account.ProviderBuild, bases, overlay) + if !reflect.DeepEqual(layered, combined) { + t.Fatalf("layered = %#v\ncombined = %#v", layered, combined) + } +} + +func newLayeredRepositoryFixture() *layeredAccountRepository { + return &layeredAccountRepository{ + bases: []account.RoutingAccountBase{{Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, Enabled: true, AuthStatus: account.AuthStatusActive}}}, + overlays: map[string]account.RoutingOverlaySnapshot{ + "model-a": {Values: []account.RoutingAccountOverlay{{AccountID: 1, ModelCapabilityKnown: true, SupportsModel: true}}}, + "model-b": {Values: []account.RoutingAccountOverlay{{AccountID: 1, ModelCapabilityKnown: true, SupportsModel: true}}}, + }, + overlayCalls: make(map[string]int), + } +} diff --git a/backend/internal/application/gateway/selector_plan.go b/backend/internal/application/gateway/selector_plan.go new file mode 100644 index 0000000000000000000000000000000000000000..6b9088902112a6d08fe0bce33e38dea4020240e2 --- /dev/null +++ b/backend/internal/application/gateway/selector_plan.go @@ -0,0 +1,226 @@ +package gateway + +import ( + "container/heap" + "context" + "crypto/sha256" + "fmt" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type candidateScore struct { + index int + tier int + preferFreeBuild bool + billingFresh bool + inFlight int + remaining float64 + lastSelected time.Time +} + +// candidatePlan 使用线性建堆保留完整路由优先级,并允许 claim 失败后按顺序取下一账号。 +type candidatePlan struct { + values []account.RoutingCandidate + scores []candidateScore +} + +func (p *candidatePlan) Len() int { return len(p.scores) } + +func (p *candidatePlan) Less(left, right int) bool { + return candidateScoreBetter(p.values, p.scores[left], p.scores[right]) +} + +func (p *candidatePlan) Swap(left, right int) { + p.scores[left], p.scores[right] = p.scores[right], p.scores[left] +} + +func (p *candidatePlan) Push(value any) { + p.scores = append(p.scores, value.(candidateScore)) +} + +func (p *candidatePlan) Pop() any { + last := len(p.scores) - 1 + value := p.scores[last] + p.scores = p.scores[:last] + return value +} + +func (p *candidatePlan) Next() (account.RoutingCandidate, bool) { + if p == nil || p.Len() == 0 { + return account.RoutingCandidate{}, false + } + score := heap.Pop(p).(candidateScore) + return p.values[score.index], true +} + +func candidateScoreBetter(values []account.RoutingCandidate, leftScore, rightScore candidateScore) bool { + leftCandidate, rightCandidate := values[leftScore.index], values[rightScore.index] + left, right := leftCandidate.Credential, rightCandidate.Credential + if leftCandidate.SupportsModel != rightCandidate.SupportsModel { + return leftCandidate.SupportsModel + } + if leftCandidate.ModelCapabilityKnown != rightCandidate.ModelCapabilityKnown { + return leftCandidate.ModelCapabilityKnown + } + if leftScore.preferFreeBuild != rightScore.preferFreeBuild { + return leftScore.preferFreeBuild + } + if leftScore.tier != rightScore.tier { + return leftScore.tier < rightScore.tier + } + if left.Priority != right.Priority { + return left.Priority > right.Priority + } + if leftScore.billingFresh != rightScore.billingFresh { + return leftScore.billingFresh + } + if leftScore.inFlight != rightScore.inFlight { + return leftScore.inFlight < rightScore.inFlight + } + if leftScore.remaining != rightScore.remaining { + return leftScore.remaining > rightScore.remaining + } + if !leftScore.lastSelected.Equal(rightScore.lastSelected) { + return leftScore.lastSelected.Before(rightScore.lastSelected) + } + return left.ID < right.ID +} + +// planCandidates 批量读取动态并发状态,并以 O(n) 建堆生成保持原比较规则的候选计划。 +func (s *Selector) planCandidates(ctx context.Context, values []account.RoutingCandidate, now time.Time, tierOrder []account.WebTier) (*candidatePlan, error) { + return s.planCandidateIndexes(ctx, values, nil, now, tierOrder) +} + +// planCandidateIndexes 在不可变候选快照上按下标规划,避免过滤阶段复制完整账号结构。 +// indexes 为 nil 时表示使用 values 的全部元素。 +func (s *Selector) planCandidateIndexes(ctx context.Context, values []account.RoutingCandidate, indexes []int, now time.Time, tierOrder []account.WebTier) (*candidatePlan, error) { + return s.planCandidateIndexesWithHints(ctx, values, indexes, now, tierOrder, nil, s.preferFreeBuildEnabled()) +} + +func (s *Selector) planCandidateIndexesWithHints(ctx context.Context, values []account.RoutingCandidate, indexes []int, now time.Time, tierOrder []account.WebTier, concurrencyHints []int, preferFreeBuild bool) (*candidatePlan, error) { + length := len(indexes) + if indexes == nil { + length = len(values) + } + inFlight := make([]int, length) + if concurrencyHints == nil { + keys := make([]string, length) + for position := range length { + index := position + if indexes != nil { + index = indexes[position] + } + keys[position] = accountConcurrencyKey(values[index].Credential.ID) + } + concurrencySnapshot, err := s.loadConcurrencySnapshot(ctx, keys) + if err != nil { + return nil, err + } + for position := range length { + inFlight[position] = concurrencySnapshot[keys[position]] + } + } else { + missingIndexes := make([]int, 0, length) + keys := make([]string, 0, length) + for position := range length { + index := position + if indexes != nil { + index = indexes[position] + } + if concurrencyHints[index] != 0 { + continue + } + missingIndexes = append(missingIndexes, index) + keys = append(keys, accountConcurrencyKey(values[index].Credential.ID)) + } + if len(keys) > 0 { + concurrencySnapshot, err := s.loadConcurrencySnapshot(ctx, keys) + if err != nil { + return nil, err + } + for position, index := range missingIndexes { + concurrencyHints[index] = concurrencySnapshot[keys[position]] + 1 + } + } + for position := range length { + index := position + if indexes != nil { + index = indexes[position] + } + inFlight[position] = concurrencyHints[index] - 1 + } + } + + s.selectionMu.RLock() + scores := make([]candidateScore, length) + for position := range length { + index := position + if indexes != nil { + index = indexes[position] + } + candidate := values[index] + score := candidateScore{ + index: index, tier: tierOrderRank(tierOrder, candidate.Credential.WebTier), + preferFreeBuild: preferFreeBuild && candidate.IsKnownFreeBuild(), + inFlight: inFlight[position], lastSelected: s.lastSelectedAt[candidate.Credential.ID], + } + if candidate.Billing != nil { + score.remaining = candidate.Billing.Remaining() + score.billingFresh = now.Sub(candidate.Billing.SyncedAt) <= 30*time.Minute + } + scores[position] = score + } + s.selectionMu.RUnlock() + plan := &candidatePlan{values: values, scores: scores} + heap.Init(plan) + return plan, nil +} + +// loadConcurrencySnapshot 在极短窗口内合并相同候选池的并发快照读取。 +// 快照只参与排序,最终容量仍由原子 Acquire 校验,因此陈旧快照不会突破账号并发上限。 +func (s *Selector) loadConcurrencySnapshot(ctx context.Context, keys []string) (map[string]int, error) { + cacheKey := concurrencySnapshotKey(keys) + load := func() (map[string]int, error) { + values := make(map[string]int, len(keys)) + if batchReader, ok := s.concurrency.(repository.ConcurrencySnapshotReader); ok { + var err error + values, err = batchReader.CurrentMany(ctx, keys) + if err != nil { + return nil, fmt.Errorf("批量读取账号并发租约: %w", err) + } + } else { + for _, key := range keys { + current, err := s.concurrency.Current(ctx, key) + if err != nil { + return nil, fmt.Errorf("读取账号并发租约: %w", err) + } + values[key] = current + } + } + return values, nil + } + // 仅测试中的手工 Selector 可能没有初始化缓存,保持最小兼容回退。 + if s.concurrencySnapshots == nil { + return load() + } + return s.concurrencySnapshots.Load(ctx, cacheKey, time.Now(), load) +} + +func concurrencySnapshotKey(keys []string) [32]byte { + hash := sha256.New() + separator := []byte{0} + for _, key := range keys { + _, _ = hash.Write([]byte(key)) + _, _ = hash.Write(separator) + } + var result [32]byte + copy(result[:], hash.Sum(nil)) + return result +} + +func accountConcurrencyKey(accountID uint64) string { + return repository.AccountConcurrencyKey(accountID) +} diff --git a/backend/internal/application/gateway/selector_segmented.go b/backend/internal/application/gateway/selector_segmented.go new file mode 100644 index 0000000000000000000000000000000000000000..8ee6035e5c5229b3bf0c05f60eb16161cea7b3e1 --- /dev/null +++ b/backend/internal/application/gateway/selector_segmented.go @@ -0,0 +1,111 @@ +package gateway + +import ( + "hash/fnv" + "sync/atomic" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" +) + +const segmentedSelectorShards = 1024 + +type segmentedSelectorConfig struct { + enabled bool + minCandidates int + windowSize int +} + +type segmentedSelectorState struct { + activeCursors [segmentedSelectorShards]atomic.Uint64 +} + +type segmentedSelectorCohort struct { + supportsModel bool + capabilityKnown bool + preferFreeBuild bool + tier int + priority int + billingFresh bool +} + +type selectorLeaseObservation struct { + provider account.Provider + stage string + upstreamStarted atomic.Bool + completed atomic.Bool +} + +func normalizeSegmentedSelectorConfig(value segmentedSelectorConfig) segmentedSelectorConfig { + if value.minCandidates < 100 { + value.minCandidates = 3000 + } + if value.windowSize < 8 || value.windowSize > value.minCandidates { + value.windowSize = 64 + } + return value +} + +func segmentedSelectorShard(provider account.Provider, upstreamModel, quotaMode string) uint64 { + hash := fnv.New64a() + _, _ = hash.Write([]byte(provider)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(upstreamModel)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(quotaMode)) + return hash.Sum64() % segmentedSelectorShards +} + +func (o *selectorLeaseObservation) completeRelease() { + if o == nil { + return + } + if o.upstreamStarted.Load() { + o.record("failed") + return + } + o.record("skipped") +} + +func (o *selectorLeaseObservation) complete(success bool) { + if o == nil { + return + } + outcome := "failed" + if success { + outcome = "success" + } + o.record(outcome) +} + +func (o *selectorLeaseObservation) record(outcome string) { + if o == nil || !o.completed.CompareAndSwap(false, true) { + return + } + perfmetrics.Default.Inc("selector_segmented_active_upstream_total", perfmetrics.Labels{ + Subsystem: "selector", Operation: "segmented_active_upstream", Provider: string(o.provider), + Stage: o.stage, Outcome: outcome, + }) +} + +func segmentedSelectorCohortBetter(left, right segmentedSelectorCohort) bool { + if left.supportsModel != right.supportsModel { + return left.supportsModel + } + if left.capabilityKnown != right.capabilityKnown { + return left.capabilityKnown + } + if left.preferFreeBuild != right.preferFreeBuild { + return left.preferFreeBuild + } + if left.tier != right.tier { + return left.tier < right.tier + } + if left.priority != right.priority { + return left.priority > right.priority + } + if left.billingFresh != right.billingFresh { + return left.billingFresh + } + return false +} diff --git a/backend/internal/application/gateway/selector_segmented_active.go b/backend/internal/application/gateway/selector_segmented_active.go new file mode 100644 index 0000000000000000000000000000000000000000..948ff5b098f2968087c7ba5ed7501b2eaa88bf14 --- /dev/null +++ b/backend/internal/application/gateway/selector_segmented_active.go @@ -0,0 +1,245 @@ +package gateway + +import ( + "context" + "errors" + "sort" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" +) + +type segmentedSelectorActiveRequest struct { + provider account.Provider + windowSize int + cursor uint64 +} + +type segmentedSelectorCohortBucket struct { + cohort segmentedSelectorCohort + indexes []int +} + +type segmentedClaimResult struct { + lease *accountLease + staleClaims int + capacityMisses int +} + +const segmentedWindowsBeforeFullFallback = 4 + +func (s *Selector) nextSegmentedActiveRequest(provider account.Provider, upstreamModel, quotaMode string, candidateCount int) *segmentedSelectorActiveRequest { + s.configMu.RLock() + config := s.segmentedConfig + s.configMu.RUnlock() + if !config.enabled || candidateCount < config.minCandidates { + return nil + } + shard := segmentedSelectorShard(provider, upstreamModel, quotaMode) + cursor := s.segmentedState.activeCursors[shard].Add(uint64(config.windowSize)) - uint64(config.windowSize) + return &segmentedSelectorActiveRequest{provider: provider, windowSize: config.windowSize, cursor: cursor} +} + +func (s *Selector) acquireSegmentedCandidates(ctx context.Context, values []account.RoutingCandidate, indexes []int, quotaMode string, tierOrder []account.WebTier, request segmentedSelectorActiveRequest) (*accountLease, error) { + startedAt := time.Now() + _, _, _, capacityWait := s.routingConfig() + waitDeadline := time.Now().Add(capacityWait) + windowsScanned := 0 + candidatesScanned := 0 + fullPlannerOnly := false + preferFreeBuild := s.preferFreeBuildEnabled() + for { + now := time.Now().UTC() + if fullPlannerOnly { + length := len(indexes) + if indexes == nil { + length = len(values) + } + candidatesScanned += length + plan, err := s.planCandidateIndexesWithHints(ctx, values, indexes, now, tierOrder, nil, preferFreeBuild) + if err != nil { + observeSegmentedActive(request.provider, "error", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + claim, err := s.claimSegmentedPlan(ctx, plan, request.provider, quotaMode, "full_fallback") + if err != nil { + observeSegmentedActive(request.provider, "error", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + if claim.lease != nil { + observeSegmentedActive(request.provider, "selected", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return claim.lease, nil + } + if claim.staleClaims > 0 && claim.capacityMisses == 0 { + observeSegmentedActive(request.provider, "unavailable", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + } else { + concurrencyHints := make([]int, len(values)) + cohorts := segmentedCandidateCohorts(values, indexes, now, tierOrder, preferFreeBuild) + roundWindows := 0 + fallbackToFull := false + for cohortIndex, bucket := range cohorts { + for windowOffset := 0; windowOffset < len(bucket.indexes); windowOffset += request.windowSize { + windowIndexes := segmentedCohortWindow(bucket.indexes, request.cursor, windowOffset, request.windowSize) + windowsScanned++ + roundWindows++ + candidatesScanned += len(windowIndexes) + plan, err := s.planCandidateIndexesWithHints(ctx, values, windowIndexes, now, tierOrder, concurrencyHints, preferFreeBuild) + if err != nil { + observeSegmentedActive(request.provider, "error", "planning", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + stage := segmentedActiveSelectionStage(cohortIndex, windowOffset) + claim, err := s.claimSegmentedPlan(ctx, plan, request.provider, quotaMode, stage) + if err != nil { + observeSegmentedActive(request.provider, "error", "claim", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + if claim.lease != nil { + observeSegmentedActive(request.provider, "selected", stage, startedAt, windowsScanned, candidatesScanned) + return claim.lease, nil + } + if roundWindows >= segmentedWindowsBeforeFullFallback { + fallbackToFull = true + break + } + } + if fallbackToFull { + break + } + } + if fallbackToFull { + length := len(indexes) + if indexes == nil { + length = len(values) + } + candidatesScanned += length + plan, err := s.planCandidateIndexesWithHints(ctx, values, indexes, now, tierOrder, concurrencyHints, preferFreeBuild) + if err != nil { + observeSegmentedActive(request.provider, "error", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + claim, err := s.claimSegmentedPlan(ctx, plan, request.provider, quotaMode, "full_fallback") + if err != nil { + observeSegmentedActive(request.provider, "error", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + if claim.lease != nil { + observeSegmentedActive(request.provider, "selected", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return claim.lease, nil + } + if claim.staleClaims > 0 && claim.capacityMisses == 0 { + observeSegmentedActive(request.provider, "unavailable", "full_fallback", startedAt, windowsScanned, candidatesScanned) + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts} + } + } + fullPlannerOnly = true + } + if capacityWait <= 0 { + observeSegmentedActive(request.provider, "saturated", "exhausted", startedAt, windowsScanned, candidatesScanned) + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + retry, err := s.awaitLeaseRetry(ctx, waitDeadline) + if err != nil { + observeSegmentedActive(request.provider, "error", "wait", startedAt, windowsScanned, candidatesScanned) + return nil, err + } + if !retry { + observeSegmentedActive(request.provider, "saturated", "timeout", startedAt, windowsScanned, candidatesScanned) + return nil, &SelectionUnavailableError{Reason: SelectionSaturated, RetryAfter: time.Second} + } + } +} + +func (s *Selector) claimSegmentedPlan(ctx context.Context, plan *candidatePlan, provider account.Provider, quotaMode, stage string) (segmentedClaimResult, error) { + result := segmentedClaimResult{} + for candidate, ok := plan.Next(); ok; candidate, ok = plan.Next() { + lease, err := s.claimAccountSlot(ctx, candidate.Credential) + if err != nil { + if errors.Is(err, errRoutingCredentialStale) { + result.staleClaims++ + continue + } + return segmentedClaimResult{}, err + } + if lease == nil { + result.capacityMisses++ + continue + } + lease.Billing = candidate.Billing + lease.QuotaMode = effectiveQuotaMode(candidate, quotaMode) + lease.selectorObservation = &selectorLeaseObservation{provider: provider, stage: stage} + result.lease = lease + return result, nil + } + return result, nil +} + +func segmentedCandidateCohorts(values []account.RoutingCandidate, indexes []int, now time.Time, tierOrder []account.WebTier, preferFreeBuild bool) []segmentedSelectorCohortBucket { + buckets := make(map[segmentedSelectorCohort][]int) + appendCandidate := func(index int) { + candidate := values[index] + cohort := segmentedSelectorCohort{ + supportsModel: candidate.SupportsModel, capabilityKnown: candidate.ModelCapabilityKnown, + preferFreeBuild: preferFreeBuild && candidate.IsKnownFreeBuild(), + tier: tierOrderRank(tierOrder, candidate.Credential.WebTier), priority: candidate.Credential.Priority, + } + if candidate.Billing != nil { + cohort.billingFresh = now.Sub(candidate.Billing.SyncedAt) <= 30*time.Minute + } + buckets[cohort] = append(buckets[cohort], index) + } + if indexes == nil { + for index := range values { + appendCandidate(index) + } + } else { + for _, index := range indexes { + appendCandidate(index) + } + } + result := make([]segmentedSelectorCohortBucket, 0, len(buckets)) + for cohort, cohortIndexes := range buckets { + result = append(result, segmentedSelectorCohortBucket{cohort: cohort, indexes: cohortIndexes}) + } + sort.Slice(result, func(left, right int) bool { + return segmentedSelectorCohortBetter(result[left].cohort, result[right].cohort) + }) + return result +} + +func segmentedCohortWindow(indexes []int, cursor uint64, offset, windowSize int) []int { + if len(indexes) == 0 || offset >= len(indexes) || windowSize <= 0 { + return nil + } + count := min(windowSize, len(indexes)-offset) + start := int(cursor % uint64(len(indexes))) + result := make([]int, count) + for position := range count { + result[position] = indexes[(start+offset+position)%len(indexes)] + } + return result +} + +func segmentedActiveSelectionStage(cohortIndex, windowOffset int) string { + if cohortIndex > 0 { + return "later_cohort" + } + if windowOffset > 0 { + return "later_window" + } + return "first_window" +} + +func observeSegmentedActive(provider account.Provider, outcome, stage string, startedAt time.Time, windows, candidates int) { + labels := perfmetrics.Labels{ + Subsystem: "selector", Operation: "segmented_active", Provider: string(provider), + Stage: stage, Outcome: outcome, + } + perfmetrics.Default.Inc("selector_segmented_active_total", labels) + perfmetrics.Default.ObserveDuration("selector_segmented_active_duration_us", labels, time.Since(startedAt)) + perfmetrics.Default.Add("selector_segmented_active_windows", labels, int64(windows)) + perfmetrics.Default.Add("selector_segmented_active_candidates", labels, int64(candidates)) +} diff --git a/backend/internal/application/gateway/selector_segmented_active_test.go b/backend/internal/application/gateway/selector_segmented_active_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2500741adea0f8bf156dca7915664cc0f5f3f10a --- /dev/null +++ b/backend/internal/application/gateway/selector_segmented_active_test.go @@ -0,0 +1,557 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/chenyme/grok2api/backend/internal/pkg/resultcache" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func BenchmarkSelectorSegmentedCandidatePlanning(b *testing.B) { + for _, candidateCount := range []int{3000, 10000} { + b.Run(fmt.Sprintf("%d/full", candidateCount), func(b *testing.B) { + benchmarkSegmentedSelector(b, candidateCount, false, false) + }) + b.Run(fmt.Sprintf("%d/active_segmented_64", candidateCount), func(b *testing.B) { + benchmarkSegmentedSelector(b, candidateCount, true, false) + }) + b.Run(fmt.Sprintf("%d/active_segmented_64_full_fallback", candidateCount), func(b *testing.B) { + benchmarkSegmentedSelector(b, candidateCount, true, true) + }) + } +} + +func benchmarkSegmentedSelector(b *testing.B, candidateCount int, enabled, forceFullFallback bool) { + b.Helper() + limiter := newSegmentedSelectiveLimiter() + if forceFullFallback { + for accountID := uint64(1); accountID <= segmentedWindowsBeforeFullFallback*64; accountID++ { + limiter.SetSaturated(accountID, true) + } + } + selector := newSegmentedActiveTestSelector(candidateCount, limiter, nil) + selector.UpdateSegmentedSelector(enabled, 3000, 64) + selector.concurrencySnapshots = resultcache.New[[32]byte, map[string]int](maxConcurrencySnapshots, time.Nanosecond) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if forceFullFallback { + shard := segmentedSelectorShard(account.ProviderBuild, "benchmark-model", "") + selector.segmentedState.activeCursors[shard].Store(0) + } + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "benchmark-model", "", "", nil, false) + if err != nil { + b.Fatal(err) + } + lease.Release() + } +} + +func TestSegmentedActiveReadsOnlyFirstAvailableWindow(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + selector := newSegmentedActiveTestSelector(100, limiter, nil) + selector.UpdateSegmentedSelector(true, 100, 8) + + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 1 { + t.Fatalf("selected account = %d, want 1", lease.Credential.ID) + } + if sizes := limiter.BatchSizes(); fmt.Sprint(sizes) != "[8]" { + t.Fatalf("concurrency batch sizes = %v, want one window", sizes) + } + if observation := lease.selectorObservation; observation == nil || observation.stage != "first_window" { + t.Fatalf("active observation = %#v", observation) + } +} + +func TestSegmentedActiveCursorIsIndependentPerRouteShard(t *testing.T) { + selector := NewSelector(nil, nil, nil, nil, time.Hour, time.Second, time.Minute) + selector.UpdateSegmentedSelector(true, 100, 8) + firstModel := "model-a" + firstShard := segmentedSelectorShard(account.ProviderBuild, firstModel, "") + secondModel := "model-b" + for segmentedSelectorShard(account.ProviderBuild, secondModel, "") == firstShard { + secondModel += "-next" + } + first := selector.nextSegmentedActiveRequest(account.ProviderBuild, firstModel, "", 100) + second := selector.nextSegmentedActiveRequest(account.ProviderBuild, firstModel, "", 100) + independent := selector.nextSegmentedActiveRequest(account.ProviderBuild, secondModel, "", 100) + if first == nil || first.cursor != 0 || second == nil || second.cursor != 8 || independent == nil || independent.cursor != 0 { + t.Fatalf("active cursors = first:%#v second:%#v independent:%#v", first, second, independent) + } +} + +func TestSegmentedActiveRotatesWindowStartPerRoute(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + selector := newSegmentedActiveTestSelector(100, limiter, nil) + selector.UpdateSegmentedSelector(true, 100, 8) + wanted := []uint64{1, 9, 17} + for index, expected := range wanted { + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + if lease.Credential.ID != expected { + lease.Release() + t.Fatalf("selection %d = %d, want %d", index, lease.Credential.ID, expected) + } + lease.Release() + } +} + +func TestSegmentedActiveContinuesAfterSaturatedWindow(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + for id := uint64(1); id <= 8; id++ { + limiter.SetSaturated(id, true) + } + selector := newSegmentedActiveTestSelector(100, limiter, nil) + selector.UpdateSegmentedSelector(true, 100, 8) + + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 9 { + t.Fatalf("selected account = %d, want 9", lease.Credential.ID) + } + if sizes := limiter.BatchSizes(); fmt.Sprint(sizes) != "[8 8]" { + t.Fatalf("concurrency batch sizes = %v, want two windows", sizes) + } + if lease.selectorObservation == nil || lease.selectorObservation.stage != "later_window" { + t.Fatalf("selection stage = %#v", lease.selectorObservation) + } +} + +func TestSegmentedActiveExhaustsHigherPriorityCohortBeforeFallingBack(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + priorities := make(map[uint64]int) + for id := uint64(1); id <= 8; id++ { + priorities[id] = 10 + limiter.SetSaturated(id, true) + } + for id := uint64(9); id <= 100; id++ { + priorities[id] = 1 + } + selector := newSegmentedActiveTestSelector(100, limiter, priorities) + selector.UpdateSegmentedSelector(true, 100, 8) + + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 9 { + t.Fatalf("selected account = %d, want first lower-priority available account 9", lease.Credential.ID) + } + if lease.selectorObservation == nil || lease.selectorObservation.stage != "later_cohort" { + t.Fatalf("selection stage = %#v", lease.selectorObservation) + } +} + +func TestSegmentedActiveCohortOrderingMatchesFullPlannerHardOrder(t *testing.T) { + cohorts := make([]segmentedSelectorCohort, 0, 64) + for _, supportsModel := range []bool{false, true} { + for _, capabilityKnown := range []bool{false, true} { + for _, preferFreeBuild := range []bool{false, true} { + for _, tier := range []int{0, 2} { + for _, priority := range []int{1, 10} { + for _, billingFresh := range []bool{false, true} { + cohorts = append(cohorts, segmentedSelectorCohort{ + supportsModel: supportsModel, capabilityKnown: capabilityKnown, + preferFreeBuild: preferFreeBuild, tier: tier, priority: priority, + billingFresh: billingFresh, + }) + } + } + } + } + } + } + for leftIndex, left := range cohorts { + for rightIndex, right := range cohorts { + if left == right { + continue + } + values := []account.RoutingCandidate{ + {Credential: account.Credential{ID: 1, Priority: left.priority}, SupportsModel: left.supportsModel, ModelCapabilityKnown: left.capabilityKnown}, + {Credential: account.Credential{ID: 2, Priority: right.priority}, SupportsModel: right.supportsModel, ModelCapabilityKnown: right.capabilityKnown}, + } + scores := []candidateScore{ + {index: 0, tier: left.tier, preferFreeBuild: left.preferFreeBuild, billingFresh: left.billingFresh}, + {index: 1, tier: right.tier, preferFreeBuild: right.preferFreeBuild, billingFresh: right.billingFresh}, + } + if got, want := segmentedSelectorCohortBetter(left, right), candidateScoreBetter(values, scores[0], scores[1]); got != want { + t.Fatalf("cohort order mismatch at %d/%d: got %t want %t", leftIndex, rightIndex, got, want) + } + } + } +} + +func TestSegmentedPlannerUsesOnePreferFreeBuildSnapshot(t *testing.T) { + now := time.Now().UTC() + values := []account.RoutingCandidate{ + { + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, Priority: 100}, + Billing: &account.Billing{PlanName: "SuperGrok", SyncedAt: now}, + }, + { + Credential: account.Credential{ID: 2, Provider: account.ProviderBuild, Priority: 1}, + Billing: &account.Billing{PlanName: "Free", SyncedAt: now}, + }, + } + selector := NewSelector(nil, memory.NewConcurrencyLimiter(), nil, nil, time.Hour, time.Second, time.Minute) + selector.UpdatePreferFreeBuild(true) + plan, err := selector.planCandidateIndexesWithHints(context.Background(), values, nil, now, nil, nil, false) + if err != nil { + t.Fatal(err) + } + selected, ok := plan.Next() + if !ok || selected.Credential.ID != 1 { + t.Fatalf("disabled snapshot selected account %d, want higher-priority account 1", selected.Credential.ID) + } + + selector.UpdatePreferFreeBuild(false) + plan, err = selector.planCandidateIndexesWithHints(context.Background(), values, nil, now, nil, nil, true) + if err != nil { + t.Fatal(err) + } + selected, ok = plan.Next() + if !ok || selected.Credential.ID != 2 { + t.Fatalf("enabled snapshot selected account %d, want Free account 2", selected.Credential.ID) + } +} + +func TestSegmentedActiveScansEveryCandidateBeforeSaturated(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + for id := uint64(1); id <= 100; id++ { + limiter.SetSaturated(id, true) + } + selector := newSegmentedActiveTestSelector(100, limiter, nil) + selector.UpdateSegmentedSelector(true, 100, 8) + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionSaturated { + t.Fatalf("error = %v", err) + } + if sizes := limiter.BatchSizes(); fmt.Sprint(sizes) != "[8 8 8 8 68]" { + t.Fatalf("concurrency batch sizes = %v, want bounded windows followed by one full fallback", sizes) + } +} + +func TestSegmentedActiveReportsNoAccountsWhenEveryCredentialIsStale(t *testing.T) { + selector := newSegmentedActiveTestSelector(8, newSegmentedSelectiveLimiter(), nil) + selector.UpdateSegmentedSelector(true, 8, 4) + repo := selector.accounts.(*layeredAccountRepository) + repo.materialErrors = make(map[uint64]error, 8) + for accountID := uint64(1); accountID <= 8; accountID++ { + repo.materialErrors[accountID] = repository.ErrNotFound + } + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionNoAccounts { + t.Fatalf("error = %v, want no accounts", err) + } +} + +func TestSegmentedActiveFallsBackToFullPlannerAfterBoundedWindows(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + priorities := make(map[uint64]int) + for id := uint64(1); id <= 32; id++ { + limiter.SetSaturated(id, true) + } + for id := uint64(1); id <= 40; id++ { + priorities[id] = 10 + } + for id := uint64(41); id <= 100; id++ { + priorities[id] = 1 + } + selector := newSegmentedActiveTestSelector(100, limiter, priorities) + selector.UpdateSegmentedSelector(true, 100, 8) + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 33 { + t.Fatalf("selected account = %d, want higher-priority full fallback account 33", lease.Credential.ID) + } + if lease.selectorObservation == nil || lease.selectorObservation.stage != "full_fallback" { + t.Fatalf("selection stage = %#v", lease.selectorObservation) + } + if sizes := limiter.BatchSizes(); fmt.Sprint(sizes) != "[8 8 8 8 68]" { + t.Fatalf("concurrency batch sizes = %v", sizes) + } +} + +func TestSegmentedActiveWaitsAndRescansAfterCapacityReturns(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + for id := uint64(1); id <= 100; id++ { + limiter.SetSaturated(id, true) + } + selector := newSegmentedActiveTestSelectorWithWait(100, limiter, nil, 200*time.Millisecond) + selector.UpdateSegmentedSelector(true, 100, 8) + startedAt := time.Now() + go func() { + time.Sleep(10 * time.Millisecond) + limiter.SetSaturated(1, false) + selector.announceLeaseReturn() + }() + + lease, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != 1 { + t.Fatalf("selected account = %d, want released account 1", lease.Credential.ID) + } + if time.Since(startedAt) < 5*time.Millisecond { + t.Fatal("selector returned before capacity was released") + } + if sizes := limiter.BatchSizes(); fmt.Sprint(sizes) != "[8 8 8 8 68 100]" { + t.Fatalf("capacity retry did not switch to one full plan: %v", sizes) + } +} + +func TestSegmentedActiveDoesNotRepeatWindowsAfterFullFallback(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + for id := uint64(1); id <= 100; id++ { + limiter.SetSaturated(id, true) + } + selector := newSegmentedActiveTestSelectorWithWait(100, limiter, nil, 100*time.Millisecond) + selector.UpdateSegmentedSelector(true, 100, 8) + go func() { + time.Sleep(5 * time.Millisecond) + selector.announceLeaseReturn() + }() + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionSaturated { + t.Fatalf("error = %v", err) + } + sizes := limiter.BatchSizes() + if len(sizes) < 6 || fmt.Sprint(sizes[:5]) != "[8 8 8 8 68]" { + t.Fatalf("initial segmented round = %v", sizes) + } + for _, size := range sizes[5:] { + if size != 100 { + t.Fatalf("capacity retry repeated segmented windows: %v", sizes) + } + } +} + +func TestSegmentedActiveUsesFullPlannerAfterExhaustingSmallPool(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + for id := uint64(1); id <= 100; id++ { + limiter.SetSaturated(id, true) + } + selector := newSegmentedActiveTestSelectorWithWait(100, limiter, nil, 100*time.Millisecond) + selector.UpdateSegmentedSelector(true, 100, 64) + go func() { + time.Sleep(5 * time.Millisecond) + selector.announceLeaseReturn() + }() + + _, err := selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", "", nil, false) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionSaturated { + t.Fatalf("error = %v", err) + } + sizes := limiter.BatchSizes() + if len(sizes) < 3 || fmt.Sprint(sizes[:2]) != "[64 36]" { + t.Fatalf("initial complete-pool windows = %v", sizes) + } + for _, size := range sizes[2:] { + if size != 100 { + t.Fatalf("capacity retry repeated small-pool windows: %v", sizes) + } + } +} + +func TestSegmentedActiveSkipsStickyPinnedAndSmallPools(t *testing.T) { + tests := []struct { + name string + count int + affinity string + pinned bool + enabled bool + }{ + {name: "disabled", count: 100}, + {name: "small pool", count: 99, enabled: true}, + {name: "sticky", count: 100, affinity: "session", enabled: true}, + {name: "pinned", count: 100, pinned: true, enabled: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + limiter := newSegmentedSelectiveLimiter() + selector := newSegmentedActiveTestSelector(test.count, limiter, nil) + selector.UpdateSegmentedSelector(test.enabled, 100, 8) + var lease *accountLease + var err error + if test.pinned { + lease, err = selector.AcquirePinned(context.Background(), account.ProviderBuild, 1, 0, "model", "", true) + } else { + lease, err = selector.Acquire(context.Background(), account.ProviderBuild, 0, "model", "", test.affinity, nil, false) + } + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.selectorObservation != nil { + t.Fatalf("non-active path received observation: %#v", lease.selectorObservation) + } + if activeSegmentedCursorCount(selector) != 0 { + t.Fatal("non-active path advanced the segmented cursor") + } + }) + } +} + +func TestSegmentedActiveLifecycleRecordsFinalOutcomeOnce(t *testing.T) { + tests := []struct { + name string + outcome string + record func(*selectorLeaseObservation) + }{ + {name: "success", outcome: "success", record: func(value *selectorLeaseObservation) { + value.upstreamStarted.Store(true) + value.complete(true) + value.completeRelease() + }}, + {name: "explicit failure", outcome: "failed", record: func(value *selectorLeaseObservation) { + value.upstreamStarted.Store(true) + value.complete(false) + value.completeRelease() + }}, + {name: "abandoned after upstream start", outcome: "failed", record: func(value *selectorLeaseObservation) { + value.upstreamStarted.Store(true) + value.completeRelease() + }}, + {name: "released before upstream start", outcome: "skipped", record: func(value *selectorLeaseObservation) { + value.completeRelease() + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + defer func() { perfmetrics.Default = previous }() + observation := &selectorLeaseObservation{provider: account.ProviderBuild, stage: "first_window"} + test.record(observation) + assertSegmentedMetric(t, registry.CollectAndReset(), "selector_segmented_active_upstream_total", "first_window", test.outcome, 1) + }) + } +} + +func newSegmentedActiveTestSelector(count int, limiter repository.ConcurrencyLimiter, priorities map[uint64]int) *Selector { + return newSegmentedActiveTestSelectorWithWait(count, limiter, priorities, 0) +} + +func newSegmentedActiveTestSelectorWithWait(count int, limiter repository.ConcurrencyLimiter, priorities map[uint64]int, capacityWait time.Duration) *Selector { + bases := make([]account.RoutingAccountBase, count) + for index := range bases { + id := uint64(index + 1) + priority := 10 + if value, ok := priorities[id]; ok { + priority = value + } + bases[index] = account.RoutingAccountBase{Credential: account.Credential{ + ID: id, Provider: account.ProviderBuild, AuthStatus: account.AuthStatusActive, + Enabled: true, Priority: priority, MaxConcurrent: account.DefaultMaxConcurrent, + }} + } + repository := &layeredAccountRepository{bases: bases, overlays: map[string]account.RoutingOverlaySnapshot{"model": {}}} + return NewSelector(repository, limiter, memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute, capacityWait) +} + +func activeSegmentedCursorCount(selector *Selector) uint64 { + var total uint64 + for index := range selector.segmentedState.activeCursors { + total += selector.segmentedState.activeCursors[index].Load() + } + return total +} + +func assertSegmentedMetric(t *testing.T, samples []perfmetrics.Sample, name, stage, outcome string, total int64) { + t.Helper() + for _, sample := range samples { + if sample.Name == name && sample.Labels.Stage == stage && sample.Labels.Outcome == outcome { + if sample.Total != total { + t.Fatalf("metric %s/%s/%s total = %d, want %d", name, stage, outcome, sample.Total, total) + } + return + } + } + t.Fatalf("metric %s/%s/%s not found in %#v", name, stage, outcome, samples) +} + +type segmentedSelectiveLimiter struct { + mu sync.Mutex + saturated map[string]bool + batchSizes []int +} + +func newSegmentedSelectiveLimiter() *segmentedSelectiveLimiter { + return &segmentedSelectiveLimiter{saturated: make(map[string]bool)} +} + +func (l *segmentedSelectiveLimiter) SetSaturated(accountID uint64, value bool) { + l.mu.Lock() + l.saturated[repository.AccountConcurrencyKey(accountID)] = value + l.mu.Unlock() +} + +func (l *segmentedSelectiveLimiter) Acquire(_ context.Context, key string, _ int) (func(), bool, error) { + l.mu.Lock() + saturated := l.saturated[key] + l.mu.Unlock() + if saturated { + return nil, false, nil + } + return func() {}, true, nil +} + +func (l *segmentedSelectiveLimiter) Current(_ context.Context, key string) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.saturated[key] { + return account.DefaultMaxConcurrent, nil + } + return 0, nil +} + +func (l *segmentedSelectiveLimiter) CurrentMany(_ context.Context, keys []string) (map[string]int, error) { + l.mu.Lock() + defer l.mu.Unlock() + l.batchSizes = append(l.batchSizes, len(keys)) + result := make(map[string]int, len(keys)) + for _, key := range keys { + if l.saturated[key] { + result[key] = account.DefaultMaxConcurrent + } + } + return result, nil +} + +func (l *segmentedSelectiveLimiter) BatchSizes() []int { + l.mu.Lock() + defer l.mu.Unlock() + return append([]int(nil), l.batchSizes...) +} diff --git a/backend/internal/application/gateway/selector_test.go b/backend/internal/application/gateway/selector_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e735509e6aa7b1605f926293b9cd4abe08718902 --- /dev/null +++ b/backend/internal/application/gateway/selector_test.go @@ -0,0 +1,1077 @@ +package gateway + +import ( + "context" + "errors" + "net/http" + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestSelectionUnavailableErrorClassification(t *testing.T) { + tests := []struct { + reason SelectionUnavailableReason + status int + code string + }{ + {reason: SelectionNoAccounts, status: http.StatusServiceUnavailable, code: "upstream_unavailable"}, + {reason: SelectionUnsupportedModel, status: http.StatusServiceUnavailable, code: "upstream_model_unavailable"}, + {reason: SelectionCooling, status: http.StatusTooManyRequests, code: "upstream_cooling"}, + {reason: SelectionModelCooling, status: http.StatusTooManyRequests, code: "upstream_model_cooling"}, + {reason: SelectionQuotaExhausted, status: http.StatusTooManyRequests, code: "upstream_quota_exhausted"}, + {reason: SelectionSaturated, status: http.StatusServiceUnavailable, code: "upstream_saturated"}, + } + for _, test := range tests { + t.Run(string(test.reason), func(t *testing.T) { + failure := &SelectionUnavailableError{Reason: test.reason} + if failure.HTTPStatus() != test.status || failure.Code() != test.code { + t.Fatalf("status=%d code=%q", failure.HTTPStatus(), failure.Code()) + } + }) + } +} + +func TestSelectorPrioritizesDueQuotaProbeOnce(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + accounts := relational.NewAccountRepository(database) + probe, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "probe", SourceKey: "probe", EncryptedAccessToken: "encrypted", Enabled: true, + AuthStatus: account.AuthStatusActive, Priority: 10, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + active, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "active", SourceKey: "active", EncryptedAccessToken: "encrypted", Enabled: true, + AuthStatus: account.AuthStatusActive, Priority: 200, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + due := now.Add(-time.Minute) + if err := accounts.SaveQuotaRecovery(ctx, account.QuotaRecovery{ + AccountID: probe.ID, Kind: account.QuotaRecoveryKindFree, Status: account.QuotaRecoveryStatusExhausted, + ConfirmedUsed: 1_065_387, ConfirmedLimit: 1_000_000, + ExhaustedAt: &now, NextProbeAt: &due, LastConfirmedAt: &now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-test", "", "", map[uint64]bool{}, true) + if err != nil { + t.Fatal(err) + } + if lease.Credential.ID != probe.ID || !lease.QuotaProbe { + t.Fatalf("lease = %#v, want due probe account %d", lease, probe.ID) + } + lease.Release() + + lease, err = selector.Acquire(ctx, account.ProviderBuild, 0, "grok-test", "", "", map[uint64]bool{probe.ID: true}, false) + if err != nil { + t.Fatal(err) + } + if lease.Credential.ID != active.ID || lease.QuotaProbe { + t.Fatalf("lease = %#v, want active account %d", lease, active.ID) + } + lease.Release() + + selector.MarkSuccess(ctx, probe) + if _, err := accounts.GetQuotaRecovery(ctx, probe.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("quota recovery should be cleared, err = %v", err) + } +} + +func BenchmarkSelectorCandidatePlanning(b *testing.B) { + ctx := context.Background() + limiter := memory.NewConcurrencyLimiter() + selector := NewSelector(nil, limiter, nil, nil, time.Hour, time.Second, time.Minute) + now := time.Now().UTC() + candidates := make([]account.RoutingCandidate, 3000) + for index := range candidates { + id := uint64(index + 1) + billing := account.Billing{ + AccountID: id, MonthlyLimit: 1_000_000, Used: float64(index % 1000), SyncedAt: now.Add(-time.Duration(index%60) * time.Minute), + } + candidates[index] = account.RoutingCandidate{ + Credential: account.Credential{ + ID: id, Provider: account.ProviderBuild, AuthStatus: account.AuthStatusActive, + Priority: index % 10, MaxConcurrent: account.DefaultMaxConcurrent, + }, + Billing: &billing, ModelCapabilityKnown: true, SupportsModel: true, + } + } + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(parallel *testing.PB) { + for parallel.Next() { + plan, err := selector.planCandidates(ctx, candidates, now, nil) + if err != nil { + b.Fatal(err) + } + if _, ok := plan.Next(); !ok { + b.Fatal("候选计划为空") + } + } + }) +} + +func TestSelectorSkipsQuotaProbeBeforeDue(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + accounts := relational.NewAccountRepository(database) + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "waiting", SourceKey: "waiting", EncryptedAccessToken: "encrypted", Enabled: true, + AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + next := now.Add(time.Hour) + if err := accounts.SaveQuotaRecovery(ctx, account.QuotaRecovery{ + AccountID: value.ID, Kind: account.QuotaRecoveryKindFree, Status: account.QuotaRecoveryStatusExhausted, + NextProbeAt: &next, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + if _, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-test", "", "", map[uint64]bool{}, true); err == nil { + t.Fatal("expected no account before next probe time") + } +} + +func TestSelectorQuotaRecoveryUsesFixedFreeAndUpstreamPaidReset(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-recovery.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "build", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + + paidPeriodEnd := now.Add(7 * time.Hour).Truncate(time.Second) + selector.MarkPaymentQuotaExhausted(ctx, value, quotaRecoveryHints{Billing: &account.Billing{ + AccountID: value.ID, PlanCode: "super", MonthlyLimit: 100, BillingPeriodEnd: paidPeriodEnd.Format(time.RFC3339), + }}) + recovery := requireQuotaRecovery(t, ctx, accounts, value.ID) + if recovery.Kind != account.QuotaRecoveryKindPaid || recovery.NextProbeAt == nil || !recovery.NextProbeAt.Equal(paidPeriodEnd) { + t.Fatalf("paid recovery = %#v", recovery) + } + + freeStarted := time.Now().UTC() + selector.MarkPaymentQuotaExhausted(ctx, value, quotaRecoveryHints{}) + recovery = requireQuotaRecovery(t, ctx, accounts, value.ID) + if recovery.Kind != account.QuotaRecoveryKindFree { + t.Fatalf("free recovery = %#v", recovery) + } + assertRecoveryDelay(t, recovery, freeStarted, defaultFreeQuotaRecoveryPause) + + freeStarted = time.Now().UTC() + selector.MarkFreeQuotaExhausted(ctx, value, 100, 100) + recovery = requireQuotaRecovery(t, ctx, accounts, value.ID) + assertRecoveryDelay(t, recovery, freeStarted, defaultFreeQuotaRecoveryPause) +} + +func TestSelectorModelQuotaUsesFixedFreeAndUpstreamPaidDelay(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-quota-recovery.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "model-quota-build", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, ObservedModel: "grok-4.5-build-free", + }) + if err != nil { + t.Fatal(err) + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + freeStarted := time.Now().UTC() + selector.MarkModelQuotaExhausted(ctx, value, &account.Billing{PlanName: "free"}, "free-model", time.Hour) + freeCandidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "free-model", "") + if err != nil || len(freeCandidates) != 1 || freeCandidates[0].ModelQuotaBlock == nil { + t.Fatalf("free candidates = %#v, err = %v", freeCandidates, err) + } + assertTimeDelay(t, freeCandidates[0].ModelQuotaBlock.CooldownUntil, freeStarted, defaultFreeQuotaRecoveryPause) + + paidStarted := time.Now().UTC() + selector.MarkModelQuotaExhausted(ctx, value, &account.Billing{PlanName: "SuperGrok"}, "paid-model", 2*time.Hour) + paidCandidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "paid-model", "") + if err != nil || len(paidCandidates) != 1 || paidCandidates[0].ModelQuotaBlock == nil { + t.Fatalf("paid candidates = %#v, err = %v", paidCandidates, err) + } + assertTimeDelay(t, paidCandidates[0].ModelQuotaBlock.CooldownUntil, paidStarted, 2*time.Hour) +} + +func TestSelectorModelQuotaPreservesSessionAffinity(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-quota-sticky.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "build", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + fallback, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "fallback", SourceKey: "fallback", EncryptedAccessToken: "encrypted-fallback", + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 50, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + sticky := memory.NewStickyStore() + expiresAt := time.Now().UTC().Add(time.Hour) + for _, key := range []string{"model-a-session", "model-b-session"} { + if err := sticky.Set(ctx, stickySessionKey(key), credential.ID, expiresAt); err != nil { + t.Fatal(err) + } + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), sticky, nil, time.Hour, time.Second, time.Minute) + selector.MarkModelQuotaExhausted(ctx, credential, nil, "model-a", time.Hour) + + for _, key := range []string{"model-a-session", "model-b-session"} { + accountID, ok, err := sticky.Get(ctx, stickySessionKey(key), time.Now().UTC()) + if err != nil || !ok || accountID != credential.ID { + t.Fatalf("sticky %q = account %d, ok=%v, err=%v", key, accountID, ok, err) + } + } + + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model-a", "", "model-a-session", nil, false) + if err != nil { + t.Fatal(err) + } + if lease.Credential.ID != fallback.ID { + t.Fatalf("blocked model session selected account %d, want fallback %d", lease.Credential.ID, fallback.ID) + } + lease.Release() + if accountID, ok, err := sticky.Get(ctx, stickySessionKey("model-a-session"), time.Now().UTC()); err != nil || !ok || accountID != fallback.ID { + t.Fatalf("affected sticky binding was not rebuilt: id=%d ok=%v err=%v", accountID, ok, err) + } + if accountID, ok, err := sticky.Get(ctx, stickySessionKey("model-b-session"), time.Now().UTC()); err != nil || !ok || accountID != credential.ID { + t.Fatalf("unrelated sticky binding changed: id=%d ok=%v err=%v", accountID, ok, err) + } +} + +func requireQuotaRecovery(t *testing.T, ctx context.Context, accounts repository.AccountRepository, accountID uint64) account.QuotaRecovery { + t.Helper() + recovery, err := accounts.GetQuotaRecovery(ctx, accountID) + if err != nil { + t.Fatal(err) + } + return recovery +} + +func assertRecoveryDelay(t *testing.T, recovery account.QuotaRecovery, started time.Time, delay time.Duration) { + t.Helper() + if recovery.NextProbeAt == nil { + t.Fatalf("recovery has no next probe: %#v", recovery) + } + assertTimeDelay(t, *recovery.NextProbeAt, started, delay) +} + +func assertTimeDelay(t *testing.T, actual, started time.Time, delay time.Duration) { + t.Helper() + want := started.Add(delay) + if actual.Before(want.Add(-time.Second)) || actual.After(want.Add(2*time.Second)) { + t.Fatalf("time = %s, want around %s", actual, want) + } +} + +func TestSelectorUsesPaidWeeklyPoolAsWebQuotaGate(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "weekly-web.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "paid-web", SourceKey: "paid-web", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + resetAt := now.Add(7 * 24 * time.Hour) + if err := accounts.SaveQuotaWindows(ctx, value.ID, account.WebTierSuper, now, []account.QuotaWindow{ + {AccountID: value.ID, Mode: "weekly", Remaining: 0, Total: 10000, UsagePercent: 100, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream}, + {AccountID: value.ID, Mode: "fast", Remaining: 30, Total: 30, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream}, + }); err != nil { + t.Fatal(err) + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + if _, err := selector.Acquire(ctx, account.ProviderWeb, 0, "", "fast", "", nil, false); err == nil { + t.Fatal("exhausted weekly pool must take precedence over a stale fast quota window") + } + if err := accounts.SaveQuotaWindows(ctx, value.ID, account.WebTierSuper, now, []account.QuotaWindow{ + {AccountID: value.ID, Mode: "weekly", Remaining: 8900, Total: 10000, UsagePercent: 11, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream}, + {AccountID: value.ID, Mode: "fast", Remaining: 0, Total: 30, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream}, + }); err != nil { + t.Fatal(err) + } + selector.MarkQuotaStateChanged(account.ProviderWeb) + lease, err := selector.Acquire(ctx, account.ProviderWeb, 0, "", "fast", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.QuotaMode != "weekly" { + t.Fatalf("quota mode = %q, want weekly", lease.QuotaMode) + } +} + +func TestSelectorClaimsPaidBillingProbeAfterPeriodEnd(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "paid-probe.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "paid", SourceKey: "paid", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, MaxConcurrent: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + due := now.Add(-time.Minute) + if err := accounts.SaveQuotaRecovery(ctx, account.QuotaRecovery{AccountID: value.ID, Kind: account.QuotaRecoveryKindPaid, Status: account.QuotaRecoveryStatusExhausted, NextProbeAt: &due, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "", "", "", map[uint64]bool{}, true) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if !lease.QuotaProbe || lease.QuotaProbeKind != account.QuotaRecoveryKindPaid { + t.Fatalf("lease = %#v", lease) + } +} + +func TestSelectorOnlyUsesAccountsSupportingRequestedModel(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector-model.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + accounts := relational.NewAccountRepository(database) + models := relational.NewModelRepository(database) + unsupported, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "basic", SourceKey: "basic", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + Priority: 500, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + supported, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "premium", SourceKey: "premium", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: unsupported.ID, IsUnifiedBillingUser: true, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: supported.ID, MonthlyLimit: 100, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, unsupported.ID, []string{"grok-basic"}, now); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, supported.ID, []string{"grok-basic", "grok-premium"}, now); err != nil { + t.Fatal(err) + } + + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + selector.UpdatePreferFreeBuild(true) + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-premium", "", "", map[uint64]bool{}, true) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != supported.ID { + t.Fatalf("selected account = %d, want %d", lease.Credential.ID, supported.ID) + } +} + +func TestSelectorKeepsWebQuotaModesIsolated(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector-web-quota.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: "web", SourceKey: "web", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, MaxConcurrent: 2, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + resetAt := now.Add(time.Hour) + if err := accounts.SaveQuotaWindows(ctx, value.ID, account.WebTierSuper, now, []account.QuotaWindow{ + {AccountID: value.ID, Mode: "fast", Remaining: 0, Total: 20, ResetAt: &resetAt, Source: account.QuotaSourceUpstream}, + {AccountID: value.ID, Mode: "auto", Remaining: 5, Total: 10, ResetAt: &resetAt, Source: account.QuotaSourceUpstream}, + }); err != nil { + t.Fatal(err) + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + if _, err := selector.Acquire(ctx, account.ProviderWeb, 0, "grok-chat", "fast", "", nil, false); err == nil { + t.Fatal("exhausted fast mode should not be selected") + } + lease, err := selector.Acquire(ctx, account.ProviderWeb, 0, "grok-chat-auto", "auto", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != value.ID || lease.QuotaMode != "auto" { + t.Fatalf("lease = %#v", lease) + } +} + +func TestSelectorHonorsWebTierPoolOrderBeforeAccountPriority(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector-web-tier.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + for index, tier := range []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy} { + if _, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: tier, + Name: string(tier), SourceKey: string(tier), EncryptedAccessToken: "encrypted", + AuthStatus: account.AuthStatusActive, Priority: 300 - index*100, MaxConcurrent: 1, + }); err != nil { + t.Fatal(err) + } + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), staticTierOrder{order: []account.WebTier{account.WebTierHeavy, account.WebTierSuper, account.WebTierBasic}}, time.Hour, time.Second, time.Minute) + selector.UpdatePreferFreeBuild(true) + lease, err := selector.Acquire(ctx, account.ProviderWeb, 0, "fast-prefer-best", "fast", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.WebTier != account.WebTierHeavy { + t.Fatalf("selected tier = %s", lease.Credential.WebTier) + } +} + +func TestSelectorEnforcesClientKeyAccountScopeAcrossProvidersAndTiers(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector-client-key-pool.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + create := func(value account.Credential) account.Credential { + t.Helper() + created, _, createErr := accounts.UpsertByIdentity(ctx, value) + if createErr != nil { + t.Fatal(createErr) + } + return created + } + buildFree := create(account.Credential{Provider: account.ProviderBuild, Name: "build-free", SourceKey: "build-free", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 10, MaxConcurrent: 2}) + buildSuper := create(account.Credential{Provider: account.ProviderBuild, Name: "build-super", SourceKey: "build-super", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 20, MaxConcurrent: 2}) + buildUnknown := create(account.Credential{Provider: account.ProviderBuild, Name: "build-unknown", SourceKey: "build-unknown", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 2}) + now := time.Now().UTC() + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: buildFree.ID, PlanName: "Free", SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: buildSuper.ID, PlanName: "SuperGrok", SyncedAt: now}); err != nil { + t.Fatal(err) + } + + webFree := create(account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierBasic, Name: "web-free", SourceKey: "web-free", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 2}) + webSuper := create(account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, Name: "web-super", SourceKey: "web-super", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 20, MaxConcurrent: 2}) + webHeavy := create(account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierHeavy, Name: "web-heavy", SourceKey: "web-heavy", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 10, MaxConcurrent: 2}) + _ = create(account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierAuto, Name: "web-unknown", SourceKey: "web-unknown", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 200, MaxConcurrent: 2}) + console := create(account.Credential{Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", SourceKey: "console", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 10, MaxConcurrent: 2}) + + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), staticTierOrder{order: []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy}}, time.Hour, time.Second, time.Minute) + providerScope := func(provider account.Provider) clientkeydomain.ProviderScope { + switch provider { + case account.ProviderBuild: + return clientkeydomain.ProviderScopeBuild + case account.ProviderWeb: + return clientkeydomain.ProviderScopeWeb + default: + return clientkeydomain.ProviderScopeConsole + } + } + assertSelected := func(provider account.Provider, tiers clientkeydomain.TierScope, excluded map[uint64]bool, want uint64) { + t.Helper() + scope := clientkeydomain.AccountScope{Providers: providerScope(provider), Tiers: tiers} + lease, acquireErr := selector.AcquireForKey(ctx, provider, 0, "", "", "", excluded, false, scope) + if acquireErr != nil { + t.Fatal(acquireErr) + } + defer lease.Release() + if lease.Credential.ID != want { + t.Fatalf("provider %s tiers %d selected %d, want %d", provider, tiers, lease.Credential.ID, want) + } + } + assertSelected(account.ProviderBuild, clientkeydomain.TierScopeFree, nil, buildFree.ID) + assertSelected(account.ProviderBuild, clientkeydomain.TierScopeSuper, nil, buildSuper.ID) + assertSelected(account.ProviderWeb, clientkeydomain.TierScopeFree, nil, webFree.ID) + assertSelected(account.ProviderWeb, clientkeydomain.TierScopeSuper, nil, webSuper.ID) + assertSelected(account.ProviderWeb, clientkeydomain.TierScopeSuper, map[uint64]bool{webSuper.ID: true}, webHeavy.ID) + assertSelected(account.ProviderConsole, clientkeydomain.TierScopeFree, nil, console.ID) + + freeBuildScope := clientkeydomain.AccountScope{Providers: clientkeydomain.ProviderScopeBuild, Tiers: clientkeydomain.TierScopeFree} + _, err = selector.AcquireForKey(ctx, account.ProviderBuild, 0, "", "", "", map[uint64]bool{buildFree.ID: true}, false, freeBuildScope) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" || unavailable.Scope != freeBuildScope { + t.Fatalf("scoped exhaustion error = %#v, err = %v", unavailable, err) + } + superBuildScope := clientkeydomain.AccountScope{Providers: clientkeydomain.ProviderScopeBuild, Tiers: clientkeydomain.TierScopeSuper} + if _, err := selector.AcquirePinnedForKey(ctx, account.ProviderBuild, buildUnknown.ID, 0, "", "", true, superBuildScope); !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" { + t.Fatalf("out-of-pool pinned error = %#v, err = %v", unavailable, err) + } + allKnownBuildTiers := clientkeydomain.AccountScope{Providers: clientkeydomain.ProviderScopeBuild, Tiers: clientkeydomain.TierScopeFree | clientkeydomain.TierScopeSuper} + if _, err := selector.AcquirePinnedForKey(ctx, account.ProviderBuild, buildUnknown.ID, 0, "", "", true, allKnownBuildTiers); !errors.As(err, &unavailable) { + t.Fatalf("unknown Build tier should be excluded: %v", err) + } + buildOnlyScope := clientkeydomain.AccountScope{Providers: clientkeydomain.ProviderScopeBuild, Tiers: clientkeydomain.TierScopeAll} + if _, err := selector.AcquireForKey(ctx, account.ProviderWeb, 0, "", "", "", nil, false, buildOnlyScope); !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" { + t.Fatalf("provider scope should fail closed: %#v, err = %v", unavailable, err) + } +} + +func TestSelectorPropagatesConcurrencyStoreFailure(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "selector-runtime-error.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + if _, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "active", SourceKey: "active", EncryptedAccessToken: "encrypted", + AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }); err != nil { + t.Fatal(err) + } + + runtimeErr := errors.New("runtime store unavailable") + selector := NewSelector(accounts, failingConcurrencyLimiter{err: runtimeErr}, memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + if _, err := selector.Acquire(ctx, account.ProviderBuild, 0, "", "", "", map[uint64]bool{}, true); !errors.Is(err, runtimeErr) { + t.Fatalf("Acquire error = %v, want wrapped runtime error", err) + } +} + +func TestStickySessionKeyIsFixedLengthAndStable(t *testing.T) { + first := stickySessionKey("affinity-key") + if len(first) != 64 || first != stickySessionKey("affinity-key") { + t.Fatalf("sticky key = %q", first) + } + if first == stickySessionKey("another-key") { + t.Fatal("different prompt cache keys produced the same sticky key") + } + if stickySessionKey("") != "" { + t.Fatal("empty prompt cache key should remain empty") + } +} + +func TestSelectorUsesBatchConcurrencySnapshot(t *testing.T) { + limiter := &batchConcurrencyLimiter{values: map[string]int{"account:1": 2, "account:2": 1}} + selector := NewSelector(nil, limiter, nil, nil, time.Hour, time.Second, time.Minute) + values := []account.RoutingCandidate{ + {Credential: account.Credential{ID: 1, Priority: 1}}, + {Credential: account.Credential{ID: 2, Priority: 1}}, + } + plan, err := selector.planCandidates(context.Background(), values, time.Now().UTC(), nil) + if err != nil { + t.Fatal(err) + } + first, ok := plan.Next() + if limiter.batchCalls != 1 || limiter.currentCalls != 0 || !ok || first.Credential.ID != 2 { + t.Fatalf("batchCalls=%d currentCalls=%d values=%#v", limiter.batchCalls, limiter.currentCalls, values) + } + if _, err := selector.planCandidates(context.Background(), values, time.Now().UTC(), nil); err != nil { + t.Fatal(err) + } + if limiter.batchCalls != 1 { + t.Fatalf("short snapshot cache made %d batch reads", limiter.batchCalls) + } + time.Sleep(concurrencySnapshotTTL + 5*time.Millisecond) + if _, err := selector.planCandidates(context.Background(), values, time.Now().UTC(), nil); err != nil { + t.Fatal(err) + } + if limiter.batchCalls != 2 { + t.Fatalf("expired snapshot cache made %d batch reads", limiter.batchCalls) + } +} + +func TestSelectorPreferFreeBuildHotReloadAndSaturationFallback(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "free-first.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + freeAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "free", SourceKey: "free", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 1, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + superAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "super", SourceKey: "super", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: freeAccount.ID, PlanName: "free", SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: superAccount.ID, MonthlyLimit: 140, SyncedAt: now}); err != nil { + t.Fatal(err) + } + + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-4.5", "", "existing-session", nil, false) + if err != nil { + t.Fatal(err) + } + if lease.Credential.ID != superAccount.ID { + t.Fatalf("disabled strategy selected %d, want higher-priority Super %d", lease.Credential.ID, superAccount.ID) + } + lease.Release() + + selector.UpdatePreferFreeBuild(true) + stickyLease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-4.5", "", "existing-session", nil, false) + if err != nil { + t.Fatal(err) + } + if stickyLease.Credential.ID != superAccount.ID { + t.Fatalf("existing sticky session moved to %d, want Super %d", stickyLease.Credential.ID, superAccount.ID) + } + stickyLease.Release() + + freeLease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-4.5", "", "new-session", nil, false) + if err != nil { + t.Fatal(err) + } + if freeLease.Credential.ID != freeAccount.ID { + t.Fatalf("enabled strategy selected %d, want Free %d", freeLease.Credential.ID, freeAccount.ID) + } + + fallbackLease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "grok-4.5", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer fallbackLease.Release() + defer freeLease.Release() + if fallbackLease.Credential.ID != superAccount.ID { + t.Fatalf("saturated Free selected %d, want Super fallback %d", fallbackLease.Credential.ID, superAccount.ID) + } +} + +func TestCandidatePlanPreservesSelectorOrdering(t *testing.T) { + now := time.Now().UTC() + limiter := &batchConcurrencyLimiter{values: map[string]int{"account:2": 1}} + selector := &Selector{concurrency: limiter, lastSelectedAt: map[uint64]time.Time{6: now}} + newCandidate := func(id uint64, tier account.WebTier, priority int, known, supported bool) account.RoutingCandidate { + return account.RoutingCandidate{ + Credential: account.Credential{ID: id, WebTier: tier, Priority: priority}, + Billing: &account.Billing{ + AccountID: id, MonthlyLimit: 100, SyncedAt: now, + }, + ModelCapabilityKnown: known, SupportsModel: supported, + } + } + values := []account.RoutingCandidate{ + newCandidate(5, account.WebTierHeavy, 100, false, false), + newCandidate(4, account.WebTierSuper, 100, true, true), + newCandidate(3, account.WebTierHeavy, 9, true, true), + newCandidate(2, account.WebTierHeavy, 10, true, true), + newCandidate(6, account.WebTierHeavy, 10, true, true), + newCandidate(1, account.WebTierHeavy, 10, true, true), + } + plan, err := selector.planCandidates(context.Background(), values, now, []account.WebTier{account.WebTierHeavy, account.WebTierSuper}) + if err != nil { + t.Fatal(err) + } + ordered := make([]uint64, 0, len(values)) + for candidate, ok := plan.Next(); ok; candidate, ok = plan.Next() { + ordered = append(ordered, candidate.Credential.ID) + } + if expected := []uint64{1, 6, 2, 3, 4, 5}; !slices.Equal(ordered, expected) { + t.Fatalf("候选顺序 = %v, want %v", ordered, expected) + } +} + +func TestSelectorConsumesOnlyMatchingQuotaSnapshot(t *testing.T) { + key := candidateCacheKey{provider: account.ProviderWeb, upstreamModel: "chat", quotaMode: "fast"} + values := []account.RoutingCandidate{{ + Credential: account.Credential{ID: 7}, QuotaWindow: &account.QuotaWindow{AccountID: 7, Mode: "fast", Remaining: 10}, + }} + selector := &Selector{candidates: map[candidateCacheKey]candidateSnapshot{key: newCandidateSnapshot(values, time.Now().UTC().Add(time.Minute))}} + original := selector.candidates[key].values + selector.ConsumeQuota(account.ProviderWeb, 7, "fast", 3) + window := selector.candidates[key].values[0].QuotaWindow + if window == nil || window.Remaining != 7 { + t.Fatalf("quota window = %#v", window) + } + if original[0].QuotaWindow == nil || original[0].QuotaWindow.Remaining != 10 { + t.Fatalf("published snapshot was mutated: %#v", original[0].QuotaWindow) + } +} + +func TestSelectorWaitsBrieflyForAccountCapacity(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "capacity-wait.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + if _, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "capacity", SourceKey: "capacity", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }); err != nil { + t.Fatal(err) + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute, 300*time.Millisecond) + first, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + type result struct { + lease *accountLease + err error + } + resultCh := make(chan result, 1) + go func() { + lease, acquireErr := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "", nil, false) + resultCh <- result{lease: lease, err: acquireErr} + }() + select { + case value := <-resultCh: + t.Fatalf("second acquire returned before capacity release: %v", value.err) + case <-time.After(30 * time.Millisecond): + } + first.Release() + select { + case value := <-resultCh: + if value.err != nil || value.lease == nil { + t.Fatalf("second acquire lease=%v err=%v", value.lease, value.err) + } + value.lease.Release() + case <-time.After(time.Second): + t.Fatal("second acquire did not wake after capacity release") + } +} + +func TestSelectorStickySessionWaitsForBoundAccountCapacity(t *testing.T) { + ctx := context.Background() + sticky := memory.NewStickyStore() + selector, primary, _ := newStickySelectorFixture(t, sticky, 300*time.Millisecond, true) + first, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + if err != nil || first.Credential.ID != primary.ID { + t.Fatalf("first lease = %#v, err = %v", first, err) + } + type result struct { + lease *accountLease + err error + } + resultCh := make(chan result, 1) + go func() { + lease, acquireErr := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + resultCh <- result{lease: lease, err: acquireErr} + }() + select { + case value := <-resultCh: + t.Fatalf("sticky request bypassed the bound account before capacity returned: %#v", value) + case <-time.After(30 * time.Millisecond): + } + first.Release() + select { + case value := <-resultCh: + if value.err != nil || value.lease == nil || value.lease.Credential.ID != primary.ID { + t.Fatalf("sticky lease = %#v, err = %v", value.lease, value.err) + } + value.lease.Release() + case <-time.After(time.Second): + t.Fatal("sticky request did not wake after bound capacity returned") + } +} + +func TestSelectorStickySessionTemporaryFallbackDoesNotRebind(t *testing.T) { + ctx := context.Background() + sticky := memory.NewStickyStore() + selector, primary, fallback := newStickySelectorFixture(t, sticky, 20*time.Millisecond, true) + first, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + if err != nil || first.Credential.ID != primary.ID { + t.Fatalf("first lease = %#v, err = %v", first, err) + } + temporary, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + if err != nil || temporary.Credential.ID != fallback.ID { + t.Fatalf("temporary lease = %#v, err = %v", temporary, err) + } + if boundID, ok, err := sticky.Get(ctx, stickySessionKey("stable-affinity"), time.Now().UTC()); err != nil || !ok || boundID != primary.ID { + t.Fatalf("sticky binding changed after temporary fallback: id=%d ok=%v err=%v", boundID, ok, err) + } + temporary.Release() + first.Release() + resumed, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + if err != nil || resumed.Credential.ID != primary.ID { + t.Fatalf("resumed sticky lease = %#v, err = %v", resumed, err) + } + resumed.Release() +} + +func TestSelectorStickyHitRefreshesTTL(t *testing.T) { + ctx := context.Background() + sticky := newRecordingStickyStore() + selector, _, _ := newStickySelectorFixture(t, sticky, 0, false) + first, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + if err != nil { + t.Fatal(err) + } + first.Release() + time.Sleep(time.Millisecond) + second, err := selector.Acquire(ctx, account.ProviderBuild, 0, "model", "", "stable-affinity", nil, false) + if err != nil { + t.Fatal(err) + } + second.Release() + expiries := sticky.Expiries() + if len(expiries) < 2 || !expiries[len(expiries)-1].After(expiries[0]) { + t.Fatalf("sticky expiry was not refreshed: %v", expiries) + } +} + +func newStickySelectorFixture(t *testing.T, sticky repository.StickySessionRepository, capacityWait time.Duration, withFallback bool) (*Selector, account.Credential, account.Credential) { + t.Helper() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "sticky-selector.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + primary, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "primary", SourceKey: "primary", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + var fallback account.Credential + if withFallback { + fallback, _, err = accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "fallback", SourceKey: "fallback", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 1, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + } + return NewSelector(accounts, memory.NewConcurrencyLimiter(), sticky, nil, time.Hour, time.Second, time.Minute, capacityWait), primary, fallback +} + +func TestSelectorAppliesPersistedCooldownOnlyToMatchingModel(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-cooldown.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "model-cooling", SourceKey: "model-cooling", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + until := time.Now().UTC().Add(time.Hour) + if err := accounts.UpsertModelQuotaBlock(ctx, account.ModelQuotaBlock{AccountID: credential.ID, UpstreamModel: "limited-model", Reason: "test", CooldownUntil: until}); err != nil { + t.Fatal(err) + } + if err := accounts.UpsertModelQuotaBlock(ctx, account.ModelQuotaBlock{AccountID: credential.ID, UpstreamModel: "limited-model", Reason: "shorter", CooldownUntil: time.Now().UTC().Add(time.Minute)}); err != nil { + t.Fatal(err) + } + selector := NewSelector(accounts, memory.NewConcurrencyLimiter(), memory.NewStickyStore(), nil, time.Hour, time.Second, time.Minute) + if _, err := selector.Acquire(ctx, account.ProviderBuild, 0, "limited-model", "", "", nil, false); err == nil { + t.Fatal("matching model cooldown was ignored") + } else { + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Reason != SelectionModelCooling || unavailable.RetryAfter < 30*time.Minute { + t.Fatalf("error = %v", err) + } + } + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "other-model", "", "", nil, false) + if err != nil { + t.Fatalf("other model was blocked: %v", err) + } + lease.Release() +} + +type failingConcurrencyLimiter struct{ err error } + +type recordingStickyStore struct { + *memory.StickyStore + mu sync.Mutex + expiries []time.Time +} + +func newRecordingStickyStore() *recordingStickyStore { + return &recordingStickyStore{StickyStore: memory.NewStickyStore()} +} + +func (s *recordingStickyStore) Bind(ctx context.Context, key string, accountID uint64, now, expiresAt time.Time) (uint64, error) { + s.mu.Lock() + s.expiries = append(s.expiries, expiresAt) + s.mu.Unlock() + return s.StickyStore.Bind(ctx, key, accountID, now, expiresAt) +} + +func (s *recordingStickyStore) Expiries() []time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return append([]time.Time(nil), s.expiries...) +} + +type batchConcurrencyLimiter struct { + values map[string]int + batchCalls int + currentCalls int +} + +func (l *batchConcurrencyLimiter) Acquire(context.Context, string, int) (func(), bool, error) { + return func() {}, true, nil +} + +func (l *batchConcurrencyLimiter) Current(context.Context, string) (int, error) { + l.currentCalls++ + return 0, nil +} + +func (l *batchConcurrencyLimiter) CurrentMany(_ context.Context, keys []string) (map[string]int, error) { + l.batchCalls++ + values := make(map[string]int, len(keys)) + for _, key := range keys { + values[key] = l.values[key] + } + return values, nil +} + +type staticTierOrder struct{ order []account.WebTier } + +func (value staticTierOrder) TierOrder(account.Provider, string) []account.WebTier { + return value.order +} + +func (f failingConcurrencyLimiter) Acquire(context.Context, string, int) (func(), bool, error) { + return nil, false, f.err +} + +func (f failingConcurrencyLimiter) Current(context.Context, string) (int, error) { + return 0, nil +} diff --git a/backend/internal/application/gateway/service.go b/backend/internal/application/gateway/service.go new file mode 100644 index 0000000000000000000000000000000000000000..b530ba5cea3ae308b62902505a5c28525b94607b --- /dev/null +++ b/backend/internal/application/gateway/service.go @@ -0,0 +1,1591 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" + neterrorpkg "github.com/chenyme/grok2api/backend/internal/pkg/neterror" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrModelNotFound = errors.New("模型不存在或未启用") + ErrNoAvailableAccount = errors.New("没有可用上游账号") + ErrResponseNotFound = errors.New("Response 不存在或已过期") + ErrResponseAccountUnavailable = errors.New("Response 绑定的上游账号不可用") + ErrResponseStateUnsupported = errors.New("目标模型不支持有状态 Response") + ErrConversationUnsupported = errors.New("目标模型不支持当前对话协议") + ErrVideoInputTooLarge = errors.New("视频参考图片总大小超过 32 MiB") + ErrLedgerUnavailable = errors.New("计费账本暂不可用") +) + +const responseOwnershipTTL = 30 * 24 * time.Hour +const finalizationTimeout = 5 * time.Second +const minimumTextBillingReservationTTL = 2 * time.Hour +const billingReservationCrashGrace = 10 * time.Minute +const mediaBillingReservationTTL = 24 * time.Hour +const modelCatalogRefreshTimeout = 30 * time.Second +const accountStateWriteTimeout = 3 * time.Second +const unlimitedRoutingAttempts = -1 + +type routingAttemptPolicy struct { + limit int + unlimited bool +} + +func newRoutingAttemptPolicy(configured int) routingAttemptPolicy { + if configured == unlimitedRoutingAttempts { + return routingAttemptPolicy{unlimited: true} + } + if configured <= 0 { + configured = 3 + } + return routingAttemptPolicy{limit: configured} +} + +func (p routingAttemptPolicy) allows(attempt int) bool { + return p.unlimited || attempt < p.limit +} + +func (p routingAttemptPolicy) hasNext(attempt int) bool { + return p.unlimited || attempt+1 < p.limit +} + +var freeQuotaUsagePattern = regexp.MustCompile(`(?i)tokens\s*\(actual/limit\)\s*:\s*([0-9]+)\s*/\s*([0-9]+)`) + +type Input struct { + RequestID string + ClientKey clientkey.Key + PublicModel string + Body []byte + Streaming bool + PromptCacheKey string + PromptCacheSeed string + // AllowClientToolCacheRoute indicates that the client request is compatible with the Build mixed-tool cache route. + // It only controls whether native x_search is added to existing client tools; it is not an authentication result. + AllowClientToolCacheRoute bool + PreviousResponseID string + // GrokTurnIndex forwards only the turn supplied by a real Grok Shell client; the server never infers or increments it. + GrokTurnIndex string + Operation audit.Operation +} + +type Usage struct { + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + TotalTokens int64 + CostInUSDTicks int64 + NumSourcesUsed int64 + NumServerSideToolsUsed int64 + ContextInputTokens int64 + ContextOutputTokens int64 + ResponseModel string +} + +type Result struct { + StatusCode int + Status string + Header http.Header + Body io.ReadCloser + MarkFirstToken func() + RecordStreamFailure func(StreamFailureDiagnostic) + Finalize func(usage Usage, responseID, errorCode string) +} + +// StreamFailureDiagnostic safely projects a failure termination event returned in-stream after downstream 2xx headers. +// Body contains only transport-extracted error fields and still receives the standard redaction and size limits. +type StreamFailureDiagnostic struct { + Body []byte + BodyTruncated bool +} + +type auditRecorder interface { + Create(ctx context.Context, value audit.Record) error +} + +type ledgerReadinessChecker interface { + CheckLedgerReady() error +} + +type routeResolver interface { + Get(ctx context.Context, id uint64) (modeldomain.Route, error) + GetByPublicID(ctx context.Context, publicID string) (modeldomain.Route, error) + GetByPublicIDCandidates(ctx context.Context, publicID string) ([]modeldomain.Route, error) + GetByProviderUpstream(ctx context.Context, providerValue accountdomain.Provider, upstreamModel string) (modeldomain.Route, error) +} + +// videoAssetStore archives and reads video results generated by a Provider. +type videoAssetStore interface { + SaveVideo(ctx context.Context, jobID, contentType string, body io.Reader) (mediadomain.Asset, error) + OpenVideo(ctx context.Context, id string) (mediadomain.Asset, io.ReadCloser, error) +} + +type accountModelSyncer interface { + SyncAccount(ctx context.Context, accountID uint64) (int, error) +} + +// Service handles model routing, account selection, failover, and audit finalization. +type Service struct { + models routeResolver + audits auditRecorder + accounts *accountapp.Service + clientKeys *clientkeyapp.Service + providers *provider.Registry + selector *Selector + responses repository.ResponseRepository + maxAttempts atomic.Int64 + buildForbiddenReauth atomic.Pointer[buildForbiddenReauthPolicy] + requestTimeout atomic.Int64 + mediaJobs repository.MediaJobRepository + mediaAssets videoAssetStore + mediaQueue chan string + mediaMu sync.Mutex + mediaQueued map[string]struct{} + mediaWorker int + mediaQueueFull atomic.Uint64 + logger *slog.Logger + rateLimitMu sync.Mutex + rateLimitActive atomic.Bool + rateLimitNextExpiry atomic.Int64 + rateLimits map[string]teamModelRateLimit + rateLimitTeams map[uint64]teamRateLimitObservation + modelSyncMu sync.Mutex + modelSyncing map[uint64]struct{} +} + +type teamModelRateLimit struct { + TeamFingerprint string + Until time.Time +} + +type teamRateLimitObservation struct { + Fingerprint string + ExpiresAt time.Time +} + +type buildForbiddenReauthPolicy struct { + enabled bool + codes map[string]struct{} +} + +func (s *Service) ConfigureMedia(repository repository.MediaJobRepository, concurrency int) { + if concurrency <= 0 { + concurrency = 4 + } + s.mediaJobs = repository + s.mediaWorker = concurrency + s.mediaQueue = make(chan string, min(2048, max(64, concurrency*32))) + s.mediaQueued = make(map[string]struct{}) +} + +// ConfigureMediaAssets injects optional local video asset archival and reading. +func (s *Service) ConfigureMediaAssets(store videoAssetStore) { + s.mediaAssets = store +} + +func NewService(models routeResolver, audits auditRecorder, accounts *accountapp.Service, clientKeys *clientkeyapp.Service, providers *provider.Registry, selector *Selector, responses repository.ResponseRepository, maxAttempts int) *Service { + service := &Service{ + models: models, audits: audits, accounts: accounts, clientKeys: clientKeys, providers: providers, + selector: selector, responses: responses, logger: slog.Default(), + rateLimits: make(map[string]teamModelRateLimit), rateLimitTeams: make(map[uint64]teamRateLimitObservation), + modelSyncing: make(map[uint64]struct{}), + } + service.UpdateMaxAttempts(maxAttempts) + return service +} + +// UpdateBuildForbiddenReauthPolicy atomically replaces the Build account invalidation policy. +func (s *Service) UpdateBuildForbiddenReauthPolicy(enabled bool, codes []string) { + policy := &buildForbiddenReauthPolicy{enabled: enabled, codes: make(map[string]struct{}, len(codes))} + for _, value := range codes { + code := strings.ToLower(strings.TrimSpace(value)) + if code != "" { + policy.codes[code] = struct{}{} + } + } + s.buildForbiddenReauth.Store(policy) +} + +func (s *Service) shouldInvalidateBuildForbidden(failure *UpstreamFailure) bool { + if failure == nil || failure.HTTPStatus != http.StatusForbidden { + return false + } + // Content safety rejections share permission-denied codes but must never invalidate accounts. + if failure.SafetyRejection { + return false + } + policy := s.buildForbiddenReauth.Load() + if policy == nil || !policy.enabled { + return false + } + _, matched := policy.codes[strings.ToLower(strings.TrimSpace(failure.UpstreamCode))] + return matched +} + +func (s *Service) markReauthRequired(ctx context.Context, requestID string, credential accountdomain.Credential, reason string) bool { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), accountStateWriteTimeout) + defer cancel() + if err := s.accounts.MarkReauthRequired(writeCtx, credential.ID, reason); err != nil { + s.logger.Error("account_reauth_required_write_failed", "request_id", requestID, "account_id", credential.ID, "provider", credential.Provider, "error", err) + return false + } + s.selector.MarkQuotaStateChanged(credential.Provider) + return true +} + +func teamModelRateLimitKey(providerValue accountdomain.Provider, teamFingerprint, upstreamModel string) string { + return string(providerValue) + "\x00" + teamFingerprint + "\x00" + strings.TrimSpace(upstreamModel) +} + +func rateLimitTeamFingerprint(teamID string) string { + teamID = strings.ToLower(strings.TrimSpace(teamID)) + if teamID == "" { + return "" + } + return security.HashToken(teamID) +} + +func shortTeamFingerprint(value string) string { + if len(value) <= 12 { + return value + } + return value[:12] +} + +func (s *Service) activeTeamModelRateLimit(credential accountdomain.Credential, upstreamModel string, now time.Time) (teamModelRateLimit, bool) { + if !s.rateLimitActive.Load() { + return teamModelRateLimit{}, false + } + credentialFingerprint := rateLimitTeamFingerprint(credential.TeamID) + s.rateLimitMu.Lock() + defer s.rateLimitMu.Unlock() + if !s.rateLimitActive.Load() { + return teamModelRateLimit{}, false + } + nextExpiry := s.rateLimitNextExpiry.Load() + if nextExpiry <= 0 || now.UnixNano() >= nextExpiry { + s.pruneTeamModelRateLimitsLocked(now) + if len(s.rateLimits) == 0 { + return teamModelRateLimit{}, false + } + } + // Check the TeamID observed in an upstream response first, then current + // credential metadata. The fallback prevents a historical observation from + // permanently masking a later server-side team reassignment. + observation := s.rateLimitTeams[credential.ID] + observedFingerprint := observation.Fingerprint + if observedFingerprint != "" && !now.Before(observation.ExpiresAt) { + delete(s.rateLimitTeams, credential.ID) + observedFingerprint = "" + } + teamFingerprints := [2]string{observedFingerprint, credentialFingerprint} + fingerprintCount := 1 + if credentialFingerprint != observedFingerprint { + fingerprintCount = 2 + } + for index := 0; index < fingerprintCount; index++ { + teamFingerprint := teamFingerprints[index] + if teamFingerprint == "" { + continue + } + key := teamModelRateLimitKey(credential.Provider, teamFingerprint, upstreamModel) + value, ok := s.rateLimits[key] + if !ok { + continue + } + if !now.Before(value.Until) { + delete(s.rateLimits, key) + s.refreshTeamModelRateLimitStateLocked() + continue + } + return value, true + } + return teamModelRateLimit{}, false +} + +func (s *Service) pruneTeamModelRateLimitsLocked(now time.Time) { + for key, value := range s.rateLimits { + if !now.Before(value.Until) { + delete(s.rateLimits, key) + } + } + for accountID, observation := range s.rateLimitTeams { + if !now.Before(observation.ExpiresAt) { + delete(s.rateLimitTeams, accountID) + } + } + s.refreshTeamModelRateLimitStateLocked() +} + +func (s *Service) refreshTeamModelRateLimitStateLocked() { + if len(s.rateLimits) == 0 { + clear(s.rateLimitTeams) + s.rateLimitNextExpiry.Store(0) + s.rateLimitActive.Store(false) + return + } + var nextExpiry time.Time + for _, value := range s.rateLimits { + if nextExpiry.IsZero() || value.Until.Before(nextExpiry) { + nextExpiry = value.Until + } + } + for _, observation := range s.rateLimitTeams { + if nextExpiry.IsZero() || observation.ExpiresAt.Before(nextExpiry) { + nextExpiry = observation.ExpiresAt + } + } + s.rateLimitNextExpiry.Store(nextExpiry.UnixNano()) + s.rateLimitActive.Store(true) +} + +func (s *Service) markTeamModelRateLimit(credential accountdomain.Credential, upstreamModel string, metadata provider.RateLimitMetadata, now time.Time) teamModelRateLimit { + retryAfter := metadata.RetryAfter + if retryAfter <= 0 { + // RPS limits recover within about one second; do not apply the generic 1m cooldown. + if strings.EqualFold(metadata.Scope, provider.RateLimitScopeRPS) { + retryAfter = 2 * time.Second + } else { + retryAfter = time.Minute + } + } + teamFingerprint := rateLimitTeamFingerprint(metadata.TeamID) + value := teamModelRateLimit{TeamFingerprint: shortTeamFingerprint(teamFingerprint), Until: now.Add(retryAfter)} + key := teamModelRateLimitKey(credential.Provider, teamFingerprint, upstreamModel) + until := now.Add(retryAfter) + s.rateLimitMu.Lock() + s.rateLimitActive.Store(true) + if s.rateLimits == nil { + s.rateLimits = make(map[string]teamModelRateLimit) + } + if s.rateLimitTeams == nil { + s.rateLimitTeams = make(map[uint64]teamRateLimitObservation) + } + if teamFingerprint != rateLimitTeamFingerprint(credential.TeamID) { + s.rateLimitTeams[credential.ID] = teamRateLimitObservation{Fingerprint: teamFingerprint, ExpiresAt: until} + } else { + delete(s.rateLimitTeams, credential.ID) + } + for existingKey, value := range s.rateLimits { + if !now.Before(value.Until) { + delete(s.rateLimits, existingKey) + } + } + for accountID, observation := range s.rateLimitTeams { + if !now.Before(observation.ExpiresAt) { + delete(s.rateLimitTeams, accountID) + } + } + if current, ok := s.rateLimits[key]; ok && !current.Until.Before(until) { + value = current + } else { + s.rateLimits[key] = value + } + s.refreshTeamModelRateLimitStateLocked() + s.rateLimitMu.Unlock() + return value +} + +func (s *Service) SetLogger(logger *slog.Logger) { + if logger != nil { + s.logger = logger + } +} + +func (s *Service) UpdateMaxAttempts(maxAttempts int) { s.maxAttempts.Store(int64(maxAttempts)) } + +func (s *Service) UpdateRequestTimeout(value time.Duration) { + if value <= 0 { + value = minimumTextBillingReservationTTL + } + s.requestTimeout.Store(int64(value)) +} + +func (s *Service) textBillingReservationTTL() time.Duration { + ttl := time.Duration(s.requestTimeout.Load()) + finalizationTimeout + billingReservationCrashGrace + return max(minimumTextBillingReservationTTL, ttl) +} + +func (s *Service) checkLedgerReady() error { + checker, ok := s.audits.(ledgerReadinessChecker) + if !ok { + return nil + } + if err := checker.CheckLedgerReady(); err != nil { + return ErrLedgerUnavailable + } + return nil +} + +func (s *Service) CreateResponse(ctx context.Context, input Input) (*Result, error) { + input.Operation = audit.OperationResponses + if isResponsesCompactionRequest(input.Body) { + input.Operation = audit.OperationCompaction + } + return s.createResponseAt(ctx, input, "/responses") +} + +func (s *Service) CreateChatCompletion(ctx context.Context, input Input) (*Result, error) { + input.Operation = audit.OperationChat + return s.createResponseAt(ctx, input, "/responses") +} + +// CreateMessage executes an Anthropic Messages request through the unified Responses upstream. +func (s *Service) CreateMessage(ctx context.Context, input Input) (*Result, error) { + input.Operation = audit.OperationMessages + return s.createResponseAt(ctx, input, "/responses") +} + +func (s *Service) CompactResponse(ctx context.Context, input Input) (*Result, error) { + input.Streaming = false + input.Operation = audit.OperationCompaction + return s.createResponseAt(ctx, input, "/responses/compact") +} + +// resolvePublicModelRoutes supports both unprefixed downstream model names and explicitly sourced compatibility names. +// Registered Provider aliases are stable compatibility contracts. allowModelAliases gates only dynamically generated +// reasoning-effort aliases so existing clients keep working after the per-key discovery switch is introduced. +func (s *Service) resolvePublicModelRoutes(ctx context.Context, publicModel string, allowModelAliases bool) ([]modeldomain.Route, string, error) { + routes, err := s.models.GetByPublicIDCandidates(ctx, publicModel) + if err == nil { + return routes, "", nil + } + if s.providers != nil { + if alias, ok := s.providers.ResolveModelAlias(publicModel); ok { + if alias.Provider != "" && alias.UpstreamModel != "" { + route, routeErr := s.models.GetByProviderUpstream(ctx, alias.Provider, alias.UpstreamModel) + if routeErr != nil { + return nil, "", routeErr + } + return []modeldomain.Route{route}, alias.ReasoningEffort, nil + } + routes, resolveErr := s.models.GetByPublicIDCandidates(ctx, alias.PublicModel) + return routes, alias.ReasoningEffort, resolveErr + } + } + // Dynamic effort-suffix aliases (e.g. grok-4.5-low) for any provider that exposes the base model. + // Only levels the base model truly supports are accepted. + if base, effort, ok := modeldomain.ParseReasoningModelAlias(publicModel); ok { + if !allowModelAliases { + return nil, "", err + } + routes, resolveErr := s.models.GetByPublicIDCandidates(ctx, base) + if resolveErr != nil { + return nil, "", resolveErr + } + return routes, effort, nil + } + return nil, "", err +} + +// selectConversationRoute selects a route for the named model that satisfies permissions, protocol, and session affinity. +func (s *Service) selectConversationRoute(routes []modeldomain.Route, key clientkey.Key, operation audit.Operation, path string, requireStoredResponse bool, ownership *inferencedomain.ResponseOwnership) (modeldomain.Route, error) { + if len(routes) == 0 || s.providers == nil { + return modeldomain.Route{}, ErrModelNotFound + } + fallback := routes[0] + accountScope := key.AccountScope() + matchedOwnership := ownership == nil + scopeMatched := false + allowed := false + conversationSupported := false + storedResponseUnsupported := false + for _, route := range routes { + if ownership != nil && route.Provider != ownership.Provider { + continue + } + matchedOwnership = true + fallback = route + if !accountScope.AllowsProvider(route.Provider) { + continue + } + scopeMatched = true + if !s.clientKeys.CanUseModel(key, route.ID) { + continue + } + allowed = true + if !s.providers.SupportsConversation(route.Provider, string(operation)) { + continue + } + conversationSupported = true + if path == "/responses/compact" && !s.providers.SupportsResponseCompaction(route.Provider) { + continue + } + if requireStoredResponse && !s.providers.SupportsStoredResponses(route.Provider) { + storedResponseUnsupported = true + continue + } + return route, nil + } + if !matchedOwnership { + return fallback, ErrResponseAccountUnavailable + } + if !scopeMatched { + return fallback, &SelectionUnavailableError{Reason: SelectionNoAccounts, Scope: accountScope} + } + if !allowed { + return fallback, clientkeyapp.ErrModelNotAllowed + } + if storedResponseUnsupported { + return fallback, ErrResponseStateUnsupported + } + if conversationSupported && path == "/responses/compact" { + return fallback, ErrConversationUnsupported + } + return fallback, ErrConversationUnsupported +} + +// selectMediaRoute selects a same-name route that satisfies media capability, key permissions, and Provider support. +func (s *Service) selectMediaRoute(routes []modeldomain.Route, key clientkey.Key, capability modeldomain.Capability, providerSupported func(accountdomain.Provider) bool) (modeldomain.Route, error) { + if len(routes) == 0 { + return modeldomain.Route{}, ErrModelNotFound + } + fallback := routes[0] + accountScope := key.AccountScope() + capabilityMatched := false + scopeMatched := false + allowed := false + for _, route := range routes { + if route.Capability != capability { + continue + } + fallback = route + capabilityMatched = true + if !accountScope.AllowsProvider(route.Provider) { + continue + } + scopeMatched = true + if !s.clientKeys.CanUseModel(key, route.ID) { + continue + } + allowed = true + if providerSupported(route.Provider) { + return route, nil + } + } + if !capabilityMatched { + return fallback, ErrModelNotFound + } + if !scopeMatched { + return fallback, &SelectionUnavailableError{Reason: SelectionNoAccounts, Scope: accountScope} + } + if !allowed { + return fallback, clientkeyapp.ErrModelNotAllowed + } + return fallback, ErrNoAvailableAccount +} + +func (s *Service) createResponseAt(ctx context.Context, input Input, path string) (*Result, error) { + ctx, egressTrace := infraegress.WithTrace(ctx) + startedAt := time.Now() + var firstToken *firstTokenTimer + if input.Streaming { + firstToken = newFirstTokenTimer(startedAt) + } + eventID := newAuditEventID() + operation := input.Operation + if operation == "" { + operation = audit.OperationResponses + } + routes, aliasEffort, err := s.resolvePublicModelRoutes(ctx, input.PublicModel, input.ClientKey.AllowModelAliases) + if err != nil { + return nil, ErrModelNotFound + } + // Select the route first instead of requiring stored Responses support up front. Console is stateless + // but still accepts complete history; Provider-level normalization handles the compatibility details. + route, routeErr := s.selectConversationRoute(routes, input.ClientKey, operation, path, false, nil) + var ownership *inferencedomain.ResponseOwnership + if input.PreviousResponseID != "" && routeErr == nil { + if s.providers.SupportsStoredResponses(route.Provider) { + value, ownershipErr := s.responses.Get(ctx, input.PreviousResponseID, input.ClientKey.ID, time.Now().UTC()) + if ownershipErr != nil { + return nil, ErrResponseNotFound + } + ownership = &value + route, routeErr = s.selectConversationRoute(routes, input.ClientKey, operation, path, true, ownership) + } else if route.Provider == accountdomain.ProviderConsole { + // Console does not retain Response state, so replay the history statelessly here; + // Provider normalization removes stale Response IDs. + input.PreviousResponseID = "" + } else { + return nil, ErrResponseStateUnsupported + } + } + publicModel := modeldomain.ExternalPublicID(route.Provider, route.PublicID) + input.PublicModel = publicModel + if aliasEffort != "" { + input.Body, err = rewriteAliasedModel(input.Body, publicModel, aliasEffort, operation) + if err != nil { + return nil, err + } + } + if routeErr != nil && !errors.Is(routeErr, clientkeyapp.ErrModelNotAllowed) { + return nil, routeErr + } + timing := newGenerationTiming(publicModel, route.Provider) + timingHandedOff := false + defer func() { + if !timingHandedOff { + timing.finish(s.logger, "failed") + } + }() + usageSource := audit.UsageSourceUpstream + if usageKind, _ := s.providers.UsageKind(route.Provider); usageKind == provider.UsageEstimated { + usageSource = audit.UsageSourceEstimated + } + mediaSummary, _ := summarizeResponseMedia(input.Body) + logResponseMediaSummary(s.logger, input.RequestID, mediaSummary) + auditBase := audit.Record{ + EventID: eventID, RequestID: input.RequestID, ClientKeyID: input.ClientKey.ID, ClientKeyName: input.ClientKey.Name, + ModelRouteID: route.ID, ModelPublicID: publicModel, ModelUpstreamModel: modeldomain.DisplayUpstreamModel(route.Provider, route.UpstreamModel), + Provider: string(route.Provider), Operation: operation, UsageSource: usageSource, Streaming: input.Streaming, + MediaInputImages: mediaSummary.InputImages, + } + if errors.Is(routeErr, clientkeyapp.ErrModelNotAllowed) { + record := auditBase + record.StatusCode = http.StatusForbidden + record.DurationMS = time.Since(startedAt).Milliseconds() + record.ErrorCode = "model_not_allowed" + record.CreatedAt = time.Now().UTC() + applyAuditEgress(&record, egressTrace, route.Provider) + if err := s.audits.Create(ctx, record); err != nil { + s.logger.Error("request_usage_write_failed", "event_id", record.EventID, "request_id", input.RequestID, "error", err) + } + return nil, clientkeyapp.ErrModelNotAllowed + } + affinityKey := "" + ownershipPromptCacheKey := "" + reasoningReplayKey := "" + if route.Provider == accountdomain.ProviderBuild { + // Derive a stable identity from explicit session signals, message anchors, and model; never generate a random conv-id. + identity := buildSessionIdentity{} + if ownership != nil && ownership.PromptCacheKey != "" { + // previous_response_id belongs to an existing Response chain and must inherit the root session identity; + // do not recompute the soft key from this turn's incremental input. + identity.upstreamID = ownership.PromptCacheKey + identity.replayKey = ownership.ReasoningReplayKey + } else { + identity = resolveBuildSessionIdentity( + input.ClientKey.ID, + route.Provider, + route.UpstreamModel, + input.PromptCacheKey, + input.PromptCacheSeed, + input.Body, + ) + } + input.PromptCacheKey = identity.upstreamID + affinityKey = identity.affinityKey + ownershipPromptCacheKey = identity.upstreamID + reasoningReplayKey = identity.replayKey + if identity.upstreamID == "" { + s.logger.Debug("prompt_cache_session_empty", "request_id", input.RequestID, "model", route.UpstreamModel, "provider", route.Provider) + } else if identity.soft { + s.logger.Debug("prompt_cache_session_soft", "request_id", input.RequestID, "model", route.UpstreamModel) + } + } + adapter, ok := s.providers.Responses(route.Provider) + if !ok { + return nil, ErrNoAvailableAccount + } + physicalCallCtx := infraegress.WithPhysicalCallTrace(ctx, string(route.Provider), string(operation)) + supportsStoredResponses := s.providers.SupportsStoredResponses(route.Provider) + if input.PreviousResponseID != "" && !supportsStoredResponses { + return nil, ErrResponseStateUnsupported + } + attemptPolicy := newRoutingAttemptPolicy(int(s.maxAttempts.Load())) + idempotencyID, _ := security.NewOpaqueToken(18) + if ownership != nil { + attemptPolicy = newRoutingAttemptPolicy(1) + } + pricingModel := s.providers.PricingModel(route.Provider, route.UpstreamModel) + if err := s.checkLedgerReady(); err != nil { + return nil, err + } + if reservation, priced := audit.EstimateOfficialTextReservation(pricingModel, input.Body); priced { + if _, err := s.clientKeys.ReserveBilling(ctx, input.ClientKey, eventID, reservation.CostInUSDTicks, s.textBillingReservationTTL()); err != nil { + return nil, err + } + } + excluded := make(map[uint64]bool) + failureFingerprints := make(map[string]int) + authRecoveryAttempted := make(map[uint64]bool) + quotaMode := s.providers.QuotaMode(route.Provider, route.UpstreamModel) + accountScope := input.ClientKey.AccountScope() + quotaProbeAttempted := false + var lastErr error + var lastFailure *UpstreamFailure + failureAttempts := newFailureAttemptRecorder(http.MethodPost, path) + responseStartedAt := startedAt + forwardResponse := func(lease *accountLease, credential accountdomain.Credential, billing *accountdomain.Billing) (*provider.Response, error) { + started := time.Now() + responseStartedAt = started + lease.markSelectorUpstreamStarted() + response, err := adapter.ForwardResponse(physicalCallCtx, provider.ResponseResourceRequest{Credential: credential, Billing: billing, Method: http.MethodPost, Path: path, Model: route.UpstreamModel, PromptCacheKey: input.PromptCacheKey, ReasoningReplayKey: reasoningReplayKey, AllowClientToolCacheRoute: input.AllowClientToolCacheRoute, GrokTurnIndex: input.GrokTurnIndex, IdempotencyID: idempotencyID, Body: input.Body, Streaming: input.Streaming, NormalizeBody: true, Operation: string(operation)}) + err = failureAttempts.captureResponse(credential, started, response, err) + timing.markUpstream(time.Since(started)) + return response, err + } + ensureCredential := func(credential accountdomain.Credential, force bool) (accountdomain.Credential, error) { + started := time.Now() + result, err := s.accounts.EnsureCredential(ctx, credential, force) + failureAttempts.captureCredentialFailure(credential, started, force, err) + timing.markCredential(time.Since(started)) + return result, err + } +attemptLoop: + for attempt := 0; attemptPolicy.allows(attempt); attempt++ { + var lease *accountLease + var err error + selectionStarted := time.Now() + if ownership != nil { + lease, err = s.selector.AcquirePinnedForKey(ctx, route.Provider, ownership.AccountID, route.ID, route.UpstreamModel, quotaMode, true, accountScope) + } else { + lease, err = s.selector.AcquireForKey(ctx, route.Provider, route.ID, route.UpstreamModel, quotaMode, affinityKey, excluded, !quotaProbeAttempted, accountScope) + } + timing.markSelection(time.Since(selectionStarted)) + if err != nil { + if lastFailure == nil { + lastErr = err + } + break + } + excluded[lease.Credential.ID] = true + if limited, ok := s.activeTeamModelRateLimit(lease.Credential, route.UpstreamModel, time.Now().UTC()); ok { + lease.Release() + lastFailure = &UpstreamFailure{ + HTTPStatus: http.StatusTooManyRequests, Code: "upstream_rate_limited", PublicMessage: "上游请求频率受限", + AccountID: lease.Credential.ID, AccountName: lease.Credential.Name, + Fingerprint: "429:team_model_rate_limit", RetryAfter: time.Until(limited.Until), + } + lastErr = fmt.Errorf("上游 Team 与模型请求频率受限") + s.logger.Warn("upstream_team_model_rate_limit_active", "request_id", input.RequestID, "provider", route.Provider, "model", route.UpstreamModel, "team_fingerprint", limited.TeamFingerprint, "retry_after", lastFailure.RetryAfter.Round(time.Second)) + // Stored Responses are pinned to one account. Return the cached 429 + // immediately instead of spinning until the cooldown expires or + // replaying the request on the same account. + if ownership != nil { + break attemptLoop + } + attempt-- + continue + } + if lease.QuotaProbe { + quotaProbeAttempted = true + } + if lease.QuotaProbeKind == accountdomain.QuotaRecoveryKindPaid { + recovered, probeErr := s.accounts.ProbePaidQuota(ctx, lease.Credential) + s.selector.MarkQuotaStateChanged(lease.Credential.Provider) + if probeErr != nil || !recovered { + lease.Release() + lastErr = firstError(probeErr, fmt.Errorf("付费额度尚未恢复")) + continue + } + lease.QuotaProbe = false + lease.QuotaProbeKind = "" + lease.Billing = nil + } + credential, err := ensureCredential(lease.Credential, false) + if err != nil { + lease.Release() + lastErr = err + lastFailure = newCredentialUpstreamFailure(err, lease.Credential.ID, lease.Credential.Name) + continue + } + response, err := forwardResponse(lease, credential, lease.Billing) + if err != nil { + lease.Release() + lastErr = err + if ctx.Err() != nil || errors.Is(err, context.Canceled) { + lastFailure = &UpstreamFailure{HTTPStatus: 499, Code: "request_canceled", PublicMessage: "请求已取消", AccountID: credential.ID, AccountName: credential.Name, Cause: firstError(ctx.Err(), err)} + break + } + if isSSOCredentialRejected(err, credential) { + s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider)) + lastFailure = newHTTPUpstreamFailure(http.StatusUnauthorized, nil, credential.ID, credential.Name) + continue + } + lastFailure = newTransportUpstreamFailure(err, credential.ID, credential.Name) + if !isRetryableTransportFailure(credential.Provider, err) { + break + } + failureFingerprints[lastFailure.Fingerprint]++ + if failureFingerprints[lastFailure.Fingerprint] >= 2 { + break + } + continue + } + handleResponse: + if response.ModelCatalogChanged { + s.queueAccountModelSync(credential.ID) + } + if response.StatusCode == http.StatusUnauthorized { + response.Body.Close() + if credential.AuthType == accountdomain.AuthTypeSSO { + s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider)) + lease.Release() + lastErr = fmt.Errorf("%s SSO 凭据已失效", credential.Provider) + lastFailure = newHTTPUpstreamFailure(http.StatusUnauthorized, nil, credential.ID, credential.Name) + continue + } + if s.markPermanentlyUnrefreshableCredentialRejected(ctx, credential) { + lease.Release() + lastErr = accountapp.ErrCredentialRefreshPermanent + lastFailure = newHTTPUpstreamFailure(http.StatusUnauthorized, nil, credential.ID, credential.Name) + continue + } + authRecoveryAttempted[credential.ID] = true + refreshed, refreshErr := ensureCredential(credential, true) + if refreshErr == nil { + response, err = forwardResponse(lease, refreshed, lease.Billing) + credential = refreshed + } + if refreshErr != nil || err != nil { + if errors.Is(refreshErr, accountapp.ErrCredentialRefreshPermanent) { + s.markCredentialRejectedAfterPermanentRefresh(ctx, credential) + } + lease.Release() + lastErr = firstError(refreshErr, err) + if refreshErr != nil { + lastFailure = newCredentialUpstreamFailure(refreshErr, credential.ID, credential.Name) + } else if ctx.Err() != nil || errors.Is(err, context.Canceled) { + lastFailure = &UpstreamFailure{HTTPStatus: 499, Code: "request_canceled", PublicMessage: "请求已取消", AccountID: credential.ID, AccountName: credential.Name, Cause: firstError(ctx.Err(), err)} + break + } else { + lastFailure = newTransportUpstreamFailure(err, credential.ID, credential.Name) + if !isRetryableTransportFailure(credential.Provider, err) { + break attemptLoop + } + } + continue + } + if response.StatusCode == http.StatusUnauthorized { + body, _ := readRetryableBody(response.Body) + _ = s.accounts.MarkReauthRequired(ctx, credential.ID, "Grok Build OAuth credential rejected after refresh") + s.selector.MarkQuotaStateChanged(credential.Provider) + lease.Release() + lastErr = fmt.Errorf("刷新后上游仍返回 401") + lastFailure = newHTTPUpstreamFailure(http.StatusUnauthorized, body, credential.ID, credential.Name) + continue + } + } + egressForbidden := s.providers.RetryForbiddenAsEgress(credential.Provider) && response.StatusCode == http.StatusForbidden + finalEgressForbidden := egressForbidden && (attempt > 0 || !attemptPolicy.hasNext(attempt)) + // Classify 403 bodies before egress retry. Definitive blocked-account signals invalidate and rotate the account; + // request-level safety rejections are returned as-is without account side effects; + // all other 403 responses retain the egress retry path without penalizing the account. + if response.StatusCode == http.StatusForbidden { + retryAfter := parseRetryAfter(response.Header.Get("Retry-After"), time.Now().UTC()) + body, _ := readRetryableBody(response.Body) + lastFailure = newHTTPUpstreamFailure(response.StatusCode, body, credential.ID, credential.Name) + if isTerminalRequestForbidden(credential.Provider, lastFailure) { + // Request-scoped / unknown-scope 403: restore the original body and return it + // without OAuth refresh, account rotation, cooldown, or invalidation. + response.Body = io.NopCloser(bytes.NewReader(body)) + lease.completeSelectorObservation(false) + lease.Release() + if lastFailure.SafetyRejection { + s.logger.Warn("upstream_safety_rejection", "request_id", input.RequestID, "account_id", credential.ID, "provider", credential.Provider, "status", response.StatusCode, "upstream_code", lastFailure.UpstreamCode) + } else { + s.logger.Warn("upstream_request_scoped_forbidden", "request_id", input.RequestID, "account_id", credential.ID, "provider", credential.Provider, "status", response.StatusCode, "upstream_code", lastFailure.UpstreamCode) + } + // Fall through to the common success/error response path so the client receives the original 403. + } else if lastFailure.AccountBlocked { + failureHandled := s.markReauthRequired(ctx, input.RequestID, credential, fmt.Sprintf("%s account is blocked", credential.Provider)) + if lastFailure.AccountScoped && !failureHandled { + s.selector.MarkFailure(ctx, credential, response.StatusCode, retryAfter) + } + lease.Release() + lastErr = fmt.Errorf("上游返回 %d", response.StatusCode) + s.logger.Warn("upstream_request_failed", "request_id", input.RequestID, "account_id", credential.ID, "provider", credential.Provider, "status", response.StatusCode, "upstream_code", lastFailure.UpstreamCode, "account_scoped", lastFailure.AccountScoped, "account_blocked", true) + continue + } else if egressForbidden && !finalEgressForbidden { + // A non-blocking 403 is an egress/browser-session failure and must not penalize the account. + delete(excluded, credential.ID) + lease.Release() + lastErr = fmt.Errorf("上游出口会话被拒绝") + continue + } else { + // Restore the consumed final non-blocking 403 body for the common response path. + response.Body = io.NopCloser(bytes.NewReader(body)) + } + } + if isTerminalRequestForbidden(credential.Provider, lastFailure) { + // already prepared as a terminal 403 response for the client + } else if isRetryableResponse(response, route.Provider) && !finalEgressForbidden { + retryAfter := parseRetryAfter(response.Header.Get("Retry-After"), time.Now().UTC()) + body, _ := readRetryableBody(response.Body) + lastFailure = newHTTPUpstreamFailure(response.StatusCode, body, credential.ID, credential.Name) + if response.StatusCode == http.StatusTooManyRequests && response.RateLimit == nil { + if metadata := provider.ParseRateLimitMetadata(body); metadata != nil { + response.RateLimit = metadata + if retryAfter <= 0 && metadata.RetryAfter > 0 { + retryAfter = metadata.RetryAfter + } + } + } + buildForbiddenReauth := credential.Provider == accountdomain.ProviderBuild && s.shouldInvalidateBuildForbidden(lastFailure) + if buildForbiddenReauth { + lastFailure.AccountScoped = true + } + // Unknown non-Super Build 403 responses retain the legacy short cooldown. + // Known quota, permission, block, and credential failures must reach their + // specific state transitions instead of being flattened into that fallback. + freeBuildForbidden := isUnclassifiedFreeBuildForbidden(response.StatusCode, credential, lease.Billing, lastFailure, buildForbiddenReauth) + if freeBuildForbidden { + lastFailure.AccountScoped = true + } + if response.StatusCode == http.StatusTooManyRequests && response.RateLimit != nil && response.RateLimit.Model == route.UpstreamModel { + rateLimitMeta := *response.RateLimit + if strings.TrimSpace(rateLimitMeta.TeamID) == "" { + rateLimitMeta.TeamID = strings.TrimSpace(credential.TeamID) + } + if rateLimitMeta.TeamID == "" { + // Team+Model shielding requires a team identity; fall through to account-scoped 429 handling. + goto afterTeamRateLimit + } + limited := s.markTeamModelRateLimit(credential, route.UpstreamModel, rateLimitMeta, time.Now().UTC()) + lastFailure.AccountScoped = false + lastFailure.Fingerprint = "429:team_model_rate_limit" + lastFailure.RetryAfter = time.Until(limited.Until) + lease.Release() + lastErr = fmt.Errorf("上游 Team 与模型请求频率受限") + s.logger.Warn("upstream_team_model_rate_limited", "request_id", input.RequestID, "provider", credential.Provider, "model", route.UpstreamModel, "team_fingerprint", limited.TeamFingerprint, "scope", rateLimitMeta.Scope, "actual", rateLimitMeta.Actual, "limit", rateLimitMeta.Limit, "retry_after", lastFailure.RetryAfter) + continue + } + afterTeamRateLimit: + // Grok Build treats only HTTP 401 as an OAuth authentication failure. + // A 403 is already authenticated and must not trigger token rotation or + // replay the same request with freshly issued credentials. + if credential.Provider != accountdomain.ProviderBuild && s.providers.SupportsCredentialRefresh(credential.Provider) && !authRecoveryAttempted[credential.ID] && credential.EncryptedRefreshToken != "" && !lastFailure.AccountBlocked && !buildForbiddenReauth && (lastFailure.PermanentAccountDenial || lastFailure.CredentialRejected) { + authRecoveryAttempted[credential.ID] = true + refreshed, refreshErr := ensureCredential(credential, true) + if refreshErr != nil { + lease.Release() + lastErr = refreshErr + lastFailure = newCredentialUpstreamFailure(refreshErr, credential.ID, credential.Name) + continue attemptLoop + } + response, err = forwardResponse(lease, refreshed, lease.Billing) + credential = refreshed + if err != nil { + lease.Release() + lastErr = err + if ctx.Err() != nil || errors.Is(err, context.Canceled) { + lastFailure = &UpstreamFailure{HTTPStatus: 499, Code: "request_canceled", PublicMessage: "请求已取消", AccountID: credential.ID, AccountName: credential.Name, Cause: firstError(ctx.Err(), err)} + break attemptLoop + } + lastFailure = newTransportUpstreamFailure(err, credential.ID, credential.Name) + if !isRetryableTransportFailure(credential.Provider, err) { + break attemptLoop + } + continue attemptLoop + } + goto handleResponse + } + failureHandled := false + if freeBuildForbidden { + s.selector.MarkFailure(ctx, credential, response.StatusCode, retryAfter) + failureHandled = true + } else if lease.QuotaMode != "" && response.StatusCode == http.StatusTooManyRequests { + exhausted, reconcileErr := s.accounts.ReconcileRateLimit(ctx, credential.ID, lease.QuotaMode, retryAfter) + s.selector.MarkQuotaStateChanged(credential.Provider) + failureHandled = reconcileErr == nil && exhausted + } else if used, limit, exhausted := parseFreeQuotaExhaustion(body); exhausted { + // The Free subscription signal is account-scoped, but its billing + // period is not a reliable reset promise. Probe again after 24 hours. + s.selector.MarkFreeQuotaExhausted(ctx, credential, used, limit) + failureHandled = true + } else if lastFailure.ModelQuotaExhausted { + s.selector.MarkModelQuotaExhausted(ctx, credential, lease.Billing, route.UpstreamModel, retryAfter) + failureHandled = true + } else if lastFailure.FreeQuotaExhausted { + s.selector.MarkFreeQuotaExhausted(ctx, credential, 0, 0) + failureHandled = true + } else if lastFailure.QuotaExhausted { + s.selector.MarkPaymentQuotaExhausted(ctx, credential, quotaRecoveryHints{ + Billing: lease.Billing, + }) + failureHandled = true + } + if lastFailure.AccountBlocked { + failureHandled = s.markReauthRequired(ctx, input.RequestID, credential, fmt.Sprintf("%s account is blocked", credential.Provider)) + } else if buildForbiddenReauth { + failureHandled = s.markReauthRequired(ctx, input.RequestID, credential, fmt.Sprintf("%s upstream error code %s matched the invalidation policy", credential.Provider, lastFailure.UpstreamCode)) + } else if s.providers.SupportsCredentialRefresh(credential.Provider) && lastFailure.PermanentAccountDenial { + if credential.Provider == accountdomain.ProviderBuild { + // A Build account may lack permission for one chat model while its OAuth credential and video + // access remain valid. Isolate this denial to the model; reauthorization is needed only when the credential is rejected. + s.selector.MarkModelAccessDenied(ctx, credential, route.UpstreamModel, retryAfter) + failureHandled = true + } else { + failureHandled = s.markReauthRequired(ctx, input.RequestID, credential, fmt.Sprintf("%s chat endpoint access denied", credential.Provider)) + } + } else if s.providers.SupportsCredentialRefresh(credential.Provider) && lastFailure.CredentialRejected { + failureHandled = s.markReauthRequired(ctx, input.RequestID, credential, fmt.Sprintf("%s credential rejected", credential.Provider)) + } + if lastFailure.AccountScoped && !failureHandled { + s.selector.MarkFailure(ctx, credential, response.StatusCode, retryAfter) + } + lease.Release() + lastErr = fmt.Errorf("上游返回 %d", response.StatusCode) + s.logger.Warn("upstream_request_failed", "request_id", input.RequestID, "account_id", credential.ID, "provider", credential.Provider, "status", response.StatusCode, "upstream_code", lastFailure.UpstreamCode, "account_scoped", lastFailure.AccountScoped) + if !lastFailure.AccountScoped { + failureFingerprints[lastFailure.Fingerprint]++ + if failureFingerprints[lastFailure.Fingerprint] >= 2 { + break + } + } + continue + } + if response.StatusCode >= 200 && response.StatusCode < 300 { + s.selector.markSuccess(ctx, credential, lease.QuotaProbe) + if diagnostic := response.RecoveredPrimaryFailure; diagnostic != nil { + recoveredFailure := newHTTPUpstreamFailure(diagnostic.StatusCode, diagnostic.Body, credential.ID, credential.Name) + if recoveredFailure.AccountBlocked || (credential.Provider == accountdomain.ProviderBuild && s.shouldInvalidateBuildForbidden(recoveredFailure)) { + reason := fmt.Sprintf("%s primary endpoint denied account access", credential.Provider) + if !s.markReauthRequired(ctx, input.RequestID, credential, reason) { + s.selector.MarkModelAccessDenied(ctx, credential, route.UpstreamModel, 0) + } + } + } + } + accountID := credential.ID + var once sync.Once + finalize := func(usage Usage, responseID, errorCode string) { + once.Do(func() { + successful := response.StatusCode >= 200 && response.StatusCode < 300 && errorCode == "" + lease.completeSelectorObservation(successful) + budget := newFinalizationBudget(string(operation), string(route.Provider)) + if isUpstreamStreamFailure(errorCode) { + if err := budget.run("account_health", finalizationHealthBudget, func(stageCtx context.Context) error { + return s.selector.MarkFailureAfterSuccess(stageCtx, credential, http.StatusBadGateway, 0) + }); err != nil { + s.logger.Warn("stream_failure_health_write_failed", "account_id", credential.ID, "provider", credential.Provider, "error", err) + } + } + lease.Release() + now := time.Now().UTC() + record := auditBase + record.AccountID = &accountID + record.AccountName = credential.Name + record.StatusCode = response.StatusCode + record.InputTokens = usage.InputTokens + record.CachedInputTokens = usage.CachedInputTokens + record.OutputTokens = usage.OutputTokens + record.ReasoningTokens = usage.ReasoningTokens + record.TotalTokens = usage.TotalTokens + record.CostInUSDTicks = usage.CostInUSDTicks + imagePricing, imagePriced := audit.EstimateOfficialImageCost(pricingModel, "", response.QuotaUnits) + if imagePriced { + record.MediaOutputImages = int64(max(0, response.QuotaUnits)) + } + tokenPricing, tokenPriced := audit.EstimateOfficialCost(pricingModel, usage.InputTokens, usage.CachedInputTokens, usage.OutputTokens, usage.ContextInputTokens) + if response.StatusCode >= 200 && response.StatusCode < 300 && errorCode == "" && imagePriced { + record.EstimatedCostInUSDTicks = imagePricing.CostInUSDTicks + record.PricingModel = imagePricing.Model + record.PricingVersion = audit.OfficialPricingAsOf + } else if tokenPriced { + record.EstimatedCostInUSDTicks = tokenPricing.CostInUSDTicks + record.PricingModel = tokenPricing.Model + record.PricingVersion = audit.OfficialPricingAsOf + } + record.NumSourcesUsed = usage.NumSourcesUsed + record.NumServerSideToolsUsed = usage.NumServerSideToolsUsed + record.ContextInputTokens = usage.ContextInputTokens + record.ContextOutputTokens = usage.ContextOutputTokens + if successful && input.Streaming { + record.FirstTokenMS = firstToken.milliseconds() + } + record.DurationMS = time.Since(startedAt).Milliseconds() + record.ErrorCode = errorCode + attempts := failureAttempts.snapshot() + if response.StatusCode < 200 || response.StatusCode >= 300 || errorCode != "" || len(attempts) > 0 { + record.Attempts = attempts + } + record.CreatedAt = now + applyAuditEgress(&record, egressTrace, route.Provider) + if supportsStoredResponses && operation == audit.OperationResponses && responseID != "" && successful { + err := budget.run("response_ownership", finalizationOwnershipBudget, func(stageCtx context.Context) error { + return s.responses.Save(stageCtx, inferencedomain.ResponseOwnership{ResponseID: responseID, AccountID: accountID, ClientKeyID: input.ClientKey.ID, Provider: route.Provider, PromptCacheKey: ownershipPromptCacheKey, ReasoningReplayKey: reasoningReplayKey, ExpiresAt: now.Add(responseOwnershipTTL), CreatedAt: now, UpdatedAt: now}) + }) + if err != nil { + s.logger.Error("response_ownership_save_failed", "response_id", responseID, "client_key_id", input.ClientKey.ID, "account_id", accountID, "provider", route.Provider, "error", err) + } + } + if successful && lease.QuotaMode != "" { + if lease.QuotaMode != "weekly" { + units := max(1, response.QuotaUnits) + var updated bool + err := budget.run("quota_decrement", finalizationQuotaBudget, func(stageCtx context.Context) error { + var decrementErr error + updated, decrementErr = s.accounts.DecrementQuota(stageCtx, accountID, lease.QuotaMode, units) + return decrementErr + }) + if err != nil { + s.logger.Warn("provider_quota_decrement_failed", "provider", credential.Provider, "account_id", accountID, "mode", lease.QuotaMode, "units", units, "error", err) + } else if updated { + s.selector.ConsumeQuota(credential.Provider, accountID, lease.QuotaMode, units) + } + } + } + if err := budget.run("audit", finalizationAuditBudget, func(stageCtx context.Context) error { + return s.audits.Create(stageCtx, record) + }); err != nil { + s.logger.Error("request_usage_write_failed", "event_id", record.EventID, "request_id", input.RequestID, "error", err) + } + if usage.ResponseModel != "" { + _ = budget.run("observed_model", finalizationMetadataBudget, func(stageCtx context.Context) error { + return s.accounts.ObserveResponseModel(stageCtx, accountID, usage.ResponseModel) + }) + } + if successful && lease.QuotaMode != "" { + if quotaKind, _ := s.providers.QuotaKind(credential.Provider); quotaKind == provider.QuotaRemoteWindow { + s.accounts.QueueQuotaRefresh(accountID, lease.QuotaMode) + } + } + outcome := "failed" + if successful { + outcome = "success" + } + timing.finish(s.logger, outcome) + }) + } + response.Body = &firstByteReadCloser{ReadCloser: response.Body, mark: timing.markFirstBody} + recordStreamFailure := func(diagnostic StreamFailureDiagnostic) { + failureAttempts.captureStreamFailure(credential, responseStartedAt, response, diagnostic) + } + var markFirstToken func() + if firstToken != nil { + markFirstToken = firstToken.mark + } + timingHandedOff = true + return &Result{StatusCode: response.StatusCode, Status: response.Status, Header: response.Header, Body: &finalizingBody{ReadCloser: response.Body, finalize: func() { finalize(Usage{}, "", "stream_closed") }}, MarkFirstToken: markFirstToken, RecordStreamFailure: recordStreamFailure, Finalize: finalize}, nil + } + if lastFailure != nil { + record := auditBase + record.StatusCode = lastFailure.HTTPStatus + record.DurationMS = time.Since(startedAt).Milliseconds() + record.ErrorCode = lastFailure.AuditCode() + record.Attempts = failureAttempts.snapshot() + record.CreatedAt = time.Now().UTC() + applyAuditEgress(&record, egressTrace, route.Provider) + if lastFailure.AccountID != 0 { + accountID := lastFailure.AccountID + record.AccountID = &accountID + record.AccountName = lastFailure.AccountName + } + persistCtx, cancel := context.WithTimeout(context.Background(), finalizationTimeout) + defer cancel() + if err := s.audits.Create(persistCtx, record); err != nil { + s.logger.Error("request_usage_write_failed", "event_id", record.EventID, "request_id", input.RequestID, "error", err) + } + return nil, lastFailure + } + if lastErr == nil { + lastErr = ErrNoAvailableAccount + } + record := auditBase + record.StatusCode = http.StatusServiceUnavailable + record.DurationMS = time.Since(startedAt).Milliseconds() + record.ErrorCode = "upstream_unavailable" + var selectionFailure *SelectionUnavailableError + if errors.As(lastErr, &selectionFailure) { + record.StatusCode = selectionFailure.HTTPStatus() + record.ErrorCode = selectionFailure.Code() + } + record.Attempts = failureAttempts.snapshot() + record.CreatedAt = time.Now().UTC() + applyAuditEgress(&record, egressTrace, route.Provider) + persistCtx, cancel := context.WithTimeout(context.Background(), finalizationTimeout) + defer cancel() + if err := s.audits.Create(persistCtx, record); err != nil { + s.logger.Error("request_usage_write_failed", "event_id", record.EventID, "request_id", input.RequestID, "error", err) + } + return nil, fmt.Errorf("%w: %w", ErrNoAvailableAccount, lastErr) +} + +func isUpstreamStreamFailure(errorCode string) bool { + switch errorCode { + case "upstream_stream_incomplete", "upstream_stream_interrupted": + return true + default: + return false + } +} + +func isRetryableTransportFailure(providerValue accountdomain.Provider, err error) bool { + return providerValue != accountdomain.ProviderBuild || !neterrorpkg.IsResponseHeaderTimeout(err) +} + +func isSSOCredentialRejected(err error, credential accountdomain.Credential) bool { + if credential.AuthType != accountdomain.AuthTypeSSO || err == nil { + return false + } + if errors.Is(err, provider.ErrUnauthorized) { + return true + } + status, ok := provider.ErrorHTTPStatus(err) + return ok && status == http.StatusUnauthorized +} + +func (s *Service) markSSOCredentialRejected(ctx context.Context, credential accountdomain.Credential, reason string) { + if credential.AuthType != accountdomain.AuthTypeSSO { + return + } + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finalizationTimeout) + defer cancel() + if err := s.accounts.MarkReauthRequired(writeCtx, credential.ID, reason); err != nil { + s.logger.Error("account_reauth_required_write_failed", "account_id", credential.ID, "provider", credential.Provider, "error", err) + } + // Discard the process-local one-second candidate snapshot even if persistence fails, + // preventing the invalid account from being selected by the next request. + s.selector.MarkQuotaStateChanged(credential.Provider) +} + +func (s *Service) queueAccountModelSync(accountID uint64) { + syncer, ok := s.models.(accountModelSyncer) + if !ok || accountID == 0 { + return + } + s.modelSyncMu.Lock() + if s.modelSyncing == nil { + s.modelSyncing = make(map[uint64]struct{}) + } + if _, exists := s.modelSyncing[accountID]; exists { + s.modelSyncMu.Unlock() + return + } + s.modelSyncing[accountID] = struct{}{} + s.modelSyncMu.Unlock() + + go func() { + defer func() { + s.modelSyncMu.Lock() + delete(s.modelSyncing, accountID) + s.modelSyncMu.Unlock() + }() + ctx, cancel := context.WithTimeout(context.Background(), modelCatalogRefreshTimeout) + defer cancel() + logger := s.logger + if logger == nil { + logger = slog.Default() + } + count, err := syncer.SyncAccount(ctx, accountID) + if err != nil { + logger.Warn("model_etag_refresh_failed", "account_id", accountID, "error", err) + return + } + logger.Info("model_etag_refresh_completed", "account_id", accountID, "models", count) + }() +} + +func rewriteAliasedModel(body []byte, publicModel, reasoningEffort string, operation audit.Operation) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("解析兼容模型请求: %w", err) + } + payload["model"] = publicModel + if reasoningEffort != "" { + switch operation { + case audit.OperationChat: + payload["reasoning_effort"] = reasoningEffort + case audit.OperationMessages: + config, _ := payload["output_config"].(map[string]any) + if reasoningEffort == modeldomain.ReasoningEffortNone { + if config != nil { + delete(config, "effort") + } + if len(config) == 0 { + delete(payload, "output_config") + } else { + payload["output_config"] = config + } + payload["thinking"] = map[string]any{"type": "disabled"} + break + } + if config == nil { + config = make(map[string]any) + } + config["effort"] = reasoningEffort + payload["output_config"] = config + payload["thinking"] = map[string]any{"type": "adaptive"} + default: + reasoning, _ := payload["reasoning"].(map[string]any) + if reasoning == nil { + reasoning = make(map[string]any) + } + reasoning["effort"] = reasoningEffort + payload["reasoning"] = reasoning + } + } + return json.Marshal(payload) +} + +type ResourceInput struct { + ClientKey clientkey.Key + ResponseID string + RawQuery string +} + +func (s *Service) cancelBillingReservation(eventID string) { + ctx, cancel := context.WithTimeout(context.Background(), finalizationTimeout) + defer cancel() + if err := s.clientKeys.CancelBilling(ctx, eventID); err != nil { + s.logger.Error("billing_reservation_cancel_failed", "event_id", eventID, "error", err) + } +} + +func newAuditEventID() string { + value, err := security.NewOpaqueToken(18) + if err != nil || value == "" { + return fmt.Sprintf("evt_%d", time.Now().UnixNano()) + } + return "evt_" + value +} + +func (s *Service) GetResponse(ctx context.Context, input ResourceInput) (*Result, error) { + return s.forwardOwnedResponse(ctx, input, http.MethodGet) +} + +func (s *Service) DeleteResponse(ctx context.Context, input ResourceInput) (*Result, error) { + return s.forwardOwnedResponse(ctx, input, http.MethodDelete) +} + +func (s *Service) forwardOwnedResponse(ctx context.Context, input ResourceInput, method string) (*Result, error) { + ownership, err := s.responses.Get(ctx, input.ResponseID, input.ClientKey.ID, time.Now().UTC()) + if err != nil { + return nil, ErrResponseNotFound + } + if !s.providers.SupportsStoredResponses(ownership.Provider) { + _ = s.responses.Delete(ctx, input.ResponseID, input.ClientKey.ID) + return nil, ErrResponseNotFound + } + accountScope := input.ClientKey.AccountScope() + if !accountScope.AllowsProvider(ownership.Provider) { + return nil, &SelectionUnavailableError{Reason: SelectionNoAccounts, Scope: accountScope} + } + adapter, ok := s.providers.Responses(ownership.Provider) + if !ok { + return nil, ErrResponseAccountUnavailable + } + operation := "response_get" + if method == http.MethodDelete { + operation = "response_delete" + } + physicalCallCtx := infraegress.WithPhysicalCallTrace(ctx, string(ownership.Provider), operation) + lease, err := s.selector.AcquirePinnedForKey(ctx, ownership.Provider, ownership.AccountID, 0, "", "", false, accountScope) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrResponseAccountUnavailable, err) + } + credential, err := s.accounts.EnsureCredential(ctx, lease.Credential, false) + if err != nil { + lease.Release() + return nil, fmt.Errorf("%w: %w", ErrResponseAccountUnavailable, err) + } + path := "/responses/" + url.PathEscape(input.ResponseID) + if input.RawQuery != "" { + path += "?" + input.RawQuery + } + response, err := adapter.ForwardResponse(physicalCallCtx, provider.ResponseResourceRequest{Credential: credential, Method: method, Path: path}) + if err != nil { + if isSSOCredentialRejected(err, credential) { + s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider)) + } + lease.Release() + return nil, err + } + if response.StatusCode == http.StatusUnauthorized { + response.Body.Close() + if credential.AuthType == accountdomain.AuthTypeSSO { + s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider)) + lease.Release() + return nil, ErrResponseAccountUnavailable + } + if s.markPermanentlyUnrefreshableCredentialRejected(ctx, credential) { + lease.Release() + return nil, fmt.Errorf("%w: %w", ErrResponseAccountUnavailable, accountapp.ErrCredentialRefreshPermanent) + } + refreshed, refreshErr := s.accounts.EnsureCredential(ctx, credential, true) + if refreshErr != nil { + if errors.Is(refreshErr, accountapp.ErrCredentialRefreshPermanent) { + s.markCredentialRejectedAfterPermanentRefresh(ctx, credential) + } + lease.Release() + return nil, refreshErr + } + response, err = adapter.ForwardResponse(physicalCallCtx, provider.ResponseResourceRequest{Credential: refreshed, Method: method, Path: path}) + credential = refreshed + if err != nil { + lease.Release() + return nil, err + } + } + if response.StatusCode >= 200 && response.StatusCode < 300 { + s.selector.markSuccess(ctx, credential, false) + if method == http.MethodDelete { + _ = s.responses.Delete(ctx, input.ResponseID, input.ClientKey.ID) + } + } else if response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone { + _ = s.responses.Delete(ctx, input.ResponseID, input.ClientKey.ID) + } + var once sync.Once + release := func() { once.Do(lease.Release) } + finalize := func(Usage, string, string) { release() } + return &Result{StatusCode: response.StatusCode, Status: response.Status, Header: response.Header, Body: &finalizingBody{ReadCloser: response.Body, finalize: release}, Finalize: finalize}, nil +} + +// markPermanentlyUnrefreshableCredentialRejected removes an account from the pool after a real upstream request confirms its access token is invalid. +func (s *Service) markPermanentlyUnrefreshableCredentialRejected(ctx context.Context, credential accountdomain.Credential) bool { + if !credential.RefreshPermanent { + return false + } + s.markCredentialRejectedAfterPermanentRefresh(ctx, credential) + return true +} + +func (s *Service) markCredentialRejectedAfterPermanentRefresh(ctx context.Context, credential accountdomain.Credential) { + _ = s.accounts.MarkReauthRequired(ctx, credential.ID, fmt.Sprintf("%s OAuth access token rejected after permanent refresh failure", credential.Provider)) + s.selector.MarkQuotaStateChanged(credential.Provider) +} + +func readRetryableBody(body io.ReadCloser) ([]byte, error) { + if body == nil { + return nil, nil + } + defer body.Close() + data, _, err := provider.ReadDiagnosticBody(body) + return data, err +} + +func parseFreeQuotaExhaustion(body []byte) (int64, int64, bool) { + text := strings.ToLower(string(body)) + if !strings.Contains(text, "subscription:free-usage-exhausted") { + return 0, 0, false + } + matches := freeQuotaUsagePattern.FindSubmatch(body) + if len(matches) != 3 { + return 0, 0, true + } + used, usedErr := strconv.ParseInt(string(matches[1]), 10, 64) + limit, limitErr := strconv.ParseInt(string(matches[2]), 10, 64) + if usedErr != nil || limitErr != nil { + return 0, 0, true + } + return used, limit, true +} + +type finalizingBody struct { + io.ReadCloser + finalize func() +} + +func (b *finalizingBody) Close() error { + err := b.ReadCloser.Close() + if b.finalize != nil { + b.finalize() + } + return err +} + +func isRetryable(status int) bool { + return status == 402 || status == 403 || status == 429 || status >= 500 +} + +func isRetryableResponse(response *provider.Response, upstreamProvider accountdomain.Provider) bool { + if response == nil || !isRetryable(response.StatusCode) { + return false + } + // Account-scoped payment failures must always rotate accounts. + // Upstream X-Should-Retry:false is only honored for non-account errors (e.g. 5xx history). + if forcesAccountFailover(response.StatusCode, upstreamProvider) { + return true + } + return !strings.EqualFold(strings.TrimSpace(response.Header.Get("X-Should-Retry")), "false") +} + +// isBarePermissionDenied reports a 403 whose only machine signal is permission-denied +// without explicit access-denied / blocked / safety wording. Such failures are request- +// unknown: do not cool, reauth, or model-deny the account. +func isBarePermissionDenied(failure *UpstreamFailure) bool { + if failure == nil || failure.HTTPStatus != http.StatusForbidden { + return false + } + if failure.SafetyRejection || failure.AccountBlocked || failure.PermanentAccountDenial || failure.QuotaExhausted || failure.CredentialRejected { + return false + } + code := strings.ToLower(strings.TrimSpace(failure.UpstreamCode)) + return code == "permission-denied" || code == "permission_denied" +} + +// isTerminalRequestForbidden identifies request-level 403 responses that must +// be returned without account or egress side effects. Bare permission-denied +// is Build-specific; Web and Console must retain their browser/clearance retry. +func isTerminalRequestForbidden(upstreamProvider accountdomain.Provider, failure *UpstreamFailure) bool { + if failure == nil { + return false + } + return failure.SafetyRejection || + (upstreamProvider == accountdomain.ProviderBuild && isBarePermissionDenied(failure)) +} + +func isUnclassifiedFreeBuildForbidden(status int, credential accountdomain.Credential, billing *accountdomain.Billing, failure *UpstreamFailure, configuredInvalidation bool) bool { + if status != http.StatusForbidden || credential.Provider != accountdomain.ProviderBuild || accountdomain.IsBuildSuper(credential, billing) || failure == nil { + return false + } + return !configuredInvalidation && !failure.SafetyRejection && !failure.AccountScoped && !isBarePermissionDenied(failure) +} + +// forcesAccountFailover keeps Build account-scoped billing, permission, and rate-limit +// failures on the account-rotation path so their state can be recorded before another +// account is selected. free-usage 429 and Team RPS 429 both need rotation even when +// upstream sets X-Should-Retry:false. +func forcesAccountFailover(status int, upstreamProvider accountdomain.Provider) bool { + return upstreamProvider == accountdomain.ProviderBuild && + (status == http.StatusPaymentRequired || status == http.StatusForbidden || status == http.StatusTooManyRequests) +} + +func parseRetryAfter(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if parsed, err := http.ParseTime(value); err == nil && parsed.After(now) { + return parsed.Sub(now) + } + return 0 +} + +func firstError(values ...error) error { + for _, value := range values { + if value != nil { + return value + } + } + return errors.New("未知上游错误") +} diff --git a/backend/internal/application/gateway/service_test.go b/backend/internal/application/gateway/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..607209aed44dc0e49bd21299d2ae7d2d91931fba --- /dev/null +++ b/backend/internal/application/gateway/service_test.go @@ -0,0 +1,3342 @@ +package gateway + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestQueueAccountModelSyncDeduplicatesConcurrentETagRefresh(t *testing.T) { + resolver := &etagSyncResolver{started: make(chan uint64, 2), release: make(chan struct{})} + service := &Service{models: resolver, logger: slog.Default(), modelSyncing: make(map[uint64]struct{})} + service.queueAccountModelSync(42) + service.queueAccountModelSync(42) + select { + case accountID := <-resolver.started: + if accountID != 42 { + t.Fatalf("account id = %d", accountID) + } + case <-time.After(time.Second): + t.Fatal("模型 ETag 刷新未启动") + } + if calls := resolver.calls.Load(); calls != 1 { + t.Fatalf("concurrent sync calls = %d", calls) + } + close(resolver.release) +} + +type etagSyncResolver struct { + calls atomic.Int64 + started chan uint64 + release chan struct{} +} + +func (r *etagSyncResolver) SyncAccount(_ context.Context, accountID uint64) (int, error) { + r.calls.Add(1) + r.started <- accountID + <-r.release + return 1, nil +} + +func (r *etagSyncResolver) Get(context.Context, uint64) (modeldomain.Route, error) { + return modeldomain.Route{}, repository.ErrNotFound +} + +func (r *etagSyncResolver) GetByPublicID(context.Context, string) (modeldomain.Route, error) { + return modeldomain.Route{}, repository.ErrNotFound +} + +func (r *etagSyncResolver) GetByPublicIDCandidates(context.Context, string) ([]modeldomain.Route, error) { + return nil, repository.ErrNotFound +} + +func (r *etagSyncResolver) GetByProviderUpstream(context.Context, account.Provider, string) (modeldomain.Route, error) { + return modeldomain.Route{}, repository.ErrNotFound +} + +func TestGatewayFailsOverBeforeReturningBody(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "gateway.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + first, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "first", SourceKey: "first", EncryptedAccessToken: "one", ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 200, MaxConcurrent: 1}) + if err != nil { + t.Fatal(err) + } + second, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "second", SourceKey: "second", EncryptedAccessToken: "two", ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1}) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-test"}); err != nil { + t.Fatal(err) + } + for _, accountID := range []uint64{first.ID, second.ID} { + if err := modelRepo.ReplaceAccountCapabilities(ctx, accountID, []string{"grok-test"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{Name: "test-key", Prefix: "test-prefix", SecretHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", EncryptedSecret: "encrypted-key", Enabled: true, RPMLimit: 120, MaxConcurrent: 8}) + if err != nil { + t.Fatal(err) + } + + adapter := &failoverAdapter{ + firstID: first.ID, failureStatus: http.StatusPaymentRequired, + failureBody: `{"code":"personal-team-blocked:spending-limit","error":"You have run out of credits"}`, + failureHeader: http.Header{"X-Should-Retry": {"false"}}, + } + registry := provider.NewRegistry(adapter) + cipher := testCipher(t) + sticky := memory.NewStickyStore() + concurrency := memory.NewConcurrencyLimiter() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, cipher, nil) + clientService := clientkeyapp.NewService(nil, nil, nil, 60, 4, nil) + selector := NewSelector(accountRepo, concurrency, sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientService, registry, selector, responseRepo, 3) + result, err := service.CreateResponse(ctx, Input{RequestID: "req-1", ClientKey: clientKey, PublicModel: "grok-test", Body: []byte(`{"model":"grok-test"}`), PromptCacheSeed: "claude-session", GrokTurnIndex: "3"}) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(result.Body) + if err != nil { + t.Fatal(err) + } + result.Finalize(Usage{InputTokens: 120, CachedInputTokens: 80, OutputTokens: 30, TotalTokens: 150, ResponseModel: "grok-test-build-free"}, "resp-test", "") + _ = result.Body.Close() + if string(body) != "ok" { + t.Fatalf("body = %q", body) + } + if len(adapter.attempts) != 2 || adapter.attempts[0] != first.ID || adapter.attempts[1] != second.ID { + t.Fatalf("attempts = %#v", adapter.attempts) + } + identity := resolveBuildSessionIdentity(clientKey.ID, account.ProviderBuild, "grok-test", "", "claude-session", nil) + expectedCacheKey := identity.upstreamID + if adapter.lastPromptCacheKey != expectedCacheKey { + t.Fatalf("prompt cache key = %q, want %q", adapter.lastPromptCacheKey, expectedCacheKey) + } + if adapter.lastReasoningReplayKey != identity.replayKey { + t.Fatalf("reasoning replay key = %q, want %q", adapter.lastReasoningReplayKey, identity.replayKey) + } + if adapter.lastGrokTurnIndex != "3" { + t.Fatalf("Grok turn index = %q, want 3", adapter.lastGrokTurnIndex) + } + if boundID, ok, err := sticky.Get(ctx, stickySessionKey(identity.affinityKey), time.Now().UTC()); err != nil || !ok || boundID != second.ID { + t.Fatalf("failover sticky binding = %d, %v, err = %v; want account %d", boundID, ok, err, second.ID) + } + observedAccount, err := accountRepo.Get(ctx, second.ID) + if err != nil || observedAccount.ObservedModel != "grok-test-build-free" { + t.Fatalf("observed account = %#v, err = %v", observedAccount, err) + } + logs, total, err := auditRepo.List(ctx, 0, 10) + if err != nil || total != 1 || logs[0].AccountID == nil || *logs[0].AccountID != second.ID || logs[0].ClientKeyName != "test-key" || logs[0].ModelPublicID != "grok-test" || logs[0].ModelUpstreamModel != "Build/grok-test" || logs[0].AccountName != "second" || logs[0].CachedInputTokens != 80 || logs[0].StatusCode != http.StatusOK || logs[0].AttemptCount != 1 { + t.Fatalf("audit = %#v, %d, %v", logs, total, err) + } + detail, err := auditRepo.Get(ctx, logs[0].ID) + if err != nil || len(detail.Attempts) != 1 || detail.Attempts[0].UpstreamStatusCode == nil || *detail.Attempts[0].UpstreamStatusCode != http.StatusPaymentRequired || !strings.Contains(string(detail.Attempts[0].ResponseBody), "personal-team-blocked:spending-limit") { + t.Fatalf("audit detail = %#v, err = %v", detail, err) + } + ownership, err := responseRepo.Get(ctx, "resp-test", clientKey.ID, time.Now().UTC()) + if err != nil || ownership.AccountID != second.ID || ownership.PromptCacheKey != expectedCacheKey || ownership.ReasoningReplayKey != identity.replayKey { + t.Fatalf("ownership = %#v, err = %v", ownership, err) + } + + compacted, err := service.CreateResponse(ctx, Input{ + RequestID: "req-compact", ClientKey: clientKey, PublicModel: "grok-test", PromptCacheSeed: "claude-session", + Body: []byte(`{"model":"grok-test","stream":true,"input":[{"role":"user","content":"continue"},{"type":"compaction_trigger"}]}`), Streaming: true, + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(compacted.Body) + if compacted.MarkFirstToken == nil { + t.Fatal("first token marker is nil") + } + compacted.MarkFirstToken() + compacted.Finalize(Usage{}, "resp-compact", "") + _ = compacted.Body.Close() + logs, total, err = auditRepo.List(ctx, 0, 10) + if err != nil || total != 2 || logs[0].Operation != audit.OperationCompaction || !logs[0].Streaming || logs[0].FirstTokenMS == nil { + t.Fatalf("compaction audit = %#v, total=%d, err=%v", logs, total, err) + } + if _, err := responseRepo.Get(ctx, "resp-compact", clientKey.ID, time.Now().UTC()); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("compaction response ownership err = %v", err) + } + + adapter.resetAttempts() + continued, err := service.CreateResponse(ctx, Input{RequestID: "req-2", ClientKey: clientKey, PublicModel: "grok-test", PreviousResponseID: "resp-test", Body: []byte(`{"model":"grok-test","previous_response_id":"resp-test"}`)}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(continued.Body) + continued.Finalize(Usage{}, "resp-next", "") + _ = continued.Body.Close() + if len(adapter.attempts) != 1 || adapter.attempts[0] != second.ID { + t.Fatalf("continued attempts = %#v", adapter.attempts) + } + if adapter.lastPromptCacheKey != expectedCacheKey || adapter.lastReasoningReplayKey != identity.replayKey { + t.Fatalf("continued session identity drifted: cache=%q replay=%q", adapter.lastPromptCacheKey, adapter.lastReasoningReplayKey) + } + nextOwnership, err := responseRepo.Get(ctx, "resp-next", clientKey.ID, time.Now().UTC()) + if err != nil || nextOwnership.PromptCacheKey != expectedCacheKey || nextOwnership.ReasoningReplayKey != identity.replayKey { + t.Fatalf("continued ownership = %#v, err = %v", nextOwnership, err) + } + + adapter.resetAttempts() + blockedKey := clientKey + blockedKey.ProviderScope = clientkey.ProviderScopeConsole + if _, err := service.GetResponse(ctx, ResourceInput{ClientKey: blockedKey, ResponseID: "resp-test"}); err == nil { + t.Fatal("owned response should be rejected after its provider leaves the key scope") + } else { + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" || len(adapter.attempts) != 0 { + t.Fatalf("scoped owned response error = %#v, attempts = %#v, err = %v", unavailable, adapter.attempts, err) + } + } + resource, err := service.GetResponse(ctx, ResourceInput{ClientKey: clientKey, ResponseID: "resp-test", RawQuery: "include=reasoning.encrypted_content"}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(resource.Body) + resource.Finalize(Usage{}, "", "") + _ = resource.Body.Close() + if adapter.lastPath != "/responses/resp-test?include=reasoning.encrypted_content" || adapter.lastMethod != http.MethodGet || len(adapter.attempts) != 1 || adapter.attempts[0] != second.ID { + t.Fatalf("resource request = %s %s, attempts = %#v", adapter.lastMethod, adapter.lastPath, adapter.attempts) + } + + deleted, err := service.DeleteResponse(ctx, ResourceInput{ClientKey: clientKey, ResponseID: "resp-test"}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(deleted.Body) + deleted.Finalize(Usage{}, "", "") + _ = deleted.Body.Close() + if _, err := responseRepo.Get(ctx, "resp-test", clientKey.ID, time.Now().UTC()); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("deleted ownership err = %v", err) + } + + adapter.setResourceStatus(http.StatusNotFound) + missing, err := service.GetResponse(ctx, ResourceInput{ClientKey: clientKey, ResponseID: "resp-next"}) + if err != nil { + t.Fatal(err) + } + _ = missing.Body.Close() + missing.Finalize(Usage{}, "", "") + if _, err := responseRepo.Get(ctx, "resp-next", clientKey.ID, time.Now().UTC()); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stale ownership err = %v", err) + } + + adapter.resetAttempts() + streamFailed, err := service.CreateResponse(ctx, Input{RequestID: "req-stream-failed", ClientKey: clientKey, PublicModel: "grok-test", Body: []byte(`{"model":"grok-test"}`), PromptCacheSeed: "stream-failed-session"}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(streamFailed.Body) + if streamFailed.RecordStreamFailure == nil { + t.Fatal("stream failure recorder is nil") + } + streamFailed.RecordStreamFailure(StreamFailureDiagnostic{Body: []byte(`{"type":"response.failed","error":{"message":"access_token=secret-token"}}`)}) + streamFailed.Finalize(Usage{}, "", "upstream_stream_error") + _ = streamFailed.Body.Close() + logs, _, err = auditRepo.List(ctx, 0, 10) + if err != nil || len(logs) == 0 { + t.Fatalf("stream failure audits = %#v, err = %v", logs, err) + } + streamDetail, err := auditRepo.Get(ctx, logs[0].ID) + if err != nil || streamDetail.ErrorCode != "upstream_stream_error" || streamDetail.AttemptCount != 1 || len(streamDetail.Attempts) != 1 { + t.Fatalf("stream failure detail = %#v, err = %v", streamDetail, err) + } + streamAttempt := streamDetail.Attempts[0] + if streamAttempt.Stage != "response_stream" || streamAttempt.UpstreamStatusCode == nil || *streamAttempt.UpstreamStatusCode != http.StatusOK || string(streamAttempt.ResponseBody) != `{"type":"response.failed","error":{"message":"access_token=[REDACTED]"}}` { + t.Fatalf("stream failure attempt = %#v", streamAttempt) + } + + adapter.resetAttempts() + expiredCooldown := time.Now().UTC().Add(-time.Minute) + for _, accountID := range []uint64{first.ID, second.ID} { + if err := accountRepo.UpdateHealth(ctx, accountID, 3, &expiredCooldown, "previous upstream failures", false); err != nil { + t.Fatal(err) + } + } + selector.ApplyInvalidation(repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild}) + interrupted, err := service.CreateResponse(ctx, Input{RequestID: "req-stream-cut", ClientKey: clientKey, PublicModel: "grok-test", Body: []byte(`{"model":"grok-test"}`), PromptCacheSeed: "other-session"}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(interrupted.Body) + healthBlocker := &blockingHealthAccountRepository{ + AccountRepository: accountRepo, started: make(chan struct{}), release: make(chan struct{}), + } + selector.accounts = healthBlocker + finalized := make(chan struct{}) + go func() { + interrupted.Finalize(Usage{}, "", "upstream_stream_incomplete") + close(finalized) + }() + select { + case <-healthBlocker.started: + case <-time.After(time.Second): + t.Fatal("stream failure health update did not start") + } + if len(adapter.attempts) != 1 { + t.Fatalf("interrupted attempts = %#v", adapter.attempts) + } + selectedAccountID := adapter.attempts[0] + if current, currentErr := concurrency.Current(ctx, accountConcurrencyKey(selectedAccountID)); currentErr != nil || current != 1 { + t.Fatalf("account lease was released before stream failure cooldown: current=%d err=%v", current, currentErr) + } + close(healthBlocker.release) + select { + case <-finalized: + case <-time.After(time.Second): + t.Fatal("stream failure finalization did not finish") + } + _ = interrupted.Body.Close() + interruptedAccount, err := accountRepo.Get(ctx, adapter.attempts[0]) + if err != nil || interruptedAccount.FailureCount != 1 || interruptedAccount.CooldownUntil == nil { + t.Fatalf("interrupted account health = %#v, err=%v", interruptedAccount, err) + } +} + +type blockingHealthAccountRepository struct { + repository.AccountRepository + started chan struct{} + release chan struct{} + once sync.Once +} + +func (r *blockingHealthAccountRepository) UpdateHealth(ctx context.Context, id uint64, failureCount int, cooldownUntil *time.Time, lastError string, success bool) error { + if !success { + r.once.Do(func() { close(r.started) }) + select { + case <-r.release: + case <-ctx.Done(): + return ctx.Err() + } + } + return r.AccountRepository.UpdateHealth(ctx, id, failureCount, cooldownUntil, lastError, success) +} + +func TestRoutingAttemptPolicy(t *testing.T) { + tests := []struct { + name string + configured int + allowed []bool + hasNext []bool + }{ + {name: "finite", configured: 2, allowed: []bool{true, true, false}, hasNext: []bool{true, false, false}}, + {name: "unlimited", configured: unlimitedRoutingAttempts, allowed: []bool{true, true, true}, hasNext: []bool{true, true, true}}, + {name: "invalid fallback", configured: 0, allowed: []bool{true, true, true, false}, hasNext: []bool{true, true, false, false}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := newRoutingAttemptPolicy(test.configured) + for attempt, want := range test.allowed { + if got := policy.allows(attempt); got != want { + t.Fatalf("allows(%d) = %t, want %t", attempt, got, want) + } + } + for attempt, want := range test.hasNext { + if got := policy.hasNext(attempt); got != want { + t.Fatalf("hasNext(%d) = %t, want %t", attempt, got, want) + } + } + }) + } +} + +func TestGatewayUnlimitedAttemptsExhaustsEligiblePool(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "gateway-unlimited-attempts.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credentials := make([]account.Credential, 0, 5) + for index := range 5 { + name := fmt.Sprintf("build-%d", index+1) + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: "encrypted-" + name, + ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 500 - index*100, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-unlimited"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-unlimited"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "unlimited-key", Prefix: "unlimited", SecretHash: strings.Repeat("a", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + failureIDs := make(map[uint64]bool, len(credentials)-1) + for _, credential := range credentials[:len(credentials)-1] { + failureIDs[credential.ID] = true + } + adapter := &failoverAdapter{ + failureIDs: failureIDs, failureStatus: http.StatusPaymentRequired, + failureBody: `{"code":"personal-team-blocked:spending-limit","error":"You have run out of credits"}`, + failureHeader: http.Header{"X-Should-Retry": {"false"}}, + } + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, unlimitedRoutingAttempts) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-unlimited", ClientKey: clientKey, PublicModel: "grok-unlimited", + Body: []byte(`{"model":"grok-unlimited","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("status = %d", result.StatusCode) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "resp-unlimited", "") + _ = result.Body.Close() + if len(adapter.attempts) != len(credentials) { + t.Fatalf("attempts = %#v, want all %d eligible accounts", adapter.attempts, len(credentials)) + } + for index, credential := range credentials { + if adapter.attempts[index] != credential.ID { + t.Fatalf("attempt %d used account %d, want %d", index, adapter.attempts[index], credential.ID) + } + } + + adapter.resetAttempts() + continued, err := service.CreateResponse(ctx, Input{ + RequestID: "req-unlimited-continued", ClientKey: clientKey, PublicModel: "grok-unlimited", + PreviousResponseID: "resp-unlimited", Body: []byte(`{"model":"grok-unlimited","previous_response_id":"resp-unlimited"}`), + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(continued.Body) + continued.Finalize(Usage{}, "resp-unlimited-next", "") + _ = continued.Body.Close() + if len(adapter.attempts) != 1 || adapter.attempts[0] != credentials[len(credentials)-1].ID { + t.Fatalf("owned response attempts = %#v, want only account %d", adapter.attempts, credentials[len(credentials)-1].ID) + } +} + +func TestGatewayUnlimitedAttemptsRetainsEgressRetry(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "gateway-unlimited-egress-retry.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: "web-egress-retry", SourceKey: "web-egress-retry", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + const model = "grok-web-egress-retry" + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderWeb, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + adapter := &transientEgressForbiddenAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, unlimitedRoutingAttempts) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-unlimited-egress-retry", ClientKey: clientkey.Key{ID: 1, Name: "web-key"}, PublicModel: model, + Body: []byte(`{"model":"grok-web-egress-retry","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("status = %d", result.StatusCode) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + if calls := adapter.calls.Load(); calls != 2 { + t.Fatalf("calls = %d, want one egress retry", calls) + } +} + +func TestGatewaySSOUnauthorizedMarksInvalidAndSwitchesAccount(t *testing.T) { + for _, providerValue := range []account.Provider{account.ProviderWeb, account.ProviderConsole} { + providerValue := providerValue + t.Run(string(providerValue), func(t *testing.T) { + testGatewaySSOFailureMarksInvalidAndSwitchesAccount(t, providerValue, http.StatusUnauthorized, `{"error":{"code":"unauthorized","message":"credential rejected"}}`) + }) + } +} + +func TestGatewaySSOBlockedForbiddenMarksInvalidAndSwitchesAccount(t *testing.T) { + for _, providerValue := range []account.Provider{account.ProviderWeb, account.ProviderConsole} { + providerValue := providerValue + t.Run(string(providerValue), func(t *testing.T) { + testGatewaySSOFailureMarksInvalidAndSwitchesAccount(t, providerValue, http.StatusForbidden, `{"error":{"code":7,"message":"User is blocked [WKE=unauthorized:blocked-user]"}}`) + }) + } +} + +func testGatewaySSOFailureMarksInvalidAndSwitchesAccount(t *testing.T, providerValue account.Provider, failureStatus int, failureBody string) { + t.Helper() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "sso-failure.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"rejected", "healthy"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: providerValue, AuthType: account.AuthTypeSSO, Name: name, SourceKey: string(providerValue) + "-" + name, + EncryptedAccessToken: "encrypted-" + name, Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 200 - index*100, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + modelName := "grok-sso-401" + if err := modelRepo.UpsertDiscovered(ctx, providerValue, []string{modelName}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{modelName}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "sso-401-key", Prefix: "sso-401", SecretHash: strings.Repeat("e", 64), EncryptedSecret: "encrypted-key", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := &ssoFailureAdapter{ + providerValue: providerValue, rejectedID: credentials[0].ID, + failureStatus: failureStatus, failureBody: failureBody, + } + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-sso-401", ClientKey: key, PublicModel: modelName, + Body: []byte(`{"model":"grok-sso-401","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(result.Body) + if err != nil { + t.Fatal(err) + } + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + if string(body) != "ok" { + t.Fatalf("body = %q", body) + } + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0] != credentials[0].ID || attempts[1] != credentials[1].ID { + t.Fatalf("attempts = %#v", attempts) + } + rejected, err := accountRepo.Get(ctx, credentials[0].ID) + if err != nil { + t.Fatal(err) + } + if rejected.AuthStatus != account.AuthStatusReauthRequired || !rejected.Enabled { + t.Fatalf("rejected account = %#v", rejected) + } + healthy, err := accountRepo.Get(ctx, credentials[1].ID) + if err != nil || healthy.AuthStatus != account.AuthStatusActive { + t.Fatalf("healthy account = %#v, err = %v", healthy, err) + } +} + +func TestGatewayTeamModelRateLimitOnlySkipsMatchingTeam(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "team-model-rate-limit.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credentials := make([]account.Credential, 0, 3) + for index, seed := range []struct { + name string + teamID string + }{{"console-team-a-first", "team-a"}, {"console-team-a-second", "team-a"}, {"console-team-b", "team-b"}} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: seed.name, SourceKey: seed.name, TeamID: seed.teamID, + EncryptedAccessToken: "encrypted-" + seed.name, Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 200 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + models := []string{"grok-console-team-rate-limit", "grok-console-team-rate-limit-other"} + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderConsole, models); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, models, now); err != nil { + t.Fatal(err) + } + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "team-model-key", Prefix: "team-model", SecretHash: strings.Repeat("d", 64), EncryptedSecret: "encrypted-key", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + + adapter := &teamModelRateLimitConsoleAdapter{rateLimitedTeam: "team-a"} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + + assertSuccess := func(requestID, publicModel string) { + t.Helper() + result, err := service.CreateResponse(ctx, Input{ + RequestID: requestID, ClientKey: key, PublicModel: publicModel, + Body: []byte(`{"model":"` + publicModel + `","input":"hello"}`), + }) + if err != nil || result == nil || result.StatusCode != http.StatusOK { + t.Fatalf("result = %#v, err = %v", result, err) + } + _ = result.Body.Close() + result.Finalize(Usage{}, "", "") + } + + assertSuccess("req-team-model-first", models[0]) + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0].AccountID != credentials[0].ID || attempts[1].AccountID != credentials[2].ID { + t.Fatalf("first attempts = %#v, want first Team A account then Team B account", attempts) + } + assertSuccess("req-team-model-cached", models[0]) + if attempts := adapter.Attempts(); len(attempts) != 3 || attempts[2].AccountID != credentials[2].ID { + t.Fatalf("cached Team A accounts should be skipped in favor of Team B, attempts = %#v", attempts) + } + assertSuccess("req-team-model-other", models[1]) + if attempts := adapter.Attempts(); len(attempts) != 5 || attempts[3].AccountID != credentials[0].ID || attempts[3].Model != models[1] || attempts[4].AccountID != credentials[2].ID { + t.Fatalf("different model should have an independent Team limit, attempts = %#v", attempts) + } +} + +func TestSelectConversationRouteRespectsClientKeyAcrossSharedPublicModel(t *testing.T) { + registry := provider.NewRegistry(&failoverAdapter{}, webStoredResponseAdapter{}, statelessConsoleAdapter{}) + service := &Service{ + clientKeys: clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), + providers: registry, + } + routes := []modeldomain.Route{ + {ID: 10, PublicID: "Build/grok-shared", Provider: account.ProviderBuild, UpstreamModel: "grok-shared"}, + {ID: 15, PublicID: "Web/grok-shared", Provider: account.ProviderWeb, UpstreamModel: "grok-shared"}, + {ID: 20, PublicID: "Console/grok-shared", Provider: account.ProviderConsole, UpstreamModel: "grok-shared"}, + } + selected, err := service.selectConversationRoute(routes, clientkey.Key{ProviderScope: clientkey.ProviderScopeWeb | clientkey.ProviderScopeConsole}, audit.OperationResponses, "/responses", false, nil) + if err != nil || selected.ID != 15 { + t.Fatalf("provider-scoped route = %#v, err = %v", selected, err) + } + selected, err = service.selectConversationRoute(routes, clientkey.Key{ProviderScope: clientkey.ProviderScopeConsole, AllowedModels: []uint64{20}}, audit.OperationResponses, "/responses", false, nil) + if err != nil || selected.ID != 20 { + t.Fatalf("selected route = %#v, err = %v", selected, err) + } + _, err = service.selectConversationRoute(routes, clientkey.Key{ProviderScope: clientkey.ProviderScopeBuild, AllowedModels: []uint64{20}}, audit.OperationResponses, "/responses", false, nil) + if !errors.Is(err, clientkeyapp.ErrModelNotAllowed) { + t.Fatalf("scope and model intersection should reject the request: %v", err) + } + _, err = service.selectConversationRoute(routes[1:], clientkey.Key{ProviderScope: clientkey.ProviderScopeBuild}, audit.OperationResponses, "/responses", false, nil) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" { + t.Fatalf("provider scope must not fall back: %#v, err = %v", unavailable, err) + } + ownership := &inferencedomain.ResponseOwnership{Provider: account.ProviderWeb} + _, err = service.selectConversationRoute(routes, clientkey.Key{ProviderScope: clientkey.ProviderScopeBuild}, audit.OperationResponses, "/responses", true, ownership) + if !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" { + t.Fatalf("owned response must remain inside the updated provider scope: %#v, err = %v", unavailable, err) + } +} + +func TestSelectMediaRouteSkipsSameNamedConversationRoute(t *testing.T) { + registry := provider.NewRegistry(&failoverAdapter{}, &webImageStreamAdapter{}) + service := &Service{ + clientKeys: clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), + providers: registry, + } + routes := []modeldomain.Route{ + {ID: 10, PublicID: "Build/grok-shared", Provider: account.ProviderBuild, UpstreamModel: "grok-shared", Capability: modeldomain.CapabilityResponses}, + {ID: 20, PublicID: "Web/grok-shared", Provider: account.ProviderWeb, UpstreamModel: "grok-shared", Capability: modeldomain.CapabilityImage}, + } + selected, err := service.selectMediaRoute(routes, clientkey.Key{}, modeldomain.CapabilityImage, func(providerValue account.Provider) bool { + _, ok := registry.ImageGeneration(providerValue) + return ok + }) + if err != nil || selected.ID != 20 { + t.Fatalf("selected route = %#v, err = %v", selected, err) + } + _, err = service.selectMediaRoute(routes, clientkey.Key{ProviderScope: clientkey.ProviderScopeBuild}, modeldomain.CapabilityImage, func(providerValue account.Provider) bool { + _, ok := registry.ImageGeneration(providerValue) + return ok + }) + var unavailable *SelectionUnavailableError + if !errors.As(err, &unavailable) || unavailable.Code() != "client_key_account_scope_unavailable" { + t.Fatalf("media route must not leave provider scope: %#v, err = %v", unavailable, err) + } +} + +func TestGenerateImageReturnsWhenEveryCredentialRefreshFails(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "image-credential-failure.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + now := time.Now().UTC() + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, + Name: "expired-image", SourceKey: "expired-image", EncryptedAccessToken: "expired", EncryptedRefreshToken: "refresh", + ExpiresAt: now.Add(-time.Minute), Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertRoutes(ctx, []modeldomain.Route{{ + PublicID: "image-credential-failure", Provider: account.ProviderBuild, UpstreamModel: "image-credential-failure", + Capability: modeldomain.CapabilityImage, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"image-credential-failure"}, now); err != nil { + t.Fatal(err) + } + adapter := &credentialFailureImageAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + _, err = service.GenerateImage(ctx, ImageGenerationInput{ + RequestID: "req-image-credential-failure", ClientKey: clientkey.Key{ID: 1, Name: "image-key"}, + PublicModel: "image-credential-failure", Prompt: "test", Count: 1, ResponseFormat: "url", + }) + if !errors.Is(err, ErrNoAvailableAccount) { + t.Fatalf("error = %v", err) + } + if adapter.generationCalls.Load() != 0 { + t.Fatalf("generation calls = %d", adapter.generationCalls.Load()) + } +} + +func TestGenerateImageUnlimitedAttemptsRetainsEgressRetry(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "image-unlimited-egress-retry.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + now := time.Now().UTC() + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: "image-egress-retry", SourceKey: "image-egress-retry", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + const model = "grok-image-egress-retry" + if err := modelRepo.UpsertRoutes(ctx, []modeldomain.Route{{ + PublicID: model, Provider: account.ProviderWeb, UpstreamModel: model, + Capability: modeldomain.CapabilityImage, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, now); err != nil { + t.Fatal(err) + } + adapter := &webImageStreamAdapter{forbiddenRemaining: 1} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, unlimitedRoutingAttempts) + + result, err := service.GenerateImage(ctx, ImageGenerationInput{ + RequestID: "req-image-unlimited-egress-retry", ClientKey: clientkey.Key{ID: 1, Name: "image-key"}, + PublicModel: model, Prompt: "test", Count: 1, ResponseFormat: "url", + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0] != credential.ID || attempts[1] != credential.ID { + t.Fatalf("attempts = %#v, want one egress retry on account %d", attempts, credential.ID) + } +} + +func TestGatewayDoesNotPersistStatelessConsoleResponses(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "console-stateless.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", SourceKey: "console", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + const model = "grok-console-stateless" + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderConsole, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "console-key", Prefix: "console", SecretHash: strings.Repeat("c", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := statelessConsoleAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + result, err := service.CreateResponse(ctx, Input{RequestID: "req-console", ClientKey: key, PublicModel: model, Body: []byte(`{"model":"grok-console-stateless","input":"hello"}`)}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "resp-console", "") + _ = result.Body.Close() + if _, err := responseRepo.Get(ctx, "resp-console", key.ID, time.Now().UTC()); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stateless response ownership err = %v", err) + } + continued, err := service.CreateResponse(ctx, Input{RequestID: "req-console-next", ClientKey: key, PublicModel: model, PreviousResponseID: "resp-console", Body: []byte(`{"model":"grok-console-stateless","previous_response_id":"resp-console","input":"hello again"}`)}) + if err != nil { + t.Fatalf("Console stateless previous response fallback = %v", err) + } + _, _ = io.ReadAll(continued.Body) + continued.Finalize(Usage{}, "resp-console-next", "") + _ = continued.Body.Close() + if _, err := service.CompactResponse(ctx, Input{RequestID: "req-console-compact", ClientKey: key, PublicModel: model, Body: []byte(`{"model":"grok-console-stateless","input":"hello"}`)}); !errors.Is(err, ErrConversationUnsupported) { + t.Fatalf("compact response error = %v", err) + } + + now := time.Now().UTC() + if err := responseRepo.Save(ctx, inferencedomain.ResponseOwnership{ + ResponseID: "resp-console-stale", AccountID: credential.ID, ClientKeyID: key.ID, Provider: account.ProviderConsole, + ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + if _, err := service.GetResponse(ctx, ResourceInput{ClientKey: key, ResponseID: "resp-console-stale"}); !errors.Is(err, ErrResponseNotFound) { + t.Fatalf("stale console resource error = %v", err) + } + if _, err := responseRepo.Get(ctx, "resp-console-stale", key.ID, time.Now().UTC()); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stale console ownership was not removed: %v", err) + } +} + +func TestGatewayWebOwnershipDoesNotPersistRawPromptCacheKey(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-ownership.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "web", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + const model = "grok-web-ownership" + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderWeb, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "web-key", Prefix: "web-key", SecretHash: strings.Repeat("d", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := webStoredResponseAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + rawKey := strings.Repeat("raw-session-", 16) + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-web-ownership", ClientKey: key, PublicModel: model, PromptCacheKey: rawKey, + Body: []byte(`{"model":"grok-web-ownership","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "resp-web-ownership", "") + _ = result.Body.Close() + ownership, err := responseRepo.Get(ctx, "resp-web-ownership", key.ID, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if ownership.AccountID != credential.ID || ownership.Provider != account.ProviderWeb || ownership.PromptCacheKey != "" || ownership.ReasoningReplayKey != "" { + t.Fatalf("web ownership = %#v", ownership) + } +} + +func TestFinalizationCommitsOwnershipAndLocalQuotaBeforeSlowAudit(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "finalization-order.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierBasic, + Name: "web", SourceKey: "finalization-order", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accountRepo.SaveQuotaWindows(ctx, credential.ID, account.WebTierBasic, now, []account.QuotaWindow{{ + AccountID: credential.ID, Mode: "fast", Remaining: 5, Total: 10, WindowSeconds: 3600, + Source: account.QuotaSourceUpstream, SyncedAt: &now, + }}); err != nil { + t.Fatal(err) + } + const model = "grok-finalization-order" + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderWeb, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, now); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "key", Prefix: "finalization-order", SecretHash: strings.Repeat("e", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := finalizationOrderAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, nil, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + audits := &blockingFinalizeAudit{started: make(chan struct{}), release: make(chan struct{})} + service := NewService(modelRepo, audits, accountService, clientkeyapp.NewService(keyRepo, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + result, err := service.CreateResponse(ctx, Input{RequestID: "req-finalization-order", ClientKey: key, PublicModel: model, Body: []byte(`{"model":"grok-finalization-order","input":"hello"}`)}) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(result.Body) + done := make(chan struct{}) + go func() { + result.Finalize(Usage{}, "resp-finalization-order", "") + close(done) + }() + select { + case <-audits.started: + case <-time.After(time.Second): + t.Fatal("audit finalization did not start") + } + if _, err := responseRepo.Get(ctx, "resp-finalization-order", key.ID, time.Now().UTC()); err != nil { + t.Fatalf("response ownership was blocked by audit: %v", err) + } + windows, err := accountRepo.GetQuotaWindows(ctx, []uint64{credential.ID}) + if err != nil { + t.Fatal(err) + } + if len(windows[credential.ID]) != 1 || windows[credential.ID][0].Remaining != 4 { + t.Fatalf("local quota was blocked by audit: %#v", windows[credential.ID]) + } + close(audits.release) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("finalization did not finish") + } + _ = result.Body.Close() +} + +type blockingFinalizeAudit struct { + started chan struct{} + release chan struct{} + once sync.Once +} + +func (a *blockingFinalizeAudit) Create(ctx context.Context, _ audit.Record) error { + a.once.Do(func() { close(a.started) }) + select { + case <-a.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +type finalizationOrderAdapter struct{} + +func (finalizationOrderAdapter) Provider() account.Provider { return account.ProviderWeb } +func (finalizationOrderAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderWeb, + Quota: provider.QuotaRemoteWindow, + Conversation: provider.ConversationSurface{Responses: true, StoredResponses: true}, + Inference: provider.InferencePolicy{Usage: provider.UsageEstimated}, + } +} +func (finalizationOrderAdapter) QuotaMode(string) string { return "fast" } +func (finalizationOrderAdapter) TierOrder(string) []account.WebTier { + return []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy} +} +func (finalizationOrderAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"resp-finalization-order"}`)), QuotaUnits: 1}, nil +} + +func TestParseFreeQuotaExhaustion(t *testing.T) { + body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"tokens (actual/limit): 1065387/1000000; Usage resets over a rolling 24-hour window"}}`) + used, limit, exhausted := parseFreeQuotaExhaustion(body) + if !exhausted || used != 1_065_387 || limit != 1_000_000 { + t.Fatalf("parsed = %d/%d, exhausted = %v", used, limit, exhausted) + } + if _, _, exhausted := parseFreeQuotaExhaustion([]byte(`{"error":"rate limited"}`)); exhausted { + t.Fatal("ordinary 429 body must not be treated as Free quota exhaustion") + } +} + +func TestParseFreeQuotaExhaustionCurrentBuildFreeLimit(t *testing.T) { + body := []byte(`{"code":"subscription:free-usage-exhausted","error":"You've used all the included free usage for model grok-4.5-build-free for now. Usage resets over a rolling 24-hour window — tokens (actual/limit): 537365/500000."}`) + used, limit, exhausted := parseFreeQuotaExhaustion(body) + if !exhausted || used != 537_365 || limit != 500_000 { + t.Fatalf("exhausted=%v used=%d limit=%d", exhausted, used, limit) + } +} + +func TestGatewayCoolsFreeBuildAccountsAfterForbidden(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "systemic-forbidden.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credentials := make([]account.Credential, 0, 3) + for index, name := range []string{"first", "second", "third"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: name, + ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 300 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-systemic"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-systemic"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "systemic-key", Prefix: "systemic", SecretHash: strings.Repeat("a", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + + adapter := &systemicForbiddenAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + + _, err = service.CreateResponse(ctx, Input{ + RequestID: "req-systemic-403", ClientKey: clientKey, PublicModel: "grok-systemic", + Body: []byte(`{"model":"grok-systemic","input":"hello"}`), + }) + var upstreamFailure *UpstreamFailure + if !errors.As(err, &upstreamFailure) || errors.Is(err, ErrNoAvailableAccount) { + t.Fatalf("error = %T %v", err, err) + } + if upstreamFailure.HTTPStatus != http.StatusForbidden || upstreamFailure.Code != "upstream_forbidden" || !upstreamFailure.AccountScoped { + t.Fatalf("upstream failure = %#v", upstreamFailure) + } + attempts := adapter.Attempts() + if len(attempts) != 3 || attempts[0] != credentials[0].ID || attempts[1] != credentials[1].ID || attempts[2] != credentials[2].ID { + t.Fatalf("attempts = %#v", attempts) + } + for _, credential := range credentials { + observed, getErr := accountRepo.Get(ctx, credential.ID) + if getErr != nil { + t.Fatal(getErr) + } + if observed.FailureCount != 1 || observed.CooldownUntil == nil || observed.AuthStatus != account.AuthStatusActive { + t.Fatalf("account %d was not cooled after 403: %#v", credential.ID, observed) + } + } + logs, total, err := auditRepo.List(ctx, 0, 10) + if err != nil || total != 1 || logs[0].StatusCode != http.StatusForbidden || logs[0].ErrorCode != "upstream_forbidden" || logs[0].AccountID == nil || *logs[0].AccountID != credentials[2].ID { + t.Fatalf("audit = %#v, total=%d, err=%v", logs, total, err) + } +} + +func TestGatewayRefreshesAndRetriesBuildUnauthorizedOnce(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "auth-rescue.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "rescue", SourceKey: "rescue", + EncryptedAccessToken: "access-old", EncryptedRefreshToken: "refresh-old", ExpiresAt: time.Now().Add(time.Hour), + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-rescue"}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-rescue"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "rescue-key", Prefix: "rescue", SecretHash: strings.Repeat("b", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + adapter := &authRescueAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-rescue", ClientKey: clientKey, PublicModel: "grok-rescue", + Body: []byte(`{"model":"grok-rescue","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(result.Body) + if err != nil { + t.Fatal(err) + } + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + if string(body) != "ok" || adapter.attempts.Load() != 2 || adapter.refreshes.Load() != 1 { + t.Fatalf("body=%q attempts=%d refreshes=%d", body, adapter.attempts.Load(), adapter.refreshes.Load()) + } + updated, err := accountRepo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if updated.EncryptedAccessToken != "access-new" || updated.AuthStatus != account.AuthStatusActive || updated.RefreshFailureCount != 0 { + t.Fatalf("updated credential = %#v", updated) + } + if err := accountRepo.UpdateCredentialRefreshFailure(ctx, credential.ID, repository.CredentialRefreshFailure{Count: 1, RetryAt: updated.ExpiresAt, Status: 400, Code: "invalid_grant", Message: "Refresh token has expired", Permanent: true}); err != nil { + t.Fatal(err) + } + adapter.rejectAll.Store(true) + if _, err := service.CreateResponse(ctx, Input{ + RequestID: "req-rejected", ClientKey: clientKey, PublicModel: "grok-rescue", + Body: []byte(`{"model":"grok-rescue","input":"hello again"}`), + }); err == nil { + t.Fatal("rejected access token unexpectedly succeeded") + } + rejected, err := accountRepo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if rejected.AuthStatus != account.AuthStatusReauthRequired || adapter.refreshes.Load() != 1 { + t.Fatalf("rejected credential = %#v, refreshes = %d", rejected, adapter.refreshes.Load()) + } +} + +func TestBuildChatPermissionDenialDoesNotInvalidateVideoCredential(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-scoped-denial.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "chat-denied-video-valid", SourceKey: "chat-denied-video-valid", + EncryptedAccessToken: "access-old", EncryptedRefreshToken: "refresh-old", ExpiresAt: time.Now().Add(time.Hour), + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := accountRepo.SaveBilling(ctx, account.Billing{AccountID: credential.ID, MonthlyLimit: 140, SyncedAt: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-chat-denied"}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-chat-denied"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "chat-denied-key", Prefix: "chat-denied", SecretHash: strings.Repeat("c", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + adapter := &authRescueAdapter{} + adapter.denyChat.Store(true) + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + + if _, err := service.CreateResponse(ctx, Input{ + RequestID: "req-chat-denied", ClientKey: clientKey, PublicModel: "grok-chat-denied", + Body: []byte(`{"model":"grok-chat-denied","input":"hello"}`), + }); err == nil { + t.Fatal("chat permission denial unexpectedly succeeded") + } + updated, err := accountRepo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if updated.AuthStatus != account.AuthStatusActive || updated.FailureCount != 0 || updated.CooldownUntil != nil { + t.Fatalf("chat denial invalidated the whole credential: %#v", updated) + } + if adapter.refreshes.Load() != 0 || adapter.attempts.Load() != 1 { + t.Fatalf("Build 403 must not refresh OAuth or replay the request: attempts=%d refreshes=%d", adapter.attempts.Load(), adapter.refreshes.Load()) + } + candidates, err := accountRepo.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "grok-chat-denied", "") + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 || candidates[0].ModelQuotaBlock == nil || candidates[0].ModelQuotaBlock.Reason != "model_access_denied" { + t.Fatalf("model-scoped denial was not persisted: %#v", candidates) + } + + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-chat-denied-opt-in"}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-chat-denied", "grok-chat-denied-opt-in"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + service.UpdateBuildForbiddenReauthPolicy(true, []string{"permission-denied"}) + adapter.recoverDenied.Store(true) + refreshesBefore := adapter.refreshes.Load() + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-chat-denied-opt-in", ClientKey: clientKey, PublicModel: "grok-chat-denied-opt-in", + Body: []byte(`{"model":"grok-chat-denied-opt-in","input":"hello"}`), + }) + if err != nil { + t.Fatalf("recovered permission denial should keep the current request successful: %v", err) + } + _ = result.Body.Close() + result.Finalize(Usage{}, "", "") + invalidated, err := accountRepo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if invalidated.AuthStatus != account.AuthStatusReauthRequired { + t.Fatalf("opt-in denial did not invalidate account: %#v", invalidated) + } + if adapter.refreshes.Load() != refreshesBefore { + t.Fatalf("opt-in denial performed a redundant refresh: before=%d after=%d", refreshesBefore, adapter.refreshes.Load()) + } +} + +func TestWebRateLimitExhaustsOnlyRequestedQuotaMode(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-rate-limit.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: "web", SourceKey: "web", EncryptedAccessToken: "encrypted", Enabled: true, + AuthStatus: account.AuthStatusActive, MaxConcurrent: 2, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accountRepo.SaveQuotaWindows(ctx, credential.ID, account.WebTierSuper, now, []account.QuotaWindow{ + {AccountID: credential.ID, Mode: "fast", Remaining: 3, Total: 20, WindowSeconds: 3600, Source: account.QuotaSourceUpstream}, + {AccountID: credential.ID, Mode: "auto", Remaining: 4, Total: 10, WindowSeconds: 3600, Source: account.QuotaSourceUpstream}, + }); err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderWeb, []string{"grok-web-test"}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-web-test"}, now); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{Name: "key", Prefix: "web-key", SecretHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", EncryptedSecret: "encrypted-key", Enabled: true, RPMLimit: 60, MaxConcurrent: 4}) + if err != nil { + t.Fatal(err) + } + adapter := webRateLimitAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + accountService.SetQuotaRecoveryQueue(memory.NewQuotaRecoveryQueue()) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + if _, err := service.CreateResponse(ctx, Input{RequestID: "req-web-429", ClientKey: key, PublicModel: "grok-web-test", Body: []byte(`{"model":"grok-web-test"}`)}); err == nil { + t.Fatal("expected rate-limited request to fail") + } + windows, err := accountRepo.GetQuotaWindows(ctx, []uint64{credential.ID}) + if err != nil { + t.Fatal(err) + } + remaining := map[string]int{} + for _, window := range windows[credential.ID] { + remaining[window.Mode] = window.Remaining + } + if remaining["fast"] != 0 || remaining["auto"] != 4 { + t.Fatalf("quota remaining = %#v", remaining) + } + if _, err := accountRepo.GetQuotaRecovery(ctx, credential.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("Web 429 must not create Build quota recovery state: %v", err) + } +} + +func TestImageStreamPropagatesWithoutTouchingChatQuota(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "image-stream.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: "web-image", SourceKey: "web-image", EncryptedAccessToken: "encrypted", Enabled: true, + AuthStatus: account.AuthStatusActive, MaxConcurrent: 2, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accountRepo.SaveQuotaWindows(ctx, credential.ID, account.WebTierSuper, now, []account.QuotaWindow{{ + AccountID: credential.ID, Mode: "fast", Remaining: 3, Total: 10, + WindowSeconds: 3600, Source: account.QuotaSourceUpstream, SyncedAt: &now, + }}); err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertRoutes(ctx, []modeldomain.Route{ + {PublicID: "grok-imagine-image-quality", Provider: account.ProviderWeb, UpstreamModel: "grok-imagine-image-quality", Capability: modeldomain.CapabilityImage, Enabled: true}, + {PublicID: "grok-imagine-image", Provider: account.ProviderWeb, UpstreamModel: "grok-imagine-image", Capability: modeldomain.CapabilityImage, Enabled: true}, + {PublicID: "grok-imagine-image-edit", Provider: account.ProviderWeb, UpstreamModel: "grok-imagine-image-edit", Capability: modeldomain.CapabilityImageEdit, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-imagine-image-quality", "grok-imagine-image", "grok-imagine-image-edit"}, now); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "image-key", Prefix: "image-key", SecretHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", EncryptedSecret: "encrypted-key", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := &webImageStreamAdapter{synced: make(chan string, 1)} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + runQuotaRefreshWorkers(t, accountService) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(keyRepo, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + result, err := service.GenerateImage(ctx, ImageGenerationInput{ + RequestID: "req-image-stream", ClientKey: key, PublicModel: "grok-imagine-image-quality", + Prompt: "test", Count: 1, Resolution: "1k", ResponseFormat: "url", Streaming: true, PartialImages: 1, + }) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(result.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "event: image_generation.completed\ndata: {}\n\ndata: [DONE]\n\n" { + t.Fatalf("stream body = %q", body) + } + if !adapter.Streaming() || adapter.PartialImages() != 1 { + t.Fatalf("image stream options were not propagated: streaming=%t partial_images=%d", adapter.Streaming(), adapter.PartialImages()) + } + if logs, total, err := auditRepo.List(ctx, 0, 10); err != nil || total != 0 || len(logs) != 0 { + t.Fatalf("audit persisted before finalization: logs=%#v total=%d err=%v", logs, total, err) + } + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + + logs, total, err := auditRepo.List(ctx, 0, 10) + if err != nil || total != 1 || len(logs) != 1 { + t.Fatalf("audit logs=%#v total=%d err=%v", logs, total, err) + } + if !logs[0].Streaming || logs[0].Operation != "image" || logs[0].Provider != string(account.ProviderWeb) || logs[0].ErrorCode != "" || + logs[0].MediaInputImages != 0 || logs[0].MediaOutputImages != 1 || + logs[0].PricingModel != "grok-imagine-image-quality-1k" || logs[0].EstimatedCostInUSDTicks != 500_000_000 { + t.Fatalf("audit = %#v", logs[0]) + } + windows, err := accountRepo.GetQuotaWindows(ctx, []uint64{credential.ID}) + if err != nil { + t.Fatal(err) + } + if len(windows[credential.ID]) != 1 || windows[credential.ID][0].Remaining != 3 { + t.Fatalf("quota windows = %#v", windows[credential.ID]) + } + + liteResult, err := service.GenerateImage(ctx, ImageGenerationInput{ + RequestID: "req-image-lite", ClientKey: key, PublicModel: "grok-imagine-image", + Prompt: "test", Count: 1, ResponseFormat: "url", + }) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(liteResult.Body); err != nil { + t.Fatal(err) + } + liteResult.Finalize(Usage{}, "", "") + _ = liteResult.Body.Close() + logs, total, err = auditRepo.List(ctx, 0, 10) + if err != nil || total != 2 || logs[0].RequestID != "req-image-lite" || logs[0].PricingModel != "grok-imagine-image" || logs[0].EstimatedCostInUSDTicks != 200_000_000 { + t.Fatalf("Lite image pricing audit = %#v, total=%d, err=%v", logs, total, err) + } + select { + case mode := <-adapter.synced: + if mode != "fast" { + t.Fatalf("Lite image synced mode = %q", mode) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Lite image quota refresh") + } + deadline := time.Now().Add(2 * time.Second) + for { + windows, err = accountRepo.GetQuotaWindows(ctx, []uint64{credential.ID}) + if err != nil { + t.Fatal(err) + } + if len(windows[credential.ID]) == 1 && windows[credential.ID][0].Remaining == 8 { + break + } + if time.Now().After(deadline) { + t.Fatalf("Lite image quota was not refreshed: %#v", windows[credential.ID]) + } + time.Sleep(10 * time.Millisecond) + } + + chatResult, err := service.CreateChatCompletion(ctx, Input{ + RequestID: "req-image-lite-chat", ClientKey: key, PublicModel: "grok-imagine-image", + Body: []byte(`{"model":"grok-imagine-image","messages":[{"role":"user","content":"draw"}],"image_config":{"n":3}}`), + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(chatResult.Body) + chatResult.Finalize(Usage{}, "resp-image-lite", "") + _ = chatResult.Body.Close() + logs, total, err = auditRepo.List(ctx, 0, 10) + if err != nil || total != 3 || logs[0].RequestID != "req-image-lite-chat" || logs[0].MediaOutputImages != 3 || logs[0].PricingModel != "grok-imagine-image" || logs[0].EstimatedCostInUSDTicks != 600_000_000 { + t.Fatalf("Lite Chat pricing audit = %#v, total=%d, err=%v", logs, total, err) + } + + editResult, err := service.EditImage(ctx, ImageEditInput{ + RequestID: "req-image-edit", ClientKey: key, PublicModel: "grok-imagine-image-edit", + Prompt: "edit", ImageURLs: []string{"data:image/png;base64,a", "data:image/png;base64,b"}, + Count: 3, Size: "1024x1024", AspectRatio: "1:1", Resolution: "2k", ResponseFormat: "url", + Streaming: true, PartialImages: 2, + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(editResult.Body) + editResult.Finalize(Usage{}, "", "") + _ = editResult.Body.Close() + logs, total, err = auditRepo.List(ctx, 0, 10) + if err != nil || total != 4 || logs[0].RequestID != "req-image-edit" || logs[0].MediaInputImages != 2 || logs[0].MediaOutputImages != 3 || logs[0].PricingModel != "grok-imagine-image-edit-2k" || logs[0].EstimatedCostInUSDTicks != 2_300_000_000 { + t.Fatalf("image edit pricing audit = %#v, total=%d, err=%v", logs, total, err) + } + editRequest := adapter.EditRequest() + if editRequest.Resolution != "2k" || editRequest.Size != "1024x1024" || editRequest.AspectRatio != "1:1" || !editRequest.Streaming || editRequest.PartialImages != 2 { + t.Fatalf("image edit request = %#v", editRequest) + } + + billingBeforeFailure, err := keyRepo.Get(ctx, key.ID) + if err != nil { + t.Fatal(err) + } + backupCredential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: "web-image-backup", SourceKey: "web-image-backup", EncryptedAccessToken: "encrypted-backup", Enabled: true, + AuthStatus: account.AuthStatusActive, MaxConcurrent: 2, + }) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, backupCredential.ID, []string{"grok-imagine-image-quality"}, now); err != nil { + t.Fatal(err) + } + selector.MarkQuotaStateChanged(account.ProviderWeb) + service.UpdateMaxAttempts(3) + attemptsBeforeFailure := len(adapter.Attempts()) + adapter.FailWithEgress(infraegress.NewManager(relational.NewEgressRepository(database), testCipher(t))) + if _, err := service.GenerateImage(ctx, ImageGenerationInput{ + RequestID: "req-image-failed", ClientKey: key, PublicModel: "grok-imagine-image-quality", + Prompt: "test", Count: 1, Resolution: "1k", ResponseFormat: "url", + }); err == nil { + t.Fatal("expected image transport failure") + } + if attempts := adapter.Attempts(); len(attempts) != attemptsBeforeFailure+1 { + t.Fatalf("image failure switched accounts after generation started: %#v", attempts) + } + logs, total, err = auditRepo.List(ctx, 0, 10) + if err != nil || total != 5 || len(logs) != 5 { + t.Fatalf("failure audit logs=%#v total=%d err=%v", logs, total, err) + } + failureAudit := logs[0] + if failureAudit.RequestID != "req-image-failed" || failureAudit.StatusCode != http.StatusBadGateway || failureAudit.ErrorCode != "upstream_unavailable" || failureAudit.MediaOutputImages != 0 || failureAudit.EstimatedCostInUSDTicks != 0 || failureAudit.EgressMode != audit.EgressModeDirect || failureAudit.EgressScope != string(egressdomain.ScopeWeb) || failureAudit.EgressNodeName != "direct" { + t.Fatalf("failure audit = %#v", failureAudit) + } + updatedKey, err := keyRepo.Get(ctx, key.ID) + if err != nil || updatedKey.ReservedUsageUSDTicks != 0 || updatedKey.BilledUsageUSDTicks != billingBeforeFailure.BilledUsageUSDTicks { + t.Fatalf("failed image billing key = %#v, err = %v", updatedKey, err) + } +} + +func TestWebImageUnauthorizedMarksInvalidAndSwitchesAccount(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-image-401.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"rejected-image", "healthy-image"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, + Name: name, SourceKey: name, EncryptedAccessToken: "encrypted-" + name, + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 200 - index*100, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + now := time.Now().UTC() + if err := modelRepo.UpsertRoutes(ctx, []modeldomain.Route{{ + PublicID: "grok-image-401", Provider: account.ProviderWeb, UpstreamModel: "grok-image-401", + Capability: modeldomain.CapabilityImage, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-image-401"}, now); err != nil { + t.Fatal(err) + } + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "image-401-key", Prefix: "image-401", SecretHash: strings.Repeat("f", 64), EncryptedSecret: "encrypted-key", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := &webImageStreamAdapter{synced: make(chan string, 1), unauthorizedID: credentials[0].ID} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(keyRepo, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + + result, err := service.GenerateImage(ctx, ImageGenerationInput{ + RequestID: "req-image-401", ClientKey: key, PublicModel: "grok-image-401", Prompt: "test", Count: 1, + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0] != credentials[0].ID || attempts[1] != credentials[1].ID { + t.Fatalf("attempts = %#v", attempts) + } + rejected, err := accountRepo.Get(ctx, credentials[0].ID) + if err != nil || rejected.AuthStatus != account.AuthStatusReauthRequired || !rejected.Enabled { + t.Fatalf("rejected account = %#v, err = %v", rejected, err) + } +} + +func TestSuccessfulWebChatRefreshesCurrentModeQuota(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "chat-quota-refresh.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierBasic, + Name: "web-chat", SourceKey: "web-chat", EncryptedAccessToken: "encrypted", Enabled: true, + AuthStatus: account.AuthStatusActive, MaxConcurrent: 2, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accountRepo.SaveQuotaWindows(ctx, credential.ID, account.WebTierBasic, now, []account.QuotaWindow{{ + AccountID: credential.ID, Mode: "fast", Remaining: 3, Total: 20, + WindowSeconds: 3600, Source: account.QuotaSourceUpstream, SyncedAt: &now, + }}); err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertRoutes(ctx, []modeldomain.Route{{ + PublicID: "grok-chat-fast", Provider: account.ProviderWeb, UpstreamModel: "grok-chat-fast", + Capability: modeldomain.CapabilityChat, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-chat-fast"}, now); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "chat-key", Prefix: "chat-key", SecretHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", EncryptedSecret: "encrypted-key", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := &webChatQuotaAdapter{synced: make(chan string, 1)} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + runQuotaRefreshWorkers(t, accountService) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(keyRepo, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + result, err := service.CreateChatCompletion(ctx, Input{ + RequestID: "req-chat-quota", ClientKey: key, PublicModel: "grok-chat-fast", + Body: []byte(`{"model":"grok-chat-fast","messages":[{"role":"user","content":"hi"}]}`), + }) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(result.Body); err != nil { + t.Fatal(err) + } + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + + select { + case mode := <-adapter.synced: + if mode != "fast" { + t.Fatalf("synced mode = %q", mode) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for post-success quota refresh") + } + deadline := time.Now().Add(2 * time.Second) + for { + windows, err := accountRepo.GetQuotaWindows(ctx, []uint64{credential.ID}) + if err != nil { + t.Fatal(err) + } + if len(windows[credential.ID]) == 1 && windows[credential.ID][0].Remaining == 17 { + break + } + if time.Now().After(deadline) { + t.Fatalf("quota windows were not refreshed: %#v", windows[credential.ID]) + } + time.Sleep(10 * time.Millisecond) + } +} + +func runQuotaRefreshWorkers(t *testing.T, service *accountapp.Service) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + service.RunWebQuotaRefresh(ctx) + close(done) + }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("quota refresh workers did not stop") + } + }) +} + +type failoverAdapter struct { + mu sync.Mutex + firstID uint64 + failureIDs map[uint64]bool + failureStatus int + failureBody string + failureHeader http.Header + attempts []uint64 + lastMethod string + lastPath string + lastPromptCacheKey string + lastReasoningReplayKey string + lastGrokTurnIndex string + resourceStatus int +} + +type ssoFailureAdapter struct { + mu sync.Mutex + providerValue account.Provider + rejectedID uint64 + failureStatus int + failureBody string + attempts []uint64 +} + +func (a *ssoFailureAdapter) Provider() account.Provider { return a.providerValue } +func (a *ssoFailureAdapter) Definition() provider.Definition { + return testConversationDefinition(a.providerValue) +} +func (a *ssoFailureAdapter) ForwardResponse(_ context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + a.mu.Lock() + a.attempts = append(a.attempts, request.Credential.ID) + a.mu.Unlock() + if request.Credential.ID == a.rejectedID { + status := a.failureStatus + if status == 0 { + status = http.StatusUnauthorized + } + body := a.failureBody + if body == "" { + body = `{"error":{"code":"unauthorized","message":"credential rejected"}}` + } + return &provider.Response{ + StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok"))}, nil +} +func (a *ssoFailureAdapter) Attempts() []uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return append([]uint64(nil), a.attempts...) +} + +type statelessConsoleAdapter struct{} + +type transientEgressForbiddenAdapter struct { + calls atomic.Int64 +} + +func (a *transientEgressForbiddenAdapter) Provider() account.Provider { return account.ProviderWeb } +func (a *transientEgressForbiddenAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderWeb) +} +func (a *transientEgressForbiddenAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + if a.calls.Add(1) == 1 { + return &provider.Response{ + StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"egress session rejected"}`)), + }, nil + } + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"resp-egress-retry","status":"completed"}`)), + }, nil +} + +type teamModelRateLimitConsoleAttempt struct { + AccountID uint64 + Model string +} + +type teamModelRateLimitConsoleAdapter struct { + mu sync.Mutex + attempts []teamModelRateLimitConsoleAttempt + rateLimitedTeam string +} + +func (statelessConsoleAdapter) Provider() account.Provider { return account.ProviderConsole } +func (statelessConsoleAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderConsole, + Conversation: provider.ConversationSurface{ + Responses: true, + }, + } +} +func (statelessConsoleAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp-console","object":"response","status":"completed"}`)), + }, nil +} + +func (a *teamModelRateLimitConsoleAdapter) Provider() account.Provider { + return account.ProviderConsole +} +func (a *teamModelRateLimitConsoleAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderConsole) +} +func (a *teamModelRateLimitConsoleAdapter) ForwardResponse(_ context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + a.mu.Lock() + a.attempts = append(a.attempts, teamModelRateLimitConsoleAttempt{AccountID: request.Credential.ID, Model: request.Model}) + a.mu.Unlock() + if request.Credential.TeamID != a.rateLimitedTeam { + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp-team-success","object":"response","status":"completed"}`)), + }, nil + } + return &provider.Response{ + StatusCode: http.StatusTooManyRequests, Status: "429 Too Many Requests", Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"team model rate limited"}`)), + RateLimit: &provider.RateLimitMetadata{ + Scope: provider.RateLimitScopeRPM, TeamID: request.Credential.TeamID, Model: request.Model, + Actual: 61, Limit: 60, RetryAfter: time.Hour, + }, + }, nil +} +func (a *teamModelRateLimitConsoleAdapter) Attempts() []teamModelRateLimitConsoleAttempt { + a.mu.Lock() + defer a.mu.Unlock() + return append([]teamModelRateLimitConsoleAttempt(nil), a.attempts...) +} + +func TestGatewaySafetyRejectionDoesNotTouchAccountState(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "safety-rejection.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"safety-a", "safety-b"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: name, + EncryptedRefreshToken: "refresh-" + name, ExpiresAt: time.Now().Add(time.Hour), + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 200 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-safety"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-safety"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "safety-key", Prefix: "safety", SecretHash: strings.Repeat("e", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + + body := `{"code":"permission-denied","error":"Content violates usage guidelines. SAFETY_CHECK_TYPE_VIOLENCE"}` + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credentials[0].ID: {{status: http.StatusForbidden, body: body}}, + credentials[1].ID: {{status: http.StatusOK, body: `{"id":"resp-should-not-run"}`}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + service.UpdateBuildForbiddenReauthPolicy(true, []string{"permission-denied"}) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-safety", ClientKey: clientKey, PublicModel: "grok-safety", + Body: []byte(`{"model":"grok-safety","input":"hello"}`), + }) + if err != nil { + t.Fatalf("safety rejection should return the upstream 403 response, err=%v", err) + } + if result.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d", result.StatusCode) + } + responseBody, _ := io.ReadAll(result.Body) + result.Finalize(Usage{}, "", "upstream_forbidden") + _ = result.Body.Close() + if !strings.Contains(string(responseBody), "Content violates usage guidelines") { + t.Fatalf("body = %s", responseBody) + } + if attempts := adapter.Attempts(); len(attempts) != 1 || attempts[0] != credentials[0].ID { + t.Fatalf("safety rejection must use exactly one physical request, attempts=%#v", attempts) + } + if adapter.refreshes.Load() != 0 { + t.Fatalf("safety rejection refreshed OAuth: %d", adapter.refreshes.Load()) + } + for _, credential := range credentials { + observed, getErr := accountRepo.Get(ctx, credential.ID) + if getErr != nil { + t.Fatal(getErr) + } + if observed.AuthStatus != account.AuthStatusActive || observed.FailureCount != 0 || observed.CooldownUntil != nil { + t.Fatalf("account %d changed after safety rejection: %#v", credential.ID, observed) + } + candidates, listErr := accountRepo.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "grok-safety", "") + if listErr != nil { + t.Fatal(listErr) + } + for _, candidate := range candidates { + if candidate.Credential.ID == credential.ID && candidate.ModelQuotaBlock != nil { + t.Fatalf("safety rejection must not mark model access denied: %#v", candidate.ModelQuotaBlock) + } + } + } +} + +func TestGatewayFreeUsageExhaustionFailsOverToAnotherAccount(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "free-usage-failover.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"free-a", "free-b"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: name, + ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 200 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-free-usage"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-free-usage"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "free-key", Prefix: "free", SecretHash: strings.Repeat("f", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + periodEnd := time.Now().UTC().Add(7 * 24 * time.Hour).Truncate(time.Second) + if err := accountRepo.SaveBilling(ctx, account.Billing{ + AccountID: credentials[0].ID, PlanName: "free", BillingPeriodEnd: periodEnd.Format(time.RFC3339), SyncedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + exhausted := `{"code":"subscription:free-usage-exhausted","error":"tokens (actual/limit): 10/10; Usage resets over a rolling 24-hour window"}` + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credentials[0].ID: {{status: http.StatusTooManyRequests, body: exhausted, header: http.Header{"X-Should-Retry": {"false"}}}}, + credentials[1].ID: {{status: http.StatusOK, body: `{"id":"resp-free-b"}`}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + + exhaustedAt := time.Now().UTC() + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-free-usage", ClientKey: clientKey, PublicModel: "grok-free-usage", + Body: []byte(`{"model":"grok-free-usage","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("status = %d", result.StatusCode) + } + result.Finalize(Usage{}, "resp-free-b", "") + _ = result.Body.Close() + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0] != credentials[0].ID || attempts[1] != credentials[1].ID { + t.Fatalf("free-usage must fail over A->B, attempts=%#v", attempts) + } + candidates, err := accountRepo.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "grok-free-usage", "") + if err != nil { + t.Fatal(err) + } + found := false + for _, candidate := range candidates { + if candidate.Credential.ID == credentials[0].ID { + found = true + if candidate.ModelQuotaBlock != nil { + t.Fatalf("subscription quota must not create a model block: %#v", candidate.ModelQuotaBlock) + } + if candidate.QuotaRecovery == nil || candidate.QuotaRecovery.Kind != account.QuotaRecoveryKindFree || candidate.QuotaRecovery.Status != account.QuotaRecoveryStatusExhausted || candidate.QuotaRecovery.ConfirmedUsed != 10 || candidate.QuotaRecovery.ConfirmedLimit != 10 { + t.Fatalf("account A quota recovery = %#v", candidate.QuotaRecovery) + } + assertRecoveryDelay(t, *candidate.QuotaRecovery, exhaustedAt, defaultFreeQuotaRecoveryPause) + } + } + if !found { + t.Fatal("account A missing from candidates snapshot") + } +} + +func TestGatewayBuildTeamRPSRateLimitSwitchesTeam(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "build-team-rps.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + teamX := "00000000-0000-0000-0000-0000000000a1" + teamY := "00000000-0000-0000-0000-0000000000b2" + staleTeam := "00000000-0000-0000-0000-0000000000c3" + credentials := make([]account.Credential, 0, 3) + // A has stale credential metadata but reports Team X in the 429 body. B also + // uses Team X and C uses Team Y. Both A and B must be skipped after the limit. + for index, seed := range []struct { + name string + teamID string + }{{"build-team-x-a", staleTeam}, {"build-team-x-b", teamX}, {"build-team-y-c", teamY}} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: seed.name, SourceKey: seed.name, TeamID: seed.teamID, + EncryptedAccessToken: "token-" + seed.name, ExpiresAt: time.Now().Add(time.Hour), + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 300 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + model := "grok-build-team-rps" + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{model}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "team-rps-key", Prefix: "teamrps", SecretHash: strings.Repeat("1", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + // Body TeamID is the source of truth for the rate-limit key (even if credential metadata drifts). + limited := `{"code":"resource-exhausted","error":"Too many requests for team ` + teamX + ` and model ` + model + `. Requests per Second (actual/limit): 2/2."}` + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credentials[0].ID: {{status: http.StatusTooManyRequests, body: limited}}, + credentials[1].ID: {{status: http.StatusOK, body: `{"id":"resp-should-skip-same-team"}`}}, + credentials[2].ID: {{status: http.StatusOK, body: `{"id":"resp-team-y"}`}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-team-rps", ClientKey: clientKey, PublicModel: model, + Body: []byte(`{"model":"` + model + `","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("status = %d", result.StatusCode) + } + result.Finalize(Usage{}, "resp-team-y", "") + _ = result.Body.Close() + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0] != credentials[0].ID || attempts[1] != credentials[2].ID { + t.Fatalf("team RPS must skip same-team B and choose different-team C, attempts=%#v want A then C", attempts) + } + if adapter.lastRateLimit == nil || adapter.lastRateLimit.TeamID != teamX || adapter.lastRateLimit.Model != model || adapter.lastRateLimit.Scope != provider.RateLimitScopeRPS || adapter.lastRateLimit.Actual != 2 || adapter.lastRateLimit.Limit != 2 { + t.Fatalf("rate limit metadata = %#v", adapter.lastRateLimit) + } + // Second request must keep skipping Team X (A and B) via cached Team+Model limit. + before := len(adapter.Attempts()) + result, err = service.CreateResponse(ctx, Input{ + RequestID: "req-team-rps-cached", ClientKey: clientKey, PublicModel: model, + Body: []byte(`{"model":"` + model + `","input":"again"}`), + }) + if err != nil { + t.Fatal(err) + } + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + attempts := adapter.Attempts() + if len(attempts) != before+1 || attempts[len(attempts)-1] != credentials[2].ID { + t.Fatalf("cached Team X limit should go straight to Team Y, attempts=%#v", attempts) + } +} + +func TestActiveTeamModelRateLimitFallsBackToCurrentCredentialTeam(t *testing.T) { + now := time.Now().UTC() + const model = "grok-team-fallback" + const observedTeam = "00000000-0000-0000-0000-0000000000e5" + const currentTeam = "00000000-0000-0000-0000-0000000000f6" + credential := account.Credential{ID: 42, Provider: account.ProviderBuild, TeamID: currentTeam} + currentFingerprint := rateLimitTeamFingerprint(currentTeam) + service := &Service{ + rateLimits: map[string]teamModelRateLimit{ + teamModelRateLimitKey(account.ProviderBuild, currentFingerprint, model): { + TeamFingerprint: shortTeamFingerprint(currentFingerprint), Until: now.Add(time.Minute), + }, + }, + rateLimitTeams: map[uint64]teamRateLimitObservation{ + credential.ID: {Fingerprint: rateLimitTeamFingerprint(observedTeam), ExpiresAt: now.Add(time.Minute)}, + }, + } + service.rateLimitActive.Store(true) + + limited, ok := service.activeTeamModelRateLimit(credential, model, now) + if !ok || limited.TeamFingerprint != shortTeamFingerprint(currentFingerprint) { + t.Fatalf("limit = %#v, ok=%v", limited, ok) + } +} + +func TestActiveTeamModelRateLimitDropsExpiredObservedTeam(t *testing.T) { + now := time.Now().UTC() + const model = "grok-team-observation-expiry" + const observedTeam = "00000000-0000-0000-0000-0000000000A1" + const currentTeam = "00000000-0000-0000-0000-0000000000b2" + credential := account.Credential{ID: 43, Provider: account.ProviderBuild, TeamID: currentTeam} + currentFingerprint := rateLimitTeamFingerprint(currentTeam) + service := &Service{ + rateLimits: map[string]teamModelRateLimit{ + teamModelRateLimitKey(account.ProviderBuild, rateLimitTeamFingerprint(observedTeam), model): { + TeamFingerprint: shortTeamFingerprint(rateLimitTeamFingerprint(observedTeam)), Until: now.Add(time.Minute), + }, + teamModelRateLimitKey(account.ProviderBuild, currentFingerprint, model): { + TeamFingerprint: shortTeamFingerprint(currentFingerprint), Until: now.Add(time.Minute), + }, + }, + rateLimitTeams: map[uint64]teamRateLimitObservation{ + credential.ID: {Fingerprint: rateLimitTeamFingerprint(observedTeam), ExpiresAt: now.Add(-time.Second)}, + }, + } + service.rateLimitActive.Store(true) + + limited, ok := service.activeTeamModelRateLimit(credential, model, now) + if !ok || limited.TeamFingerprint != shortTeamFingerprint(currentFingerprint) { + t.Fatalf("limit = %#v, ok=%v", limited, ok) + } + if _, exists := service.rateLimitTeams[credential.ID]; exists { + t.Fatal("expired observed Team mapping was retained") + } +} + +func TestActiveTeamModelRateLimitPrunesExpiredUnrelatedLimit(t *testing.T) { + now := time.Now().UTC() + service := &Service{ + rateLimits: map[string]teamModelRateLimit{ + teamModelRateLimitKey(account.ProviderBuild, rateLimitTeamFingerprint("00000000-0000-0000-0000-0000000000a1"), "old-model"): { + Until: now.Add(-time.Second), + }, + }, + rateLimitTeams: map[uint64]teamRateLimitObservation{ + 99: {Fingerprint: rateLimitTeamFingerprint("00000000-0000-0000-0000-0000000000a1"), ExpiresAt: now.Add(-time.Second)}, + }, + } + service.rateLimitActive.Store(true) + service.rateLimitNextExpiry.Store(now.Add(-time.Second).UnixNano()) + + credential := account.Credential{ID: 100, Provider: account.ProviderBuild, TeamID: "00000000-0000-0000-0000-0000000000b2"} + if limited, ok := service.activeTeamModelRateLimit(credential, "new-model", now); ok { + t.Fatalf("expired unrelated limit remained active: %#v", limited) + } + if service.rateLimitActive.Load() || service.rateLimitNextExpiry.Load() != 0 || len(service.rateLimits) != 0 || len(service.rateLimitTeams) != 0 { + t.Fatalf("expired state was not fully pruned: active=%v next=%d limits=%d teams=%d", service.rateLimitActive.Load(), service.rateLimitNextExpiry.Load(), len(service.rateLimits), len(service.rateLimitTeams)) + } +} + +func TestGatewayGeneric429CoolsAccountAndRotates(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "generic-429.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"fast-a", "fast-b"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: name, + ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 200 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-fast"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-fast"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "fast-key", Prefix: "fast", SecretHash: strings.Repeat("2", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credentials[0].ID: {{status: http.StatusTooManyRequests, body: `{"error":"You are sending requests too quickly. Please try again later."}`}}, + credentials[1].ID: {{status: http.StatusOK, body: `{"id":"resp-fast-b"}`}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-generic-429", ClientKey: clientKey, PublicModel: "grok-fast", + Body: []byte(`{"model":"grok-fast","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + result.Finalize(Usage{}, "resp-fast-b", "") + _ = result.Body.Close() + if attempts := adapter.Attempts(); len(attempts) != 2 || attempts[0] != credentials[0].ID || attempts[1] != credentials[1].ID { + t.Fatalf("generic 429 must rotate, attempts=%#v", attempts) + } + cooled, err := accountRepo.Get(ctx, credentials[0].ID) + if err != nil { + t.Fatal(err) + } + if cooled.AuthStatus != account.AuthStatusActive || cooled.FailureCount != 1 || cooled.CooldownUntil == nil { + t.Fatalf("generic 429 must briefly cool account A without permanent invalidation: %#v", cooled) + } +} + +func TestGatewayExhausted429PreservesLastBodyInFailure(t *testing.T) { + // When all attempts fail, CreateResponse returns UpstreamFailure (sanitized). + // captureResponse must reattach the diagnostic body so subsequent classification + // and attempt diagnostics still see the complete last JSON payload. + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "exhausted-429-body.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"body-a", "body-b"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: name, + ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 200 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-body"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-body"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "body-key", Prefix: "body", SecretHash: strings.Repeat("4", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + firstBody := `{"code":"resource-exhausted","error":"first too quickly"}` + secondBody := `{"code":"resource-exhausted","error":"second too quickly complete json"}` + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credentials[0].ID: {{status: http.StatusTooManyRequests, body: firstBody}}, + credentials[1].ID: {{status: http.StatusTooManyRequests, body: secondBody}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + // Capture attempts via a wrapping audit recorder that keeps the in-memory Attempts slice. + audits := &attemptCapturingAudit{inner: auditRepo} + service := NewService(modelRepo, audits, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + + _, err = service.CreateResponse(ctx, Input{ + RequestID: "req-body-429", ClientKey: clientKey, PublicModel: "grok-body", + Body: []byte(`{"model":"grok-body","input":"hello"}`), + }) + var upstreamFailure *UpstreamFailure + if !errors.As(err, &upstreamFailure) { + t.Fatalf("err = %T %v", err, err) + } + if upstreamFailure.HTTPStatus != http.StatusTooManyRequests { + t.Fatalf("status = %d", upstreamFailure.HTTPStatus) + } + if len(audits.last.Attempts) < 2 { + t.Fatalf("attempts = %#v", audits.last.Attempts) + } + last := audits.last.Attempts[len(audits.last.Attempts)-1] + if !strings.Contains(string(last.ResponseBody), "second too quickly complete json") { + t.Fatalf("last attempt body not preserved: %q", last.ResponseBody) + } + if attempts := adapter.Attempts(); len(attempts) != 2 { + t.Fatalf("attempts = %#v", attempts) + } +} + +type attemptCapturingAudit struct { + inner auditRecorder + last audit.Record +} + +func (a *attemptCapturingAudit) Create(ctx context.Context, value audit.Record) error { + a.last = value + if a.inner != nil { + return a.inner.Create(ctx, value) + } + return nil +} + +func TestGatewayBarePermissionDeniedDoesNotPenalizeAccount(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "bare-permission.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "bare", SourceKey: "bare", EncryptedAccessToken: "access", + EncryptedRefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour), + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-bare"}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-bare"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "bare-key", Prefix: "bare", SecretHash: strings.Repeat("5", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + body := `{"code":"permission-denied","error":"request rejected by policy"}` + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credential.ID: {{status: http.StatusForbidden, body: body}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + service.UpdateBuildForbiddenReauthPolicy(true, []string{"permission-denied"}) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-bare", ClientKey: clientKey, PublicModel: "grok-bare", + Body: []byte(`{"model":"grok-bare","input":"hello"}`), + }) + if err != nil { + t.Fatalf("bare permission-denied must return the original response: %v", err) + } + if result.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d", result.StatusCode) + } + responseBody, _ := io.ReadAll(result.Body) + result.Finalize(Usage{}, "", "upstream_forbidden") + _ = result.Body.Close() + if !strings.Contains(string(responseBody), "request rejected by policy") { + t.Fatalf("body = %s", responseBody) + } + if attempts := adapter.Attempts(); len(attempts) != 1 { + t.Fatalf("attempts = %#v, want one terminal request", attempts) + } + observed, getErr := accountRepo.Get(ctx, credential.ID) + if getErr != nil { + t.Fatal(getErr) + } + if observed.AuthStatus != account.AuthStatusActive || observed.FailureCount != 0 || observed.CooldownUntil != nil { + t.Fatalf("bare permission-denied penalized account: %#v", observed) + } + if adapter.refreshes.Load() != 0 { + t.Fatalf("bare permission-denied refreshed OAuth: %d", adapter.refreshes.Load()) + } + candidates, listErr := accountRepo.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "grok-bare", "") + if listErr != nil { + t.Fatal(listErr) + } + for _, candidate := range candidates { + if candidate.Credential.ID == credential.ID && candidate.ModelQuotaBlock != nil { + t.Fatalf("bare permission-denied must not create model block: %#v", candidate.ModelQuotaBlock) + } + } +} + +func TestGatewayBarePermissionDeniedRetainsEgressRetryForWebAndConsole(t *testing.T) { + for _, providerValue := range []account.Provider{account.ProviderWeb, account.ProviderConsole} { + providerValue := providerValue + t.Run(string(providerValue), func(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "bare-permission-egress.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + model := "grok-egress-" + string(providerValue) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: providerValue, AuthType: account.AuthTypeSSO, Name: "egress", SourceKey: model, + EncryptedAccessToken: "access", ExpiresAt: time.Now().Add(time.Hour), WebTier: account.WebTierSuper, + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, providerValue, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "egress-key", Prefix: "egress", SecretHash: strings.Repeat("7", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + + adapter := &barePermissionEgressAdapter{providerValue: providerValue} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 2) + + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-egress", ClientKey: clientKey, PublicModel: model, + Body: []byte(`{"model":"` + model + `","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("status = %d", result.StatusCode) + } + result.Finalize(Usage{}, "", "") + _ = result.Body.Close() + if attempts := adapter.attempts.Load(); attempts != 2 { + t.Fatalf("egress attempts = %d, want 2", attempts) + } + observed, err := accountRepo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if observed.AuthStatus != account.AuthStatusActive || observed.FailureCount != 0 || observed.CooldownUntil != nil { + t.Fatalf("egress retry changed account state: %#v", observed) + } + }) + } +} + +func TestGatewayPreviousResponseIDDoesNotCrossAccounts(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "prev-response-pin.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + credentials := make([]account.Credential, 0, 2) + for index, name := range []string{"pin-a", "pin-b"} { + credential, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, EncryptedAccessToken: name, + ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 200 - index, MaxConcurrent: 1, + }) + if createErr != nil { + t.Fatal(createErr) + } + credentials = append(credentials, credential) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-pin"}); err != nil { + t.Fatal(err) + } + for _, credential := range credentials { + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{"grok-pin"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "pin-key", Prefix: "pin", SecretHash: strings.Repeat("3", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := responseRepo.Save(ctx, inferencedomain.ResponseOwnership{ + ResponseID: "resp-pin-root", AccountID: credentials[0].ID, ClientKeyID: clientKey.ID, + Provider: account.ProviderBuild, PromptCacheKey: "session-pin", ExpiresAt: now.Add(time.Hour), + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + exhausted := `{"code":"subscription:free-usage-exhausted","error":"tokens (actual/limit): 10/10"}` + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{ + credentials[0].ID: {{status: http.StatusTooManyRequests, body: exhausted, header: http.Header{"X-Should-Retry": {"false"}}}}, + credentials[1].ID: {{status: http.StatusOK, body: `{"id":"resp-should-not-run"}`}}, + }} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + + _, err = service.CreateResponse(ctx, Input{ + RequestID: "req-pin", ClientKey: clientKey, PublicModel: "grok-pin", + PreviousResponseID: "resp-pin-root", + Body: []byte(`{"model":"grok-pin","previous_response_id":"resp-pin-root","input":"hello"}`), + }) + if err == nil { + t.Fatal("pinned free-usage exhaustion should fail without cross-account failover") + } + if attempts := adapter.Attempts(); len(attempts) != 1 || attempts[0] != credentials[0].ID { + t.Fatalf("previous_response_id must stay pinned to account A, attempts=%#v", attempts) + } +} + +func TestGatewayPinnedResponseReturnsCachedTeamRateLimitWithoutSpinning(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "pinned-team-rate-limit.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + + const model = "grok-pinned-team-limit" + const teamID = "00000000-0000-0000-0000-0000000000d4" + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "pinned-team", SourceKey: "pinned-team", TeamID: teamID, + EncryptedAccessToken: "access", ExpiresAt: time.Now().Add(time.Hour), + Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderBuild, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + clientKey, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "pinned-team-key", Prefix: "pinnedteam", SecretHash: strings.Repeat("6", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := responseRepo.Save(ctx, inferencedomain.ResponseOwnership{ + ResponseID: "resp-pinned-team", AccountID: credential.ID, ClientKeyID: clientKey.ID, + Provider: account.ProviderBuild, PromptCacheKey: "session-pinned-team", ExpiresAt: now.Add(time.Hour), + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + adapter := &scriptedBuildAdapter{responses: map[uint64][]scriptedBuildResponse{}} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 3) + service.markTeamModelRateLimit(credential, model, provider.RateLimitMetadata{ + Scope: provider.RateLimitScopeRPS, TeamID: teamID, Model: model, Actual: 2, Limit: 2, RetryAfter: time.Minute, + }, now) + + requestCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + _, err = service.CreateResponse(requestCtx, Input{ + RequestID: "req-pinned-team", ClientKey: clientKey, PublicModel: model, + PreviousResponseID: "resp-pinned-team", + Body: []byte(`{"model":"grok-pinned-team-limit","previous_response_id":"resp-pinned-team","input":"hello"}`), + }) + var failure *UpstreamFailure + if !errors.As(err, &failure) || failure.HTTPStatus != http.StatusTooManyRequests || failure.RetryAfter <= 0 { + t.Fatalf("failure = %#v, err = %v", failure, err) + } + if requestCtx.Err() != nil { + t.Fatalf("pinned request waited for context expiry: %v", requestCtx.Err()) + } + if attempts := adapter.Attempts(); len(attempts) != 0 { + t.Fatalf("cached Team limit must not reach upstream, attempts=%#v", attempts) + } +} + +type scriptedBuildResponse struct { + status int + body string + header http.Header +} + +type barePermissionEgressAdapter struct { + providerValue account.Provider + attempts atomic.Int64 +} + +func (a *barePermissionEgressAdapter) Provider() account.Provider { return a.providerValue } +func (a *barePermissionEgressAdapter) Definition() provider.Definition { + return testConversationDefinition(a.providerValue) +} +func (a *barePermissionEgressAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + if a.attempts.Add(1) == 1 { + return &provider.Response{ + StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"code":"permission-denied","error":"request rejected by policy"}`)), + }, nil + } + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"resp-egress-ok","status":"completed","output":[]}`)), + }, nil +} + +type scriptedBuildAdapter struct { + mu sync.Mutex + attempts []uint64 + responses map[uint64][]scriptedBuildResponse + refreshes atomic.Int64 + lastRateLimit *provider.RateLimitMetadata +} + +func (a *scriptedBuildAdapter) Provider() account.Provider { return account.ProviderBuild } +func (a *scriptedBuildAdapter) Definition() provider.Definition { + definition := testConversationDefinition(account.ProviderBuild) + definition.Conversation.StoredResponses = true + definition.Conversation.Compact = true + return definition +} +func (a *scriptedBuildAdapter) ForwardResponse(_ context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.attempts = append(a.attempts, request.Credential.ID) + queue := a.responses[request.Credential.ID] + if len(queue) == 0 { + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"resp-default"}`))}, nil + } + next := queue[0] + a.responses[request.Credential.ID] = queue[1:] + header := next.header.Clone() + if header == nil { + header = make(http.Header) + } + var rateLimit *provider.RateLimitMetadata + if next.status == http.StatusTooManyRequests { + rateLimit = provider.ParseRateLimitMetadata([]byte(next.body)) + a.lastRateLimit = rateLimit + } + return &provider.Response{ + StatusCode: next.status, Status: http.StatusText(next.status), Header: header, + Body: io.NopCloser(strings.NewReader(next.body)), RateLimit: rateLimit, + }, nil +} +func (a *scriptedBuildAdapter) RefreshCredential(context.Context, account.Credential) (provider.RefreshedCredential, error) { + a.refreshes.Add(1) + return provider.RefreshedCredential{EncryptedAccessToken: "access-new", EncryptedRefreshToken: "refresh-new", ExpiresAt: time.Now().Add(6 * time.Hour)}, nil +} +func (a *scriptedBuildAdapter) Attempts() []uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return append([]uint64(nil), a.attempts...) +} + +type systemicForbiddenAdapter struct { + mu sync.Mutex + attempts []uint64 +} + +type authRescueAdapter struct { + attempts atomic.Int64 + refreshes atomic.Int64 + rejectAll atomic.Bool + denyChat atomic.Bool + recoverDenied atomic.Bool +} + +func (a *authRescueAdapter) Provider() account.Provider { return account.ProviderBuild } +func (a *authRescueAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderBuild) +} +func (a *authRescueAdapter) ForwardResponse(_ context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + a.attempts.Add(1) + if a.denyChat.Load() { + if a.recoverDenied.Load() { + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"resp-fallback","status":"completed","output":[]}`)), + RecoveredPrimaryFailure: &provider.DiagnosticResponse{ + StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), + Body: []byte(`{"code":"permission-denied","error":"Access to the chat endpoint is denied"}`), + }, + }, nil + } + return &provider.Response{ + StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"permission_denied","message":"Access to the chat endpoint is denied"}}`)), + }, nil + } + if a.rejectAll.Load() { + return &provider.Response{ + StatusCode: http.StatusUnauthorized, Status: "401 Unauthorized", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"unauthorized","message":"access token rejected"}}`)), + }, nil + } + if request.Credential.EncryptedAccessToken == "access-old" { + return &provider.Response{ + StatusCode: http.StatusUnauthorized, Status: "401 Unauthorized", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"permission_denied","message":"Access to the chat endpoint is denied"}}`)), + }, nil + } + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok"))}, nil +} +func (a *authRescueAdapter) RefreshCredential(context.Context, account.Credential) (provider.RefreshedCredential, error) { + a.refreshes.Add(1) + return provider.RefreshedCredential{EncryptedAccessToken: "access-new", EncryptedRefreshToken: "refresh-new", ExpiresAt: time.Now().Add(6 * time.Hour)}, nil +} + +func (a *systemicForbiddenAdapter) Provider() account.Provider { return account.ProviderBuild } +func (a *systemicForbiddenAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderBuild) +} +func (a *systemicForbiddenAdapter) ForwardResponse(_ context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + a.mu.Lock() + a.attempts = append(a.attempts, request.Credential.ID) + a.mu.Unlock() + return &provider.Response{ + StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"upstream policy rejected request"}`)), + }, nil +} +func (a *systemicForbiddenAdapter) Attempts() []uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return append([]uint64(nil), a.attempts...) +} + +type webStoredResponseAdapter struct{} + +func (webStoredResponseAdapter) Provider() account.Provider { return account.ProviderWeb } +func (webStoredResponseAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderWeb, + Conversation: provider.ConversationSurface{ + Responses: true, StoredResponses: true, + }, + Inference: provider.InferencePolicy{Usage: provider.UsageEstimated}, + } +} +func (webStoredResponseAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"resp-web-ownership"}`)), + }, nil +} + +type webRateLimitAdapter struct{} + +type webImageStreamAdapter struct { + mu sync.Mutex + streaming bool + partialImages int + editRequest provider.ImageEditRequest + synced chan string + failureEgress *infraegress.Manager + attempts []uint64 + unauthorizedID uint64 + forbiddenRemaining int +} + +type webChatQuotaAdapter struct { + synced chan string +} + +type credentialFailureImageAdapter struct { + generationCalls atomic.Int64 +} + +func (a *credentialFailureImageAdapter) Provider() account.Provider { return account.ProviderBuild } + +func (a *credentialFailureImageAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderBuild, ModelNamespace: account.ProviderBuild.ModelNamespace(), + Credential: provider.CredentialSurface{AuthType: account.AuthTypeOAuth, Refresh: true}, + Media: provider.MediaSurface{ImageGeneration: true}, + } +} + +func (a *credentialFailureImageAdapter) RefreshCredential(context.Context, account.Credential) (provider.RefreshedCredential, error) { + return provider.RefreshedCredential{}, errors.New("simulated credential refresh failure") +} + +func (a *credentialFailureImageAdapter) GenerateImage(context.Context, provider.ImageGenerationRequest) (*provider.Response, error) { + a.generationCalls.Add(1) + return nil, errors.New("unexpected image generation") +} + +func (webRateLimitAdapter) Provider() account.Provider { return account.ProviderWeb } +func (webRateLimitAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderWeb) +} +func (webRateLimitAdapter) QuotaMode(string) string { return "fast" } +func (webRateLimitAdapter) TierOrder(string) []account.WebTier { + return []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy} +} +func (webRateLimitAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + header := make(http.Header) + header.Set("Retry-After", "3600") + return &provider.Response{StatusCode: http.StatusTooManyRequests, Header: header, Body: io.NopCloser(strings.NewReader(`{"error":"limited"}`))}, nil +} + +func (a *webImageStreamAdapter) Provider() account.Provider { return account.ProviderWeb } +func (a *webImageStreamAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderWeb) +} +func (a *webImageStreamAdapter) QuotaMode(model string) string { + if model == "grok-imagine-image" { + return "fast" + } + return "" +} +func (a *webImageStreamAdapter) TierOrder(string) []account.WebTier { + return []account.WebTier{account.WebTierSuper, account.WebTierHeavy} +} +func (a *webImageStreamAdapter) GenerateImage(ctx context.Context, request provider.ImageGenerationRequest) (*provider.Response, error) { + a.mu.Lock() + a.streaming = request.Streaming + a.partialImages = request.PartialImages + failureEgress := a.failureEgress + a.attempts = append(a.attempts, request.Credential.ID) + unauthorizedID := a.unauthorizedID + forbidden := a.forbiddenRemaining > 0 + if forbidden { + a.forbiddenRemaining-- + } + a.mu.Unlock() + if forbidden { + return &provider.Response{ + StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"egress session rejected"}`)), + }, nil + } + if request.Credential.ID == unauthorizedID { + return &provider.Response{ + StatusCode: http.StatusUnauthorized, Status: "401 Unauthorized", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"unauthorized"}}`)), + }, nil + } + if failureEgress != nil { + lease, err := failureEgress.Acquire(ctx, egressdomain.ScopeWeb, "image-failure") + if err != nil { + return nil, err + } + lease.Release() + return nil, errors.New("simulated image transport failure") + } + body := "event: image_generation.completed\ndata: {}\n\ndata: [DONE]\n\n" + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(body)), QuotaUnits: 1}, nil +} +func (a *webImageStreamAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp-image-lite","object":"response"}`)), QuotaUnits: 3, + }, nil +} +func (a *webImageStreamAdapter) EditImage(_ context.Context, request provider.ImageEditRequest) (*provider.Response, error) { + a.mu.Lock() + a.editRequest = request + a.mu.Unlock() + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"created":1,"data":[{"url":"https://example.com/edit.png"}]}`)), QuotaUnits: request.Count, + }, nil +} +func (a *webImageStreamAdapter) Streaming() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.streaming +} +func (a *webImageStreamAdapter) PartialImages() int { + a.mu.Lock() + defer a.mu.Unlock() + return a.partialImages +} +func (a *webImageStreamAdapter) EditRequest() provider.ImageEditRequest { + a.mu.Lock() + defer a.mu.Unlock() + return a.editRequest +} +func (a *webImageStreamAdapter) FailWithEgress(manager *infraegress.Manager) { + a.mu.Lock() + a.failureEgress = manager + a.mu.Unlock() +} +func (a *webImageStreamAdapter) Attempts() []uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return append([]uint64(nil), a.attempts...) +} +func (a *webImageStreamAdapter) SyncQuota(context.Context, account.Credential) (provider.QuotaSnapshot, error) { + return provider.QuotaSnapshot{}, errors.New("unexpected full quota sync") +} +func (a *webImageStreamAdapter) SyncQuotaMode(_ context.Context, credential account.Credential, mode string) (account.QuotaWindow, error) { + now := time.Now().UTC() + a.synced <- mode + return account.QuotaWindow{ + AccountID: credential.ID, Mode: mode, Remaining: 8, Total: 10, + WindowSeconds: 3600, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }, nil +} + +func (a *webChatQuotaAdapter) Provider() account.Provider { return account.ProviderWeb } +func (a *webChatQuotaAdapter) Definition() provider.Definition { + return testConversationDefinition(account.ProviderWeb) +} +func (a *webChatQuotaAdapter) QuotaMode(string) string { return "fast" } +func (a *webChatQuotaAdapter) TierOrder(string) []account.WebTier { + return []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy} +} +func (a *webChatQuotaAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"chat-response"}`))}, nil +} +func (a *webChatQuotaAdapter) SyncQuota(context.Context, account.Credential) (provider.QuotaSnapshot, error) { + return provider.QuotaSnapshot{}, errors.New("unexpected full quota sync") +} +func (a *webChatQuotaAdapter) SyncQuotaMode(_ context.Context, credential account.Credential, mode string) (account.QuotaWindow, error) { + now := time.Now().UTC() + a.synced <- mode + return account.QuotaWindow{ + AccountID: credential.ID, Mode: mode, Remaining: 17, Total: 20, + WindowSeconds: 3600, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }, nil +} + +func (a *failoverAdapter) Provider() account.Provider { return account.ProviderBuild } +func (a *failoverAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderBuild, + Conversation: provider.ConversationSurface{ + Responses: true, Compact: true, StoredResponses: true, + }, + } +} +func (a *failoverAdapter) ForwardResponse(_ context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + a.mu.Lock() + a.attempts = append(a.attempts, request.Credential.ID) + a.lastMethod = request.Method + a.lastPath = request.Path + a.lastPromptCacheKey = request.PromptCacheKey + a.lastReasoningReplayKey = request.ReasoningReplayKey + a.lastGrokTurnIndex = request.GrokTurnIndex + resourceStatus := a.resourceStatus + a.mu.Unlock() + status, body := http.StatusOK, "ok" + header := make(http.Header) + if request.Method != http.MethodPost && resourceStatus != 0 { + status, body = resourceStatus, "missing" + } else if request.Credential.ID == a.firstID || a.failureIDs[request.Credential.ID] { + status = a.failureStatus + if status == 0 { + status = http.StatusTooManyRequests + } + body = a.failureBody + if body == "" { + body = "limited" + } + header = a.failureHeader.Clone() + } + return &provider.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func (a *failoverAdapter) setResourceStatus(status int) { + a.mu.Lock() + defer a.mu.Unlock() + a.resourceStatus = status +} + +func (a *failoverAdapter) resetAttempts() { + a.mu.Lock() + defer a.mu.Unlock() + a.attempts = nil + a.lastMethod = "" + a.lastPath = "" + a.lastPromptCacheKey = "" + a.lastReasoningReplayKey = "" + a.lastGrokTurnIndex = "" +} +func (a *failoverAdapter) ListModels(context.Context, account.Credential) ([]string, error) { + return nil, nil +} + +func testConversationDefinition(providerValue account.Provider) provider.Definition { + definition := provider.Definition{ + Provider: providerValue, + Conversation: provider.ConversationSurface{ + Responses: true, ChatCompletions: true, Messages: true, + }, + Inference: provider.InferencePolicy{Usage: provider.UsageUpstream}, + } + if providerValue == account.ProviderBuild { + definition.Credential.Refresh = true + } + if providerValue == account.ProviderWeb { + definition.Quota = provider.QuotaRemoteWindow + definition.Inference.Usage = provider.UsageEstimated + } + if providerValue == account.ProviderWeb || providerValue == account.ProviderConsole { + definition.Inference.RetryForbiddenAsEgress = true + } + return definition +} +func (a *failoverAdapter) GetBilling(context.Context, account.Credential) (account.Billing, error) { + return account.Billing{}, nil +} +func (a *failoverAdapter) RefreshCredential(context.Context, account.Credential) (provider.RefreshedCredential, error) { + return provider.RefreshedCredential{}, nil +} +func (a *failoverAdapter) StartDeviceAuthorization(context.Context) (provider.DeviceAuthorization, error) { + return provider.DeviceAuthorization{}, nil +} +func (a *failoverAdapter) PollDeviceAuthorization(context.Context, string) (provider.CredentialSeed, error) { + return provider.CredentialSeed{}, nil +} +func (a *failoverAdapter) ParseImportedCredentials([]byte) ([]provider.CredentialSeed, error) { + return nil, nil +} +func (a *failoverAdapter) MarshalCredentials([]provider.CredentialSeed) ([]byte, error) { + return nil, nil +} + +func testCipher(t *testing.T) *security.Cipher { + t.Helper() + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + return cipher +} diff --git a/backend/internal/application/gateway/timing.go b/backend/internal/application/gateway/timing.go new file mode 100644 index 0000000000000000000000000000000000000000..e9a31dad271b9346dca326cc732e0fd02601ce5a --- /dev/null +++ b/backend/internal/application/gateway/timing.go @@ -0,0 +1,143 @@ +package gateway + +import ( + "io" + "log/slog" + "sync" + "sync/atomic" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" +) + +// generationTiming 只记录阶段耗时和有限枚举,不保存请求体、凭据或会话键。 +type generationTiming struct { + mu sync.Mutex + started time.Time + route string + provider accountdomain.Provider + selectionWait time.Duration + credentialWait time.Duration + upstreamWait time.Duration + firstHeaders time.Duration + firstBody time.Duration + attempts int + finished bool +} + +func newGenerationTiming(route string, provider accountdomain.Provider) *generationTiming { + return &generationTiming{started: time.Now(), route: route, provider: provider} +} + +func (t *generationTiming) markSelection(duration time.Duration) { + t.mu.Lock() + t.selectionWait += duration + t.mu.Unlock() +} + +func (t *generationTiming) markCredential(duration time.Duration) { + t.mu.Lock() + t.credentialWait += duration + t.mu.Unlock() +} + +func (t *generationTiming) markUpstream(duration time.Duration) { + t.mu.Lock() + t.attempts++ + t.upstreamWait += duration + if t.firstHeaders == 0 { + t.firstHeaders = time.Since(t.started) + } + t.mu.Unlock() +} + +func (t *generationTiming) markFirstBody() { + t.mu.Lock() + if t.firstBody == 0 { + t.firstBody = time.Since(t.started) + } + t.mu.Unlock() +} + +func (t *generationTiming) finish(logger *slog.Logger, outcome string) { + if t == nil { + return + } + t.mu.Lock() + if t.finished { + t.mu.Unlock() + return + } + t.finished = true + total := time.Since(t.started) + retries := max(0, t.attempts-1) + fields := []any{ + "route", t.route, "provider", t.provider, "outcome", outcome, "total_ms", total.Milliseconds(), + "selection_wait_ms", t.selectionWait.Milliseconds(), "credential_wait_ms", t.credentialWait.Milliseconds(), + "upstream_wait_ms", t.upstreamWait.Milliseconds(), "first_headers_ms", t.firstHeaders.Milliseconds(), + "first_body_ms", t.firstBody.Milliseconds(), "attempts", t.attempts, "retries", retries, + } + t.mu.Unlock() + labels := perfmetrics.Labels{Subsystem: "gateway", Provider: string(t.provider), Outcome: outcome} + perfmetrics.Default.ObserveDuration("request_duration_us", labels, total) + perfmetrics.Default.ObserveDuration("stage_duration_us", withTimingStage(labels, "selection"), t.selectionWait) + perfmetrics.Default.ObserveDuration("stage_duration_us", withTimingStage(labels, "credential"), t.credentialWait) + perfmetrics.Default.ObserveDuration("stage_duration_us", withTimingStage(labels, "upstream"), t.upstreamWait) + perfmetrics.Default.Add("attempt_count", labels, int64(t.attempts)) + if logger == nil { + logger = slog.Default() + } + logger.Debug("generation_timing", fields...) +} + +func withTimingStage(labels perfmetrics.Labels, stage string) perfmetrics.Labels { + labels.Stage = stage + return labels +} + +type firstByteReadCloser struct { + io.ReadCloser + once sync.Once + mark func() +} + +type firstTokenTimer struct { + started time.Time + once sync.Once + encoded atomic.Int64 +} + +func newFirstTokenTimer(started time.Time) *firstTokenTimer { + return &firstTokenTimer{started: started} +} + +func (t *firstTokenTimer) mark() { + if t == nil { + return + } + t.once.Do(func() { + elapsedMS := max(int64(0), time.Since(t.started).Milliseconds()) + t.encoded.Store(elapsedMS + 1) + }) +} + +func (t *firstTokenTimer) milliseconds() *int64 { + if t == nil { + return nil + } + encoded := t.encoded.Load() + if encoded == 0 { + return nil + } + value := encoded - 1 + return &value +} + +func (r *firstByteReadCloser) Read(buffer []byte) (int, error) { + n, err := r.ReadCloser.Read(buffer) + if n > 0 && r.mark != nil { + r.once.Do(r.mark) + } + return n, err +} diff --git a/backend/internal/application/gateway/timing_test.go b/backend/internal/application/gateway/timing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..58f9a4fe10b9dae5c6a848a2bcba4968efd18017 --- /dev/null +++ b/backend/internal/application/gateway/timing_test.go @@ -0,0 +1,51 @@ +package gateway + +import ( + "bytes" + "io" + "log/slog" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestGenerationTimingLogsOnlyPhaseMetadata(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + timing := newGenerationTiming("public-model", accountdomain.ProviderBuild) + timing.markSelection(10 * time.Millisecond) + timing.markCredential(20 * time.Millisecond) + timing.markUpstream(30 * time.Millisecond) + timing.markUpstream(40 * time.Millisecond) + body := &firstByteReadCloser{ReadCloser: io.NopCloser(strings.NewReader("ok")), mark: timing.markFirstBody} + if _, err := io.ReadAll(body); err != nil { + t.Fatal(err) + } + timing.finish(logger, "success") + logged := output.String() + for _, expected := range []string{"generation_timing", "route=public-model", "provider=grok_build", "selection_wait_ms=10", "credential_wait_ms=20", "upstream_wait_ms=70", "attempts=2", "retries=1"} { + if !strings.Contains(logged, expected) { + t.Fatalf("log missing %q: %s", expected, logged) + } + } +} + +func TestFirstTokenTimerMarksOnce(t *testing.T) { + timer := newFirstTokenTimer(time.Now().Add(-25 * time.Millisecond)) + if timer.milliseconds() != nil { + t.Fatal("unmarked timer returned a value") + } + timer.mark() + first := timer.milliseconds() + if first == nil || *first < 20 { + t.Fatalf("first token milliseconds = %v", first) + } + time.Sleep(time.Millisecond) + timer.mark() + second := timer.milliseconds() + if second == nil || *second != *first { + t.Fatalf("timer changed after second mark: first=%v second=%v", first, second) + } +} diff --git a/backend/internal/application/gateway/video.go b/backend/internal/application/gateway/video.go new file mode 100644 index 0000000000000000000000000000000000000000..3fadffbbeefcff677d960f4d27c97ea1a2e61555 --- /dev/null +++ b/backend/internal/application/gateway/video.go @@ -0,0 +1,623 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" +) + +const ( + videoJobTimeout = 2 * time.Hour + videoJobLease = videoJobTimeout + 5*time.Minute + videoJobRecoveryInterval = 30 * time.Second + videoOutputAttempts = 3 +) + +type VideoInput struct { + RequestID string + ClientKey clientkey.Key + PublicModel string + Prompt string + Duration int + AspectRatio string + Resolution string + ReferenceURLs []string +} + +func (s *Service) CreateVideo(ctx context.Context, input VideoInput) (media.Job, error) { + if s.mediaJobs == nil || s.mediaQueue == nil { + return media.Job{}, fmt.Errorf("视频任务服务未配置") + } + if len(input.Prompt) > 100000 || (len(input.Prompt) == 0 && len(input.ReferenceURLs) == 0) { + return media.Job{}, fmt.Errorf("文本生视频必须提供 prompt;图片生视频可以省略 prompt") + } + inputJSON, err := encodeVideoInput(input.ReferenceURLs) + if err != nil { + return media.Job{}, err + } + routes, err := s.models.GetByPublicIDCandidates(ctx, input.PublicModel) + if err != nil { + return media.Job{}, ErrModelNotFound + } + route, err := s.selectMediaRoute(routes, input.ClientKey, model.CapabilityVideo, func(providerValue account.Provider) bool { + _, ok := s.providers.Videos(providerValue) + return ok + }) + if err != nil { + return media.Job{}, err + } + if err := s.checkLedgerReady(); err != nil { + return media.Job{}, err + } + externalModel := model.ExternalPublicID(route.Provider, route.PublicID) + quotaMode := s.providers.QuotaMode(route.Provider, route.UpstreamModel) + lease, err := s.selector.AcquireForKey(ctx, route.Provider, route.ID, route.UpstreamModel, quotaMode, "", nil, false, input.ClientKey.AccountScope()) + if err != nil { + return media.Job{}, fmt.Errorf("%w: %w", ErrNoAvailableAccount, err) + } + accountID := lease.Credential.ID + lease.Release() + token, err := security.NewOpaqueToken(18) + if err != nil { + return media.Job{}, err + } + now := time.Now().UTC() + job := media.Job{ + ID: "video_" + token, RequestID: input.RequestID, + ClientKeyID: input.ClientKey.ID, ClientKeyName: input.ClientKey.Name, + AccountID: accountID, AccountName: lease.Credential.Name, + Provider: string(route.Provider), Model: externalModel, ModelRouteID: route.ID, UpstreamModel: model.DisplayUpstreamModel(route.Provider, route.UpstreamModel), Prompt: input.Prompt, + Seconds: input.Duration, Size: input.AspectRatio, Quality: input.Resolution, + Status: media.StatusQueued, Progress: 0, InputJSON: inputJSON, InputImageCount: len(input.ReferenceURLs), CreatedAt: now, UpdatedAt: now, + } + reserved := false + if pricing, ok := audit.EstimateOfficialVideoCost(externalModel, input.Resolution, input.Duration); ok { + reserved, err = s.clientKeys.ReserveBilling(ctx, input.ClientKey, "video_usage_"+job.ID, pricing.CostInUSDTicks, mediaBillingReservationTTL) + if err != nil { + return media.Job{}, err + } + } + if err := s.mediaJobs.CreateMediaJob(ctx, job); err != nil { + if reserved { + s.cancelBillingReservation("video_usage_" + job.ID) + } + return media.Job{}, err + } + if !s.enqueueVideoJob(job.ID) { + s.logger.Warn("video_job_queue_full", "job_id", job.ID) + } + return job, nil +} + +func (s *Service) GetVideo(ctx context.Context, id string, key clientkey.Key) (media.Job, error) { + if s.mediaJobs == nil { + return media.Job{}, ErrResponseNotFound + } + job, err := s.mediaJobs.GetMediaJob(ctx, id, key.ID) + if err != nil { + return media.Job{}, ErrResponseNotFound + } + return job, nil +} + +func (s *Service) OpenVideoContent(ctx context.Context, id string, key clientkey.Key) (io.ReadCloser, string, int64, error) { + job, err := s.GetVideo(ctx, id, key) + if err != nil { + return nil, "", 0, err + } + if job.Status != media.StatusCompleted { + return nil, "", 0, fmt.Errorf("视频内容尚未可用") + } + // 本地资产优先:XAI ZDR 上传完成后不经公网回环下载。 + if job.ResultAssetID != "" && s.mediaAssets != nil { + asset, body, openErr := s.mediaAssets.OpenVideo(ctx, job.ResultAssetID) + if openErr == nil { + return body, asset.MIMEType, asset.SizeBytes, nil + } + } + if job.UpstreamURL == "" { + return nil, "", 0, fmt.Errorf("视频内容尚未可用") + } + adapter, ok := s.providers.Videos(account.Provider(job.Provider)) + if !ok { + return nil, "", 0, ErrResponseAccountUnavailable + } + downloader, ok := adapter.(provider.VideoContentDownloader) + if !ok || s.selector == nil || s.selector.accounts == nil || s.accounts == nil { + return nil, "", 0, ErrResponseAccountUnavailable + } + credential, err := s.selector.accounts.Get(ctx, job.AccountID) + if err != nil { + return nil, "", 0, ErrResponseAccountUnavailable + } + credential, err = s.accounts.EnsureCredential(ctx, credential, false) + if err != nil { + return nil, "", 0, ErrResponseAccountUnavailable + } + return downloader.DownloadVideo(ctx, credential, job.UpstreamURL) +} + +func (s *Service) RecoverVideoJobs(ctx context.Context) error { + if s.mediaJobs == nil { + return nil + } + usageErr := s.reconcileVideoUsage(ctx) + values, err := s.mediaJobs.ListRecoverableMediaJobs(ctx, 1000) + if err != nil { + return errors.Join(usageErr, err) + } + for _, job := range values { + if !s.enqueueVideoJob(job.ID) { + break + } + } + return usageErr +} + +// RunVideoWorkers 使用固定 Worker 处理持久化任务,避免突发请求按任务创建无界 goroutine。 +func (s *Service) RunVideoWorkers(ctx context.Context) { + if s.mediaQueue == nil || s.mediaWorker <= 0 { + return + } + var workers sync.WaitGroup + workers.Add(s.mediaWorker) + for range s.mediaWorker { + go func() { + defer workers.Done() + for { + select { + case <-ctx.Done(): + return + case id := <-s.mediaQueue: + err := batch.Do(ctx, func(workCtx context.Context) error { + s.processVideoJob(workCtx, id) + return nil + }) + s.mediaMu.Lock() + delete(s.mediaQueued, id) + s.mediaMu.Unlock() + if err != nil && ctx.Err() == nil { + if panicErr, ok := err.(*batch.PanicError); ok { + s.logger.Error("video_worker_panicked", "job_id", id, "error", panicErr, "stack", string(panicErr.Stack)) + } else { + s.logger.Error("video_worker_failed", "job_id", id, "error", err) + } + } + } + } + }() + } + workers.Wait() +} + +func (s *Service) enqueueVideoJob(id string) bool { + if id == "" || s.mediaQueue == nil { + return false + } + s.mediaMu.Lock() + if _, exists := s.mediaQueued[id]; exists { + s.mediaMu.Unlock() + return true + } + s.mediaQueued[id] = struct{}{} + s.mediaMu.Unlock() + select { + case s.mediaQueue <- id: + return true + default: + s.mediaMu.Lock() + delete(s.mediaQueued, id) + s.mediaMu.Unlock() + full := s.mediaQueueFull.Add(1) + if s.logger != nil && (full == 1 || full%100 == 0) { + s.logger.Warn("video_queue_full", "count", full, "queued", len(s.mediaQueue), "capacity", cap(s.mediaQueue)) + } + return false + } +} + +func (s *Service) processVideoJob(ctx context.Context, id string) { + job, claimed, err := s.claimVideoJob(ctx, id) + if err != nil { + s.logger.Warn("video_job_claim_failed", "job_id", id, "error", err) + return + } + if !claimed { + return + } + var route model.Route + if job.ModelRouteID != 0 { + route, err = s.models.Get(ctx, job.ModelRouteID) + } else { + route, err = s.models.GetByPublicID(ctx, job.Model) + } + if err != nil { + s.failVideoJob(ctx, job, "model_not_found", errors.New("模型路由不存在")) + return + } + s.runVideoJob(ctx, job, route) +} + +// RunVideoRecovery 周期认领新建后未启动或执行实例失联后的媒体任务。 +func (s *Service) RunVideoRecovery(ctx context.Context) { + ticker := time.NewTicker(videoJobRecoveryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.RecoverVideoJobs(ctx); err != nil { + s.logger.Warn("video_job_recovery_failed", "error", err) + } + } + } +} + +func (s *Service) claimVideoJob(ctx context.Context, id string) (media.Job, bool, error) { + now := time.Now().UTC() + claimToken, err := security.NewOpaqueToken(18) + if err != nil { + return media.Job{}, false, err + } + return s.mediaJobs.TryClaimMediaJob(ctx, id, now, now.Add(videoJobLease), claimToken) +} + +func (s *Service) runVideoJob(parent context.Context, job media.Job, route model.Route) { + ctx, cancel := context.WithTimeout(parent, videoJobTimeout) + defer cancel() + ctx, egressTrace := infraegress.WithTrace(ctx) + startedAt := time.Now() + job.Progress = max(job.Progress, 1) + job.UpdatedAt = time.Now().UTC() + if err := s.mediaJobs.UpdateMediaJob(ctx, job); err != nil { + s.logger.Warn("video_job_progress_write_failed", "job_id", job.ID, "error", err) + } + // 视频任务创建时已持久化账号归属;恢复只能重新获取原账号,禁止因后续 + // 轮询或结果处理失败切换到其他账号。 + lease, err := s.selector.AcquirePinned(ctx, route.Provider, job.AccountID, route.ID, route.UpstreamModel, "", true) + if err != nil { + if parent.Err() != nil { + s.deferVideoJob(parent, job) + return + } + s.failVideoJob(parent, job, "account_unavailable", err) + return + } + defer lease.Release() + credential, err := s.accounts.EnsureCredential(ctx, lease.Credential, false) + if err != nil { + s.failVideoJob(parent, job, "account_unavailable", err) + return + } + lease.Credential = credential + adapter, ok := s.providers.Videos(route.Provider) + if !ok { + s.failVideoJob(parent, job, "provider_unavailable", ErrNoAvailableAccount) + return + } + lastProgress := job.Progress + result, err := adapter.GenerateVideo(ctx, provider.VideoRequest{ + Credential: lease.Credential, Billing: lease.Billing, JobID: job.ID, Prompt: job.Prompt, Duration: job.Seconds, AspectRatio: job.Size, Resolution: job.Quality, + ReferenceURLs: decodeVideoInput(job.InputJSON), + Progress: func(value int) { + value = min(99, max(1, value)) + if value-lastProgress < 5 { + return + } + lastProgress = value + job.Progress, job.UpdatedAt = value, time.Now().UTC() + leaseUntil := job.UpdatedAt.Add(videoJobLease) + job.LeaseUntil = &leaseUntil + updateCtx, updateCancel := context.WithTimeout(context.Background(), 3*time.Second) + _ = s.mediaJobs.UpdateMediaJob(updateCtx, job) + updateCancel() + }, + }) + if err == nil && result.AssetID == "" && result.URL != "" { + result, err = s.persistRemoteVideo(ctx, job.ID, adapter, lease.Credential, result) + } + if err != nil { + if parent.Err() != nil { + s.deferVideoJob(parent, job) + return + } + failureCtx, failureCancel := context.WithTimeout(context.Background(), finalizationTimeout) + failureHandled := false + if errors.Is(err, provider.ErrUnauthorized) { + if lease.Credential.AuthType == account.AuthTypeSSO { + s.markSSOCredentialRejected(failureCtx, lease.Credential, fmt.Sprintf("%s SSO credential rejected", lease.Credential.Provider)) + } + failureHandled = true + } else if status, ok := provider.ErrorHTTPStatus(err); ok { + switch { + case status == http.StatusUnauthorized && lease.Credential.AuthType == account.AuthTypeSSO: + s.markSSOCredentialRejected(failureCtx, lease.Credential, fmt.Sprintf("%s SSO credential rejected", lease.Credential.Provider)) + failureHandled = true + case status == http.StatusForbidden && s.providers.RetryForbiddenAsEgress(lease.Credential.Provider): + // Web Provider 已对 anti-bot 403 降低出口健康并重建浏览器会话; + // 视频请求已提交,不能换号重试,也不能误伤账号池。 + // 符合资格的 Build 主地址 403 由 Adapter 尝试 XAI,不在此禁用账号。 + failureHandled = true + case status == http.StatusForbidden && lease.Credential.Provider == account.ProviderBuild: + if !account.IsBuildSuper(lease.Credential, lease.Billing) { + // 非 Super 的 403 按账号级故障处理;auto 模式不会因此回退 XAI。 + s.selector.MarkFailure(failureCtx, lease.Credential, status, 0) + } + // Super(Billing paid 或 entitlement)的 403 保持服务级处理。 + failureHandled = true + case (status == http.StatusPaymentRequired || status == http.StatusTooManyRequests) && lease.QuotaMode != "": + exhausted, reconcileErr := s.accounts.ReconcileRateLimit(failureCtx, lease.Credential.ID, lease.QuotaMode, 0) + s.selector.MarkQuotaStateChanged(lease.Credential.Provider) + if reconcileErr != nil || !exhausted { + s.selector.MarkFailure(failureCtx, lease.Credential, status, 0) + } + failureHandled = true + case status >= http.StatusInternalServerError: + // 5xx 是 Provider 服务级故障,不应让某个账号退出号池。 + failureHandled = true + default: + s.selector.MarkFailure(failureCtx, lease.Credential, status, 0) + failureHandled = true + } + } + if !failureHandled && !provider.IsMediaPostProcessingError(err) { + s.selector.MarkFailure(failureCtx, lease.Credential, 0, 0) + } + failureCancel() + applyMediaJobEgress(&job, egressTrace, route.Provider) + s.logVideoGenerationFailure(job, lease.Credential, err) + failureCode, publicErr := "generation_failed", err + if status, ok := provider.ErrorHTTPStatus(err); errors.Is(err, provider.ErrUnauthorized) || (ok && (status == http.StatusUnauthorized || status == http.StatusForbidden)) { + failureCode, publicErr = "provider_unavailable", errors.New("上游服务暂不可用") + } + s.failVideoJob(parent, job, failureCode, publicErr) + return + } + now := time.Now().UTC() + job.Status, job.Progress, job.UpstreamURL, job.ContentType = media.StatusCompleted, 100, result.URL, result.ContentType + // 成功终态必须清空历史错误字段,避免管理端/恢复路径把中间失败文案当成最终结果。 + job.ErrorCode, job.ErrorMessage = "", "" + if result.AssetID != "" { + job.ResultAssetID = result.AssetID + } + applyMediaJobEgress(&job, egressTrace, route.Provider) + job.LeaseUntil, job.UpdatedAt, job.CompletedAt = nil, now, &now + if err := s.persistVideoJobWithRetry(parent, job); err != nil { + s.logger.Error("video_job_terminal_write_failed", "job_id", job.ID, "error", err) + return + } + s.selector.MarkSuccess(context.Background(), lease.Credential) + if err := s.recordVideoAudit(context.Background(), job, time.Since(startedAt).Milliseconds()); err != nil { + s.logger.Error("video_usage_record_failed", "job_id", job.ID, "event_id", "video_usage_"+job.ID, "error", err) + } + if quotaKind, _ := s.providers.QuotaKind(route.Provider); quotaKind == provider.QuotaRemoteWindow && lease.QuotaMode == "weekly" { + s.accounts.QueueQuotaRefresh(job.AccountID, lease.QuotaMode) + } +} + +// persistRemoteVideo 只重试已经生成的视频结果下载与本地归档,不重新调用生成接口, +// 且所有尝试固定使用创建任务的同一凭据。 +func (s *Service) persistRemoteVideo(ctx context.Context, jobID string, adapter provider.VideoAdapter, credential account.Credential, result provider.VideoResult) (provider.VideoResult, error) { + if s.mediaAssets == nil { + return result, provider.NewMediaPostProcessingError(provider.MediaPostProcessingStorage, errors.New("视频媒体存储未配置")) + } + downloader, ok := adapter.(provider.VideoContentDownloader) + if !ok { + return result, provider.NewMediaPostProcessingError(provider.MediaPostProcessingDownload, errors.New("Provider 不支持视频内容下载")) + } + var lastErr error + for attempt := 0; attempt < videoOutputAttempts; attempt++ { + body, contentType, _, downloadErr := downloader.DownloadVideo(ctx, credential, result.URL) + if downloadErr != nil { + lastErr = provider.NewMediaPostProcessingError(provider.MediaPostProcessingDownload, downloadErr) + } else { + asset, saveErr := s.mediaAssets.SaveVideo(ctx, jobID, contentType, body) + _ = body.Close() + if saveErr == nil { + result.AssetID = asset.ID + result.ContentType = asset.MIMEType + return result, nil + } + lastErr = provider.NewMediaPostProcessingError(provider.MediaPostProcessingStorage, saveErr) + } + if ctx.Err() != nil || attempt+1 >= videoOutputAttempts { + break + } + if waitErr := waitVideoOutputRetry(ctx, attempt); waitErr != nil { + return result, waitErr + } + } + return result, lastErr +} + +func waitVideoOutputRetry(ctx context.Context, attempt int) error { + delays := [...]time.Duration{200 * time.Millisecond, 750 * time.Millisecond} + timer := time.NewTimer(delays[min(attempt, len(delays)-1)]) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (s *Service) reconcileVideoUsage(ctx context.Context) error { + jobs, err := s.mediaJobs.ListUnrecordedTerminalMediaJobs(ctx, 200) + if err != nil { + return err + } + var result error + for _, job := range jobs { + durationMS := int64(0) + if job.CompletedAt != nil { + durationMS = max(int64(0), job.CompletedAt.Sub(job.CreatedAt).Milliseconds()) + } + if err := s.recordVideoAudit(ctx, job, durationMS); err != nil { + result = firstError(result, fmt.Errorf("任务 %s: %w", job.ID, err)) + } + } + return result +} + +func (s *Service) recordVideoAudit(ctx context.Context, job media.Job, durationMS int64) error { + var accountID *uint64 + if job.AccountID > 0 { + value := job.AccountID + accountID = &value + } + createdAt := time.Now().UTC() + if job.CompletedAt != nil && !job.CompletedAt.IsZero() { + createdAt = job.CompletedAt.UTC() + } + statusCode := http.StatusOK + if job.Status == media.StatusFailed { + statusCode = http.StatusBadGateway + switch job.ErrorCode { + case "account_unavailable", "provider_unavailable": + statusCode = http.StatusServiceUnavailable + case "model_not_found": + statusCode = http.StatusNotFound + } + } + record := audit.Record{ + EventID: "video_usage_" + job.ID, RequestID: job.RequestID, ClientKeyID: job.ClientKeyID, ClientKeyName: job.ClientKeyName, + ModelRouteID: job.ModelRouteID, ModelPublicID: job.Model, ModelUpstreamModel: job.UpstreamModel, + Provider: job.Provider, Operation: audit.OperationVideo, UsageSource: audit.UsageSourceNone, + AccountID: accountID, AccountName: job.AccountName, StatusCode: statusCode, ErrorCode: job.ErrorCode, + EgressNodeID: job.EgressNodeID, EgressNodeName: job.EgressNodeName, EgressScope: job.EgressScope, EgressMode: audit.EgressMode(job.EgressMode), + MediaInputImages: int64(job.InputImageCount), + DurationMS: durationMS, CreatedAt: createdAt, + } + if job.Status == media.StatusCompleted { + record.MediaOutputSeconds = int64(max(0, job.Seconds)) + } + if pricing, ok := audit.EstimateOfficialVideoCost(job.Model, job.Quality, job.Seconds); ok && job.Status == media.StatusCompleted { + record.EstimatedCostInUSDTicks = pricing.CostInUSDTicks + record.PricingModel = pricing.Model + record.PricingVersion = audit.OfficialPricingAsOf + } + if durable, ok := s.audits.(interface { + CreateDurable(context.Context, audit.Record) error + }); ok { + if err := durable.CreateDurable(ctx, record); err != nil { + return err + } + } else if err := s.audits.Create(ctx, record); err != nil { + return err + } + markCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancel() + return s.mediaJobs.MarkMediaJobUsageRecorded(markCtx, job.ID, time.Now().UTC()) +} + +func encodeVideoInput(referenceURLs []string) (string, error) { + data, err := json.Marshal(map[string][]string{"image_urls": referenceURLs}) + if err != nil { + return "", fmt.Errorf("编码视频输入: %w", err) + } + if len(data) > media.MaxInputJSONBytes { + return "", ErrVideoInputTooLarge + } + return string(data), nil +} + +func decodeVideoInput(value string) []string { + var input map[string][]string + _ = json.Unmarshal([]byte(value), &input) + return input["image_urls"] +} + +func (s *Service) failVideoJob(ctx context.Context, job media.Job, code string, err error) { + now := time.Now().UTC() + job.Status, job.ErrorCode, job.ErrorMessage = media.StatusFailed, code, err.Error() + if len(job.ErrorMessage) > 512 { + job.ErrorMessage = job.ErrorMessage[:512] + } + job.LeaseUntil, job.UpdatedAt, job.CompletedAt = nil, now, &now + if updateErr := s.persistVideoJobWithRetry(ctx, job); updateErr != nil { + s.logger.Error("video_job_terminal_write_failed", "job_id", job.ID, "error", updateErr) + return + } + if auditErr := s.recordVideoAudit(context.Background(), job, max(int64(0), now.Sub(job.CreatedAt).Milliseconds())); auditErr != nil { + s.logger.Error("video_usage_record_failed", "job_id", job.ID, "event_id", "video_usage_"+job.ID, "error", auditErr) + } + s.cancelBillingReservation("video_usage_" + job.ID) +} + +func (s *Service) logVideoGenerationFailure(job media.Job, credential account.Credential, err error) { + logger := s.logger + if logger == nil { + logger = slog.Default() + } + attributes := []any{ + "job_id", job.ID, + "request_id", job.RequestID, + "account_id", credential.ID, + "provider", credential.Provider, + "model", job.UpstreamModel, + "egress_scope", job.EgressScope, + "egress_mode", job.EgressMode, + "error", sanitizeDiagnosticText(err.Error(), 512), + } + if status, ok := provider.ErrorHTTPStatus(err); ok { + attributes = append(attributes, "upstream_status", status) + } + if job.EgressNodeID != nil { + attributes = append(attributes, "egress_node_id", *job.EgressNodeID, "egress_node_name", job.EgressNodeName) + } + logger.Warn("video_generation_failed", attributes...) +} + +func (s *Service) deferVideoJob(ctx context.Context, job media.Job) { + now := time.Now().UTC() + leaseUntil := now.Add(5 * time.Minute) + job.Status = media.StatusInProgress + job.LeaseUntil = &leaseUntil + job.UpdatedAt = now + job.ErrorCode = "" + job.ErrorMessage = "" + if err := s.persistVideoJobWithRetry(ctx, job); err != nil { + s.logger.Error("video_job_defer_write_failed", "job_id", job.ID, "error", err) + } +} + +// persistVideoJobWithRetry 至少执行一次收尾写入;后续退避可被工作进程关闭信号取消。 +func (s *Service) persistVideoJobWithRetry(ctx context.Context, job media.Job) error { + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + lastErr = s.mediaJobs.UpdateMediaJob(writeCtx, job) + cancel() + if lastErr == nil { + return nil + } + if attempt < 3 { + timer := time.NewTimer(time.Duration(attempt) * 100 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return errors.Join(lastErr, ctx.Err()) + case <-timer.C: + } + } + } + return lastErr +} diff --git a/backend/internal/application/gateway/video_test.go b/backend/internal/application/gateway/video_test.go new file mode 100644 index 0000000000000000000000000000000000000000..433e512217594d64d1924de0715e658b466775e4 --- /dev/null +++ b/backend/internal/application/gateway/video_test.go @@ -0,0 +1,280 @@ +package gateway + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestRecoverVideoJobsRetriesUsageWithoutRegeneratingVideo(t *testing.T) { + completedAt := time.Now().UTC() + repository := &videoUsageRepository{job: media.Job{ + ID: "video_usage_recovery", RequestID: "request-usage-recovery", + ClientKeyID: 1, ClientKeyName: "client", AccountID: 2, AccountName: "account", + Provider: "grok_web", Model: "grok-imagine-video", ModelRouteID: 3, UpstreamModel: "video", + Seconds: 8, Quality: "720p", Status: media.StatusCompleted, InputImageCount: 2, CreatedAt: completedAt.Add(-time.Minute), CompletedAt: &completedAt, + }} + recorder := &durableVideoAuditRecorder{failures: 1} + service := &Service{mediaJobs: repository, audits: recorder} + if err := service.RecoverVideoJobs(context.Background()); err == nil { + t.Fatal("first durable audit failure was ignored") + } + if repository.job.UsageRecordedAt != nil { + t.Fatal("usage was marked before durable audit commit") + } + if err := service.RecoverVideoJobs(context.Background()); err != nil { + t.Fatal(err) + } + if repository.job.UsageRecordedAt == nil || recorder.calls != 2 { + t.Fatalf("recordedAt = %v, audit calls = %d", repository.job.UsageRecordedAt, recorder.calls) + } + if recorder.last.EventID != "video_usage_video_usage_recovery" || recorder.last.EstimatedCostInUSDTicks <= 0 || recorder.last.MediaInputImages != 2 { + t.Fatalf("audit = %#v", recorder.last) + } +} + +func TestEncodeVideoInputEnforcesPersistedLimit(t *testing.T) { + overhead := len(`{"image_urls":[""]}`) + atLimit := strings.Repeat("A", media.MaxInputJSONBytes-overhead) + encoded, err := encodeVideoInput([]string{atLimit}) + if err != nil || len(encoded) != media.MaxInputJSONBytes { + t.Fatalf("encoded len=%d err=%v", len(encoded), err) + } + if _, err := encodeVideoInput([]string{atLimit + "A"}); !errors.Is(err, ErrVideoInputTooLarge) { + t.Fatalf("oversized input error = %v", err) + } +} + +func TestRecoverVideoJobsRecordsFailedAuditWithEgress(t *testing.T) { + completedAt := time.Now().UTC() + nodeID := uint64(42) + repository := &videoUsageRepository{job: media.Job{ + ID: "video_failed_recovery", RequestID: "request-failed-recovery", + ClientKeyID: 1, ClientKeyName: "client", AccountID: 2, AccountName: "account", + Provider: "grok_web", Model: "grok-imagine-video", ModelRouteID: 3, UpstreamModel: "video", + Seconds: 8, Quality: "720p", Status: media.StatusFailed, ErrorCode: "generation_failed", ErrorMessage: "upstream disconnected", + EgressNodeID: &nodeID, EgressNodeName: "warp", EgressScope: "grok_web", EgressMode: "proxy", + InputJSON: `{}`, CreatedAt: completedAt.Add(-time.Minute), CompletedAt: &completedAt, + }} + recorder := &durableVideoAuditRecorder{} + service := &Service{mediaJobs: repository, audits: recorder} + if err := service.RecoverVideoJobs(context.Background()); err != nil { + t.Fatal(err) + } + if repository.job.UsageRecordedAt == nil || recorder.calls != 1 { + t.Fatalf("recordedAt = %v, audit calls = %d", repository.job.UsageRecordedAt, recorder.calls) + } + if recorder.last.StatusCode != 502 || recorder.last.ErrorCode != "generation_failed" || recorder.last.EgressNodeID == nil || *recorder.last.EgressNodeID != nodeID || recorder.last.EgressNodeName != "warp" || recorder.last.EgressMode != audit.EgressModeProxy { + t.Fatalf("audit = %#v", recorder.last) + } + if recorder.last.EstimatedCostInUSDTicks != 0 || recorder.last.MediaOutputSeconds != 0 { + t.Fatalf("failed job was billed: %#v", recorder.last) + } +} + +func TestRecoverVideoJobsRecordsDetachedAccountSnapshot(t *testing.T) { + completedAt := time.Now().UTC() + repository := &videoUsageRepository{job: media.Job{ + ID: "video_detached_account", RequestID: "request-detached-account", + ClientKeyID: 1, ClientKeyName: "client", AccountName: "deleted account", + Provider: "grok_web", Model: "grok-imagine-video", ModelRouteID: 3, UpstreamModel: "video", + Seconds: 8, Quality: "720p", Status: media.StatusFailed, ErrorCode: "generation_failed", + InputJSON: `{}`, CreatedAt: completedAt.Add(-time.Minute), CompletedAt: &completedAt, + }} + recorder := &durableVideoAuditRecorder{} + service := &Service{mediaJobs: repository, audits: recorder} + if err := service.RecoverVideoJobs(context.Background()); err != nil { + t.Fatal(err) + } + if recorder.last.AccountID != nil || recorder.last.AccountName != "deleted account" { + t.Fatalf("detached account audit = %#v", recorder.last) + } +} + +func TestLogVideoGenerationFailurePreservesUpstreamDiagnostic(t *testing.T) { + var output bytes.Buffer + service := &Service{logger: slog.New(slog.NewTextHandler(&output, nil))} + nodeID := uint64(7) + service.logVideoGenerationFailure(media.Job{ + ID: "video_failure", RequestID: "request-failure", UpstreamModel: "grok-imagine-video", + EgressNodeID: &nodeID, EgressNodeName: "proxy-1", EgressScope: "grok_web", EgressMode: "proxy", + }, account.Credential{ID: 42, Provider: account.ProviderWeb}, videoStatusError{ + status: http.StatusForbidden, + message: "Grok Web 媒体上游返回 403: upload denied access_token=secret https://assets.grok.com/video?token=secret", + }) + logLine := output.String() + for _, expected := range []string{ + "msg=video_generation_failed", "job_id=video_failure", "request_id=request-failure", + "account_id=42", "provider=grok_web", "upstream_status=403", "upload denied", + "egress_node_id=7", "egress_node_name=proxy-1", + } { + if !strings.Contains(logLine, expected) { + t.Fatalf("log missing %q: %s", expected, logLine) + } + } + for _, secret := range []string{"access_token=secret", "token=secret"} { + if strings.Contains(logLine, secret) { + t.Fatalf("log exposed %q: %s", secret, logLine) + } + } +} + +type videoStatusError struct { + status int + message string +} + +func (e videoStatusError) Error() string { return e.message } +func (e videoStatusError) HTTPStatusCode() int { return e.status } + +func TestVideoQueueIsBoundedAndDeduplicated(t *testing.T) { + service := &Service{} + service.ConfigureMedia(&videoUsageRepository{}, 1) + capacity := cap(service.mediaQueue) + for index := range capacity { + if !service.enqueueVideoJob(fmt.Sprintf("video_%d", index)) { + t.Fatalf("enqueue %d failed before capacity", index) + } + } + if !service.enqueueVideoJob("video_0") { + t.Fatal("duplicate queued job should be treated as accepted") + } + if service.enqueueVideoJob("video_overflow") { + t.Fatal("queue accepted a job beyond its capacity") + } +} + +func TestPersistRemoteVideoRetriesSameResultWithoutRegeneration(t *testing.T) { + adapter := &videoPersistAdapter{failures: 1} + store := &videoAssetStoreStub{} + service := &Service{mediaAssets: store} + credential := account.Credential{ID: 42, Provider: account.ProviderWeb} + result, err := service.persistRemoteVideo(context.Background(), "video_job", adapter, credential, provider.VideoResult{URL: "https://assets.grok.com/video.mp4", ContentType: "video/mp4"}) + if err != nil { + t.Fatal(err) + } + if adapter.generateCalls != 0 || adapter.downloadCalls != 2 || adapter.lastCredentialID != credential.ID { + t.Fatalf("generate=%d download=%d credential=%d", adapter.generateCalls, adapter.downloadCalls, adapter.lastCredentialID) + } + if store.saveCalls != 1 || result.AssetID != "vid_local" || result.ContentType != "video/mp4" { + t.Fatalf("store calls=%d result=%#v", store.saveCalls, result) + } +} + +type videoPersistAdapter struct { + failures int + generateCalls int + downloadCalls int + lastCredentialID uint64 +} + +func (a *videoPersistAdapter) Provider() account.Provider { return account.ProviderWeb } + +func (a *videoPersistAdapter) GenerateVideo(context.Context, provider.VideoRequest) (provider.VideoResult, error) { + a.generateCalls++ + return provider.VideoResult{}, errors.New("must not regenerate") +} + +func (a *videoPersistAdapter) DownloadVideo(_ context.Context, credential account.Credential, _ string) (io.ReadCloser, string, int64, error) { + a.downloadCalls++ + a.lastCredentialID = credential.ID + if a.downloadCalls <= a.failures { + return nil, "", 0, errors.New("temporary download failure") + } + return io.NopCloser(strings.NewReader("video")), "video/mp4", 5, nil +} + +type videoAssetStoreStub struct{ saveCalls int } + +func (s *videoAssetStoreStub) SaveVideo(_ context.Context, jobID, contentType string, body io.Reader) (media.Asset, error) { + s.saveCalls++ + if jobID != "video_job" { + return media.Asset{}, fmt.Errorf("job ID = %s", jobID) + } + if contentType != "video/mp4" { + return media.Asset{}, fmt.Errorf("content type = %s", contentType) + } + data, err := io.ReadAll(body) + if err != nil || string(data) != "video" { + return media.Asset{}, fmt.Errorf("video body = %q: %w", data, err) + } + return media.Asset{ID: "vid_local", Kind: "video", MIMEType: "video/mp4", SizeBytes: int64(len(data))}, nil +} + +func (*videoAssetStoreStub) OpenVideo(context.Context, string) (media.Asset, io.ReadCloser, error) { + return media.Asset{}, nil, errors.New("not implemented") +} + +type durableVideoAuditRecorder struct { + failures int + calls int + last audit.Record +} + +func (r *durableVideoAuditRecorder) Create(context.Context, audit.Record) error { return nil } + +func (r *durableVideoAuditRecorder) CreateDurable(_ context.Context, value audit.Record) error { + r.calls++ + r.last = value + if r.calls <= r.failures { + return errors.New("database unavailable") + } + return nil +} + +type videoUsageRepository struct{ job media.Job } + +func (r *videoUsageRepository) CreateMediaJob(context.Context, media.Job) error { return nil } + +func (r *videoUsageRepository) GetMediaJob(context.Context, string, uint64) (media.Job, error) { + return r.job, nil +} + +func (r *videoUsageRepository) GetMediaJobsByIDs(context.Context, []string) ([]media.Job, error) { + return []media.Job{r.job}, nil +} + +func (r *videoUsageRepository) UpdateMediaJob(context.Context, media.Job) error { return nil } + +func (r *videoUsageRepository) DeleteMediaJob(context.Context, string) error { return nil } + +func (r *videoUsageRepository) ListMediaJobs(context.Context, repository.MediaJobListQuery) ([]media.Job, int64, error) { + return nil, 0, nil +} + +func (r *videoUsageRepository) SummarizeMediaJobs(context.Context) (repository.MediaJobStats, error) { + return repository.MediaJobStats{}, nil +} + +func (r *videoUsageRepository) ListRecoverableMediaJobs(context.Context, int) ([]media.Job, error) { + return nil, nil +} + +func (r *videoUsageRepository) ListUnrecordedTerminalMediaJobs(context.Context, int) ([]media.Job, error) { + if r.job.UsageRecordedAt != nil || (r.job.Status != media.StatusCompleted && r.job.Status != media.StatusFailed) { + return nil, nil + } + return []media.Job{r.job}, nil +} + +func (r *videoUsageRepository) TryClaimMediaJob(context.Context, string, time.Time, time.Time, string) (media.Job, bool, error) { + return media.Job{}, false, nil +} + +func (r *videoUsageRepository) MarkMediaJobUsageRecorded(_ context.Context, _ string, recordedAt time.Time) error { + r.job.UsageRecordedAt = &recordedAt + return nil +} diff --git a/backend/internal/application/invalidation/service.go b/backend/internal/application/invalidation/service.go new file mode 100644 index 0000000000000000000000000000000000000000..e711ce69b9f023eda18e09f8e65f7e959203a3db --- /dev/null +++ b/backend/internal/application/invalidation/service.go @@ -0,0 +1,129 @@ +package invalidation + +import ( + "context" + "log/slog" + "sync/atomic" + "time" + + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + defaultQueueSize = 2048 + publishTimeout = 500 * time.Millisecond + logSampleInterval = 1000 + maxCoalesceBatch = defaultQueueSize +) + +type Service struct { + bus repository.InvalidationBus + source string + logger *slog.Logger + handler func(repository.InvalidationEvent) + queue chan repository.InvalidationEvent + dropped atomic.Uint64 + failures atomic.Uint64 +} + +func NewService(bus repository.InvalidationBus, source string, handler func(repository.InvalidationEvent), logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() + } + if handler == nil { + handler = func(repository.InvalidationEvent) {} + } + return &Service{bus: bus, source: source, logger: logger, handler: handler, queue: make(chan repository.InvalidationEvent, defaultQueueSize)} +} + +// Notify applies local invalidation synchronously and queues remote delivery. +// Local correctness never depends on Redis availability or queue capacity. +func (s *Service) Notify(ctx context.Context, event repository.InvalidationEvent) { + if s == nil || !event.Valid() { + return + } + if event.SourceInstance == "" { + event.SourceInstance = s.source + } + if event.PublishedAt.IsZero() { + event.PublishedAt = time.Now().UTC() + } + s.handler(event) + if s.bus == nil { + return + } + select { + case s.queue <- event: + default: + dropped := s.dropped.Add(1) + if dropped == 1 || dropped%logSampleInterval == 0 { + s.logger.Warn("invalidation_publish_queue_full", "dropped", dropped) + } + } +} + +// RunPublisher drains the bounded remote notification queue until shutdown. +func (s *Service) RunPublisher(ctx context.Context) error { + if s == nil || s.bus == nil { + <-ctx.Done() + return nil + } + for { + select { + case <-ctx.Done(): + return nil + case event := <-s.queue: + pending := map[invalidationKey]repository.InvalidationEvent{eventKey(event): event} + drain: + for drained := 1; drained < maxCoalesceBatch; drained++ { + select { + case next := <-s.queue: + pending[eventKey(next)] = next + default: + break drain + } + } + for _, next := range pending { + publishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), publishTimeout) + err := s.bus.PublishInvalidation(publishCtx, next) + cancel() + if err != nil { + failures := s.failures.Add(1) + if failures == 1 || failures%logSampleInterval == 0 { + s.logger.Warn("invalidation_publish_failed", "failures", failures, "error", err) + } + } + } + } + } +} + +type invalidationKey struct { + layer repository.InvalidationLayer + provider string + clientKeyID uint64 +} + +func eventKey(event repository.InvalidationEvent) invalidationKey { + key := invalidationKey{layer: event.Layer(), provider: string(event.Provider)} + if key.layer == repository.InvalidationLayerClientKey { + key.clientKeyID = event.ClientKeyID + } + return key +} + +// RunSubscriber consumes remote events. Pub/Sub delivery is best effort; the +// local cache TTL remains the correctness fallback when events are missed. +func (s *Service) RunSubscriber(ctx context.Context) error { + if s == nil || s.bus == nil { + <-ctx.Done() + return nil + } + return s.bus.ListenInvalidations(ctx, func(eventCtx context.Context, event repository.InvalidationEvent) error { + if !event.Valid() || event.SourceInstance == s.source { + return nil + } + s.handler(event) + return nil + }) +} diff --git a/backend/internal/application/invalidation/service_test.go b/backend/internal/application/invalidation/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..861dbd99992a44f16cefabea9bc6bd8958efc72f --- /dev/null +++ b/backend/internal/application/invalidation/service_test.go @@ -0,0 +1,108 @@ +package invalidation + +import ( + "context" + "errors" + "log/slog" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type testBus struct { + mu sync.Mutex + published []repository.InvalidationEvent + events []repository.InvalidationEvent + publishErr error +} + +func (b *testBus) PublishInvalidation(_ context.Context, event repository.InvalidationEvent) error { + b.mu.Lock() + b.published = append(b.published, event) + b.mu.Unlock() + return b.publishErr +} + +func (b *testBus) ListenInvalidations(ctx context.Context, handler func(context.Context, repository.InvalidationEvent) error) error { + for _, event := range b.events { + if err := handler(ctx, event); err != nil { + return err + } + } + return nil +} + +func TestNotifyAlwaysAppliesLocalInvalidation(t *testing.T) { + var applied []repository.InvalidationEvent + service := NewService(nil, "local", func(event repository.InvalidationEvent) { + applied = append(applied, event) + }, slog.Default()) + service.Notify(context.Background(), repository.InvalidationEvent{ + Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild, + }) + if len(applied) != 1 || applied[0].SourceInstance != "local" || applied[0].PublishedAt.IsZero() { + t.Fatalf("local invalidation = %#v", applied) + } +} + +func TestNotifyAppliesLocallyWhenRemoteQueueIsFull(t *testing.T) { + var applied int + service := NewService(&testBus{}, "local", func(repository.InvalidationEvent) { applied++ }, slog.Default()) + for range cap(service.queue) { + service.queue <- repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged} + } + service.Notify(context.Background(), repository.InvalidationEvent{Kind: repository.InvalidationAccountBillingChanged}) + if applied != 1 || service.dropped.Load() != 1 { + t.Fatalf("applied=%d dropped=%d", applied, service.dropped.Load()) + } +} + +func TestRunSubscriberIgnoresInvalidAndSameSourceEvents(t *testing.T) { + bus := &testBus{events: []repository.InvalidationEvent{ + {Kind: "unknown", SourceInstance: "remote"}, + {Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild, SourceInstance: "local"}, + {Kind: repository.InvalidationAccountQuotaChanged, Provider: account.ProviderWeb, SourceInstance: "remote", Revision: 2}, + }} + var applied []repository.InvalidationEvent + service := NewService(bus, "local", func(event repository.InvalidationEvent) { + applied = append(applied, event) + }, slog.Default()) + if err := service.RunSubscriber(context.Background()); err != nil { + t.Fatal(err) + } + if len(applied) != 1 || applied[0].Kind != repository.InvalidationAccountQuotaChanged { + t.Fatalf("applied events = %#v", applied) + } +} + +func TestRunPublisherDoesNotStopAfterPublishFailure(t *testing.T) { + bus := &testBus{publishErr: errors.New("redis unavailable")} + service := NewService(bus, "local", nil, slog.Default()) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- service.RunPublisher(ctx) }() + service.Notify(context.Background(), repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + deadline := time.Now().Add(time.Second) + for service.failures.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } + if service.failures.Load() != 1 { + t.Fatalf("publish failures = %d", service.failures.Load()) + } +} + +func TestEventKeyKeepsClientKeyInvalidationsDistinct(t *testing.T) { + first := eventKey(repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged, ClientKeyID: 1}) + second := eventKey(repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged, ClientKeyID: 2}) + global := eventKey(repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged}) + if first == second || first == global || second == global { + t.Fatalf("client-key invalidation keys were coalesced: first=%#v second=%#v global=%#v", first, second, global) + } +} diff --git a/backend/internal/application/media/service.go b/backend/internal/application/media/service.go new file mode 100644 index 0000000000000000000000000000000000000000..20b7e9ac040b01b68ade84bfadc116ca594a91fb --- /dev/null +++ b/backend/internal/application/media/service.go @@ -0,0 +1,506 @@ +package media + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrAssetNotFound = errors.New("媒体资源不存在") + ErrInvalidImage = errors.New("图片内容无效") + ErrInvalidImageSelection = errors.New("图片选择无效") + ErrInvalidVideoSelection = errors.New("视频任务选择无效") + ErrActiveVideoSelection = errors.New("排队中或生成中的视频任务不能删除") + ErrInvalidFilter = errors.New("媒体筛选条件无效") + ErrMediaJobsUnavailable = errors.New("视频任务仓储未配置") +) + +// Service 负责图片/视频校验、文件落盘和元数据持久化的一致性收口。 +type Service struct { + assets repository.MediaAssetRepository + jobs repository.MediaJobRepository + tickets repository.MediaUploadTicketRepository + objects repository.MediaObjectStorage + cleanupLock repository.DistributedLock + publicBaseURL string + configMu sync.RWMutex + maxImageBytes int64 + maxTotalBytes int64 + cleanupAt int + cleanupEvery time.Duration + cleanupSignal chan struct{} + configChanged chan struct{} + totalBytes atomic.Int64 +} + +type Config struct { + PublicBaseURL string + MaxImageBytes int64 + MaxTotalBytes int64 + CleanupThresholdPercent int + CleanupInterval time.Duration +} + +type ImageStats struct { + TotalImages int64 + TotalBytes int64 +} + +type VideoStats struct { + TotalJobs int64 + Completed int64 + Failed int64 + InProgress int64 + Queued int64 +} + +func NewService(assets repository.MediaAssetRepository, jobs repository.MediaJobRepository, objects repository.MediaObjectStorage, cleanupLock repository.DistributedLock, cfg Config) *Service { + return NewServiceWithTickets(assets, jobs, nil, objects, cleanupLock, cfg) +} + +// NewServiceWithTickets 构造包含视频上传票据能力的媒体服务。 +func NewServiceWithTickets(assets repository.MediaAssetRepository, jobs repository.MediaJobRepository, tickets repository.MediaUploadTicketRepository, objects repository.MediaObjectStorage, cleanupLock repository.DistributedLock, cfg Config) *Service { + return &Service{ + assets: assets, jobs: jobs, tickets: tickets, objects: objects, cleanupLock: cleanupLock, + publicBaseURL: strings.TrimRight(strings.TrimSpace(cfg.PublicBaseURL), "/"), maxImageBytes: cfg.MaxImageBytes, + maxTotalBytes: cfg.MaxTotalBytes, cleanupAt: cfg.CleanupThresholdPercent, cleanupEvery: cfg.CleanupInterval, + cleanupSignal: make(chan struct{}, 1), configChanged: make(chan struct{}, 1), + } +} + +// UpdateConfig 热更新媒体容量和清理策略,不重建底层存储实例。 +func (s *Service) UpdateConfig(cfg Config) { + s.configMu.Lock() + s.publicBaseURL = strings.TrimRight(strings.TrimSpace(cfg.PublicBaseURL), "/") + s.maxImageBytes = cfg.MaxImageBytes + s.maxTotalBytes = cfg.MaxTotalBytes + s.cleanupAt = cfg.CleanupThresholdPercent + s.cleanupEvery = cfg.CleanupInterval + s.configMu.Unlock() + select { + case s.configChanged <- struct{}{}: + default: + } +} + +// SaveImage 校验并保存一份不可变图片,文件写入失败或元数据落库失败时不会留下半成品。 +func (s *Service) SaveImage(ctx context.Context, data []byte) (mediadomain.Asset, error) { + cfg := s.runtimeConfig() + if len(data) == 0 || int64(len(data)) > cfg.MaxImageBytes { + return mediadomain.Asset{}, ErrInvalidImage + } + mimeType := http.DetectContentType(data) + if !supportedImageMIME(mimeType) { + return mediadomain.Asset{}, ErrInvalidImage + } + id, err := newAssetID() + if err != nil { + return mediadomain.Asset{}, err + } + digest := sha256.Sum256(data) + createdAt := time.Now().UTC() + storageKey, err := s.objects.SaveImage(ctx, id, mimeType, data) + if err != nil { + return mediadomain.Asset{}, err + } + asset := mediadomain.Asset{ + ID: id, Kind: "image", StorageKey: storageKey, MIMEType: mimeType, + SizeBytes: int64(len(data)), SHA256: hex.EncodeToString(digest[:]), CreatedAt: createdAt, + } + if err := s.assets.CreateMediaAsset(ctx, asset); err != nil { + _ = s.objects.Delete(context.WithoutCancel(ctx), storageKey) + return mediadomain.Asset{}, err + } + if s.totalBytes.Add(asset.SizeBytes) > cleanupThresholdBytes(cfg) { + select { + case s.cleanupSignal <- struct{}{}: + default: + } + } + return asset, nil +} + +// PublicImageURL 返回可直接用于图片展示的公开资源地址。 +func (s *Service) PublicImageURL(id string) string { + return s.runtimeConfig().PublicBaseURL + "/v1/media/images/" + id +} + +// OpenImage 读取图片元数据和正文,不向调用方暴露实际文件路径。 +func (s *Service) OpenImage(ctx context.Context, id string) (mediadomain.Asset, io.ReadCloser, error) { + asset, err := s.assets.GetMediaAsset(ctx, strings.TrimSpace(id)) + if errors.Is(err, repository.ErrNotFound) { + return mediadomain.Asset{}, nil, ErrAssetNotFound + } + if err != nil { + return mediadomain.Asset{}, nil, err + } + if asset.Kind != "image" { + return mediadomain.Asset{}, nil, ErrAssetNotFound + } + body, err := s.objects.Open(ctx, asset.StorageKey) + if errors.Is(err, os.ErrNotExist) { + return mediadomain.Asset{}, nil, ErrAssetNotFound + } + if err != nil { + return mediadomain.Asset{}, nil, err + } + return asset, body, nil +} + +// AdminListImages 分页返回图片资源列表。 +func (s *Service) AdminListImages(ctx context.Context, page, pageSize int, search string) ([]mediadomain.Asset, int64, error) { + return s.assets.ListMediaAssets(ctx, repository.MediaAssetListQuery{Page: mediaPageQuery(page, pageSize, search, repository.SortQuery{})}) +} + +// AdminListVideoJobs 分页返回视频任务列表。 +func (s *Service) AdminListVideoJobs(ctx context.Context, page, pageSize int, search, status string, sort repository.SortQuery) ([]mediadomain.Job, int64, error) { + if s.jobs == nil { + return nil, 0, ErrMediaJobsUnavailable + } + status = strings.TrimSpace(status) + if !validMediaStatus(status) || !repository.IsValidSort(sort, "prompt", "model", "status", "progress", "spec", "account", "createdAt", "completedAt") { + return nil, 0, ErrInvalidFilter + } + return s.jobs.ListMediaJobs(ctx, repository.MediaJobListQuery{ + Page: mediaPageQuery(page, pageSize, search, sort), + Filter: repository.MediaJobListFilter{Status: status}, + }) +} + +// AdminImageStats 返回图片统计信息。 +func (s *Service) AdminImageStats(ctx context.Context) (ImageStats, error) { + stats, err := s.assets.SummarizeMediaAssets(ctx) + if err != nil { + return ImageStats{}, err + } + return ImageStats{TotalImages: stats.TotalImages, TotalBytes: stats.TotalBytes}, nil +} + +// AdminDeleteImages 删除管理员明确选择的图片对象及其元数据。 +// 不存在或已被并发清理的图片按幂等成功处理;非图片资产不会被删除。 +func (s *Service) AdminDeleteImages(ctx context.Context, ids []string) (int, error) { + if len(ids) == 0 || len(ids) > 100 { + return 0, ErrInvalidImageSelection + } + unique := make(map[string]struct{}, len(ids)) + assets := make([]mediadomain.Asset, 0, len(ids)) + for _, rawID := range ids { + id := strings.TrimSpace(rawID) + if id == "" { + return 0, ErrInvalidImageSelection + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + asset, err := s.assets.GetMediaAsset(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + continue + } + if err != nil { + return 0, err + } + if asset.Kind == "image" { + assets = append(assets, asset) + } + } + + deleted := 0 + for _, asset := range assets { + if err := s.objects.Delete(ctx, asset.StorageKey); err != nil && !errors.Is(err, os.ErrNotExist) { + return deleted, err + } + if err := s.assets.DeleteMediaAsset(ctx, asset.ID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + continue + } + return deleted, err + } + deleted++ + } + if total, err := s.assets.TotalMediaAssetBytes(ctx); err == nil { + s.totalBytes.Store(total) + } + return deleted, nil +} + +// AdminVideoStats 返回视频任务统计信息。 +func (s *Service) AdminVideoStats(ctx context.Context) (VideoStats, error) { + if s.jobs == nil { + return VideoStats{}, ErrMediaJobsUnavailable + } + stats, err := s.jobs.SummarizeMediaJobs(ctx) + if err != nil { + return VideoStats{}, err + } + return VideoStats{ + TotalJobs: stats.TotalJobs, Completed: stats.Completed, Failed: stats.Failed, + InProgress: stats.InProgress, Queued: stats.Queued, + }, nil +} + +// AdminDeleteVideoJobs 删除管理员明确选择的终态视频任务。 +// 成功任务若已归档本地视频,则同步撤销上传票据并删除对象和媒体元数据;审计记录独立保留。 +func (s *Service) AdminDeleteVideoJobs(ctx context.Context, ids []string) (int, error) { + if s.jobs == nil { + return 0, ErrMediaJobsUnavailable + } + if len(ids) == 0 || len(ids) > 100 { + return 0, ErrInvalidVideoSelection + } + unique := make(map[string]struct{}, len(ids)) + normalized := make([]string, 0, len(ids)) + for _, rawID := range ids { + id := strings.TrimSpace(rawID) + if id == "" { + return 0, ErrInvalidVideoSelection + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + normalized = append(normalized, id) + } + jobs, err := s.jobs.GetMediaJobsByIDs(ctx, normalized) + if err != nil { + return 0, err + } + for _, job := range jobs { + if job.Status != mediadomain.StatusCompleted && job.Status != mediadomain.StatusFailed { + return 0, ErrActiveVideoSelection + } + } + + deleted := 0 + for _, job := range jobs { + if s.tickets != nil { + if err := s.tickets.DeleteUploadTicketsByJobID(ctx, job.ID); err != nil { + return deleted, err + } + } + if err := s.deleteVideoAsset(ctx, job.ResultAssetID); err != nil { + return deleted, err + } + if err := s.jobs.DeleteMediaJob(ctx, job.ID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + continue + } + return deleted, err + } + deleted++ + } + if total, err := s.assets.TotalMediaAssetBytes(ctx); err == nil { + s.totalBytes.Store(total) + } + return deleted, nil +} + +// deleteVideoAsset 删除任务绑定的本地视频对象与元数据;缺失对象按幂等成功处理。 +func (s *Service) deleteVideoAsset(ctx context.Context, assetID string) error { + assetID = strings.TrimSpace(assetID) + if assetID == "" { + return nil + } + asset, err := s.assets.GetMediaAsset(ctx, assetID) + if errors.Is(err, repository.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if asset.Kind != "video" { + return nil + } + if err := s.objects.Delete(ctx, asset.StorageKey); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := s.assets.DeleteMediaAsset(ctx, asset.ID); err != nil && !errors.Is(err, repository.ErrNotFound) { + return err + } + return nil +} + +func mediaPageQuery(page, pageSize int, search string, sort repository.SortQuery) repository.PageQuery { + page, pageSize = repository.NormalizePage(page, pageSize, repository.DefaultPageSize) + return repository.PageQuery{Offset: (page - 1) * pageSize, Limit: pageSize, Search: strings.TrimSpace(search), Sort: sort} +} + +func validMediaStatus(status string) bool { + switch mediadomain.Status(status) { + case "", mediadomain.StatusQueued, mediadomain.StatusInProgress, mediadomain.StatusCompleted, mediadomain.StatusFailed: + return true + default: + return false + } +} + +// RunCleanup 响应容量阈值并按周期清理最旧媒体资源。 +func (s *Service) RunCleanup(ctx context.Context, onError func(error)) { + cfg := s.runtimeConfig() + if total, err := s.assets.TotalMediaAssetBytes(ctx); err == nil { + s.totalBytes.Store(total) + if total > cleanupThresholdBytes(cfg) { + if _, cleanupErr := s.Cleanup(ctx); cleanupErr != nil && onError != nil { + onError(cleanupErr) + } + } + } else if onError != nil { + onError(err) + } + ticker := time.NewTicker(cfg.CleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-s.cleanupSignal: + case <-s.configChanged: + cfg = s.runtimeConfig() + ticker.Reset(cfg.CleanupInterval) + } + cfg = s.runtimeConfig() + cleanupCtx, cancel := context.WithTimeout(ctx, min(cfg.CleanupInterval, 5*time.Minute)) + _, err := s.Cleanup(cleanupCtx) + cancel() + if err != nil && onError != nil { + onError(err) + } + } +} + +const ( + cleanupAssetBatchSize = 200 + cleanupTicketBatchSize = 200 + // cleanupTicketMaxBatchesPerRun 限制单次 Cleanup 调用中过期票据的删除批次数。 + // 超出部分留给后续直接/后台清理继续回收,避免一次调用无界垄断。 + cleanupTicketMaxBatchesPerRun = 1 +) + +// Cleanup 在跨实例锁保护下删除最旧媒体资产,直到回落到自动清理阈值。 +// 同时有界清理过期上传票据。受保护资产被跳过;通过 offset 前进避免整页受保护时死循环。 +func (s *Service) Cleanup(ctx context.Context) (int, error) { + cfg := s.runtimeConfig() + if s.cleanupLock != nil { + release, acquired, err := s.cleanupLock.Acquire(ctx, "media:cleanup", 30*time.Minute) + if err != nil || !acquired { + return 0, err + } + defer release() + } + // 过期票据回收:不触碰未过期票据与已登记媒体资产;单次调用有批次数上限。 + if s.tickets != nil { + for batch := 0; batch < cleanupTicketMaxBatchesPerRun; batch++ { + n, err := s.tickets.DeleteExpiredUploadTickets(ctx, time.Now().UTC(), cleanupTicketBatchSize) + if err != nil { + return 0, err + } + if n < int64(cleanupTicketBatchSize) { + break + } + } + } + total, err := s.assets.TotalMediaAssetBytes(ctx) + if err != nil { + return 0, err + } + s.totalBytes.Store(total) + threshold := cleanupThresholdBytes(cfg) + if total <= threshold { + return 0, nil + } + deleted := 0 + offset := 0 + for total > threshold { + values, err := s.assets.ListOldestMediaAssets(ctx, offset, cleanupAssetBatchSize) + if err != nil { + return deleted, err + } + if len(values) == 0 { + break + } + protected, protErr := s.assets.ListProtectedMediaAssetIDs(ctx) + if protErr != nil { + return deleted, protErr + } + deletedInBatch := 0 + for _, asset := range values { + if total <= threshold { + break + } + if _, skip := protected[asset.ID]; skip { + continue + } + if err := s.objects.Delete(ctx, asset.StorageKey); err != nil { + if errors.Is(err, os.ErrNotExist) { + return deleted, fmt.Errorf("媒体对象缺失,已保留共享元数据: %s: %w", asset.StorageKey, err) + } + return deleted, err + } + if err := s.assets.DeleteMediaAsset(ctx, asset.ID); err != nil && !errors.Is(err, repository.ErrNotFound) { + return deleted, err + } + total = max(0, total-asset.SizeBytes) + deleted++ + deletedInBatch++ + } + if deletedInBatch == 0 { + // 本页无可删资产:前进 offset 以越过受保护前缀,避免无限循环。 + if len(values) < cleanupAssetBatchSize { + break + } + offset += len(values) + continue + } + // 删除后最旧顺序变化,从头部重新扫描。 + offset = 0 + } + s.totalBytes.Store(total) + return deleted, nil +} + +func (s *Service) runtimeConfig() Config { + s.configMu.RLock() + defer s.configMu.RUnlock() + return Config{ + PublicBaseURL: s.publicBaseURL, + MaxImageBytes: s.maxImageBytes, MaxTotalBytes: s.maxTotalBytes, + CleanupThresholdPercent: s.cleanupAt, CleanupInterval: s.cleanupEvery, + } +} + +func cleanupThresholdBytes(cfg Config) int64 { + return cfg.MaxTotalBytes * int64(cfg.CleanupThresholdPercent) / 100 +} + +func newAssetID() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("生成媒体资源 ID: %w", err) + } + return "img_" + base64.RawURLEncoding.EncodeToString(raw), nil +} + +func supportedImageMIME(value string) bool { + switch value { + case "image/jpeg", "image/png", "image/webp", "image/gif": + return true + default: + return false + } +} diff --git a/backend/internal/application/media/service_test.go b/backend/internal/application/media/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8103eb8aa57dabadb96d6b38dbec08f755aec4ff --- /dev/null +++ b/backend/internal/application/media/service_test.go @@ -0,0 +1,534 @@ +package media + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + localmedia "github.com/chenyme/grok2api/backend/internal/infra/media" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const onePixelPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + +func TestServicePersistsAndReopensImage(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects")) + if err != nil { + t.Fatal(err) + } + service := NewService(relational.NewMediaAssetRepository(database), relational.NewMediaJobRepository(database), objects, nil, Config{ + PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, + CleanupThresholdPercent: 80, CleanupInterval: 10 * time.Minute, + }) + raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) + asset, err := service.SaveImage(ctx, raw) + if err != nil { + t.Fatal(err) + } + if asset.MIMEType != "image/png" || asset.SizeBytes != int64(len(raw)) || len(asset.SHA256) != 64 { + t.Fatalf("asset = %#v", asset) + } + if got := service.PublicImageURL(asset.ID); got != "https://api.example/v1/media/images/"+asset.ID { + t.Fatalf("public URL = %q", got) + } + stored, body, err := service.OpenImage(ctx, asset.ID) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(body) + _ = body.Close() + if err != nil || stored.ID != asset.ID || !bytes.Equal(data, raw) { + t.Fatalf("stored=%#v size=%d err=%v", stored, len(data), err) + } + if _, err := service.SaveImage(ctx, []byte("not an image")); err == nil { + t.Fatal("invalid image content was accepted") + } +} + +func TestAdminDeleteVideoJobsRemovesTerminalJobAssetAndTicket(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-video-delete.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountValue, _, err := relational.NewAccountRepository(database).UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, WebTier: accountdomain.WebTierBasic, + Name: "video-delete-account", SourceKey: "video-delete-account", EncryptedAccessToken: "encrypted-access-token", AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key, err := relational.NewClientKeyRepository(database).Create(ctx, clientkeydomain.Key{ + Name: "video-delete-key", Prefix: "video-delete", SecretHash: strings.Repeat("a", 64), EncryptedSecret: "encrypted-secret", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "video-delete-objects")) + if err != nil { + t.Fatal(err) + } + assets := relational.NewMediaAssetRepository(database) + jobs := relational.NewMediaJobRepository(database) + tickets := relational.NewMediaUploadTicketRepository(database) + service := NewServiceWithTickets(assets, jobs, tickets, objects, nil, Config{MaxTotalBytes: 1 << 30}) + payload := append([]byte{0, 0, 0, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{3}, 64)...) + asset, err := service.SaveVideo(ctx, "", "video/mp4", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + completedAt := now.Add(time.Minute) + job := mediadomain.Job{ + ID: "video_delete_completed", RequestID: "request-video-delete", ClientKeyID: key.ID, ClientKeyName: key.Name, + AccountID: accountValue.ID, AccountName: accountValue.Name, Provider: string(accountdomain.ProviderWeb), + Model: "grok-imagine-video", ModelRouteID: 1, UpstreamModel: "grok-imagine-video", Prompt: "delete me", + Seconds: 6, Size: "16:9", Quality: "720p", Status: mediadomain.StatusCompleted, Progress: 100, + InputJSON: `{}`, ResultAssetID: asset.ID, ContentType: asset.MIMEType, CreatedAt: now, UpdatedAt: completedAt, CompletedAt: &completedAt, + } + if err := jobs.CreateMediaJob(ctx, job); err != nil { + t.Fatal(err) + } + tokenHash := strings.Repeat("b", 64) + if err := tickets.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: tokenHash, AssetID: asset.ID, JobID: job.ID, MaxBytes: DefaultMaxVideoBytes, + AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), ConsumedAt: &now, CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + + deleted, err := service.AdminDeleteVideoJobs(ctx, []string{job.ID}) + if err != nil || deleted != 1 { + t.Fatalf("deleted=%d err=%v", deleted, err) + } + if values, err := jobs.GetMediaJobsByIDs(ctx, []string{job.ID}); err != nil || len(values) != 0 { + t.Fatalf("remaining jobs=%#v err=%v", values, err) + } + if _, err := assets.GetMediaAsset(ctx, asset.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("asset error=%v, want not found", err) + } + if body, err := objects.Open(ctx, asset.StorageKey); !errors.Is(err, os.ErrNotExist) { + if body != nil { + _ = body.Close() + } + t.Fatalf("object error=%v, want not exist", err) + } + if _, err := tickets.GetUploadTicketByHash(ctx, tokenHash); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("ticket error=%v, want not found", err) + } +} + +func TestCleanupDeletesOldestAssetsAtThreshold(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-cleanup.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects")) + if err != nil { + t.Fatal(err) + } + repository := relational.NewMediaAssetRepository(database) + raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) + now := time.Now().UTC() + ids := []string{"img_cleanup_0000000000000001", "img_cleanup_0000000000000002", "img_cleanup_0000000000000003", "img_cleanup_0000000000000004"} + for index, id := range ids { + key, err := objects.SaveImage(ctx, id, "image/png", raw) + if err != nil { + t.Fatal(err) + } + createdAt := now.Add(time.Duration(index-4) * time.Hour) + if index == len(ids)-1 { + createdAt = now + } + if err := repository.CreateMediaAsset(ctx, mediadomain.Asset{ + ID: id, Kind: "image", StorageKey: key, MIMEType: "image/png", SizeBytes: int64(len(raw)), + SHA256: strings.Repeat("a", 64), CreatedAt: createdAt, + }); err != nil { + t.Fatal(err) + } + } + service := NewService(repository, relational.NewMediaJobRepository(database), objects, nil, Config{ + PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, + MaxTotalBytes: int64(len(raw) * 2), CleanupThresholdPercent: 50, + CleanupInterval: 10 * time.Minute, + }) + deleted, err := service.Cleanup(ctx) + if err != nil || deleted != 3 { + t.Fatalf("deleted=%d err=%v", deleted, err) + } + total, err := repository.TotalMediaAssetBytes(ctx) + if err != nil || total != int64(len(raw)) { + t.Fatalf("remaining bytes=%d err=%v", total, err) + } + if _, _, err := service.OpenImage(ctx, ids[0]); !errors.Is(err, ErrAssetNotFound) { + t.Fatalf("oldest asset still exists: %v", err) + } + if _, body, err := service.OpenImage(ctx, ids[3]); err != nil { + t.Fatalf("recent asset was deleted: %v", err) + } else { + _ = body.Close() + } +} + +func TestCleanupPagesPastProtectedAssetsAndDeletesLaterUnprotected(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-cleanup-protected.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-protected")) + if err != nil { + t.Fatal(err) + } + assetRepo := relational.NewMediaAssetRepository(database) + ticketRepo := relational.NewMediaUploadTicketRepository(database) + raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) + now := time.Now().UTC() + // 超过 cleanupAssetBatchSize(200) 的受保护前缀 + 1 个可删资产。 + const protectedCount = cleanupAssetBatchSize + 1 + for i := 0; i < protectedCount; i++ { + id := fmt.Sprintf("img_prot_%04d_aaaaaaaaaa", i) + key, err := objects.SaveImage(ctx, id, "image/png", raw) + if err != nil { + t.Fatal(err) + } + if err := assetRepo.CreateMediaAsset(ctx, mediadomain.Asset{ + ID: id, Kind: "image", StorageKey: key, MIMEType: "image/png", SizeBytes: int64(len(raw)), + SHA256: strings.Repeat("b", 64), CreatedAt: now.Add(time.Duration(i-protectedCount-1) * time.Minute), + }); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(fmt.Sprintf("prot-ticket-%d", i))) + if err := ticketRepo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hex.EncodeToString(sum[:]), AssetID: id, JobID: fmt.Sprintf("job_prot_%d", i), + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + deletableID := "img_free_00000000000001" + key, err := objects.SaveImage(ctx, deletableID, "image/png", raw) + if err != nil { + t.Fatal(err) + } + if err := assetRepo.CreateMediaAsset(ctx, mediadomain.Asset{ + ID: deletableID, Kind: "image", StorageKey: key, MIMEType: "image/png", SizeBytes: int64(len(raw)), + SHA256: strings.Repeat("c", 64), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + // 阈值极低:强制触发清理;受保护资产不可删,应通过 offset 扫到可删项。 + service := NewServiceWithTickets(assetRepo, relational.NewMediaJobRepository(database), ticketRepo, objects, nil, Config{ + PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, + MaxTotalBytes: int64(len(raw)), CleanupThresholdPercent: 50, CleanupInterval: time.Minute, + }) + deleted, err := service.Cleanup(ctx) + if err != nil { + t.Fatal(err) + } + if deleted != 1 { + t.Fatalf("deleted=%d, want 1 (only unprotected asset)", deleted) + } + if _, err := assetRepo.GetMediaAsset(ctx, deletableID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("deletable asset still present: %v", err) + } + if _, err := assetRepo.GetMediaAsset(ctx, fmt.Sprintf("img_prot_%04d_aaaaaaaaaa", 0)); err != nil { + t.Fatalf("protected oldest was deleted: %v", err) + } + if _, err := assetRepo.GetMediaAsset(ctx, fmt.Sprintf("img_prot_%04d_aaaaaaaaaa", protectedCount-1)); err != nil { + t.Fatalf("protected near-end was deleted: %v", err) + } +} + +func TestCleanupAllProtectedTerminatesWithoutDelete(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-cleanup-all-prot.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-all-prot")) + if err != nil { + t.Fatal(err) + } + assetRepo := relational.NewMediaAssetRepository(database) + ticketRepo := relational.NewMediaUploadTicketRepository(database) + raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) + now := time.Now().UTC() + for i := 0; i < 3; i++ { + id := fmt.Sprintf("img_allp_%04d_aaaaaaaa", i) + key, err := objects.SaveImage(ctx, id, "image/png", raw) + if err != nil { + t.Fatal(err) + } + if err := assetRepo.CreateMediaAsset(ctx, mediadomain.Asset{ + ID: id, Kind: "image", StorageKey: key, MIMEType: "image/png", SizeBytes: int64(len(raw)), + SHA256: strings.Repeat("d", 64), CreatedAt: now.Add(time.Duration(i) * time.Minute), + }); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(fmt.Sprintf("all-prot-%d", i))) + if err := ticketRepo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hex.EncodeToString(sum[:]), AssetID: id, JobID: fmt.Sprintf("job_allp_%d", i), + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + service := NewServiceWithTickets(assetRepo, relational.NewMediaJobRepository(database), ticketRepo, objects, nil, Config{ + PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, + MaxTotalBytes: int64(len(raw)), CleanupThresholdPercent: 10, CleanupInterval: time.Minute, + }) + // 若仍无限循环,此用例会挂起失败。 + done := make(chan struct{}) + var deleted int + var cleanupErr error + go func() { + deleted, cleanupErr = service.Cleanup(ctx) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("cleanup did not terminate when all assets are protected") + } + if cleanupErr != nil { + t.Fatal(cleanupErr) + } + if deleted != 0 { + t.Fatalf("deleted=%d, want 0", deleted) + } + total, err := assetRepo.TotalMediaAssetBytes(ctx) + if err != nil || total != int64(len(raw)*3) { + t.Fatalf("total=%d err=%v", total, err) + } +} + +func TestCleanupPrunesExpiredUploadTickets(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-cleanup-tickets.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-tickets")) + if err != nil { + t.Fatal(err) + } + ticketRepo := relational.NewMediaUploadTicketRepository(database) + now := time.Now().UTC() + expiredSum := sha256.Sum256([]byte("expired-token")) + activeSum := sha256.Sum256([]byte("active-token")) + expiredHash := hex.EncodeToString(expiredSum[:]) + activeHash := hex.EncodeToString(activeSum[:]) + if err := ticketRepo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: expiredHash, AssetID: "vid_expired_00000001", JobID: "job_expired", + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(-time.Minute), CreatedAt: now.Add(-time.Hour), + }); err != nil { + t.Fatal(err) + } + if err := ticketRepo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: activeHash, AssetID: "vid_active_0000000001", JobID: "job_active", + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + ticketRepo, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + if _, err := service.Cleanup(ctx); err != nil { + t.Fatal(err) + } + if _, err := ticketRepo.GetUploadTicketByHash(ctx, expiredHash); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("expired ticket should be pruned: %v", err) + } + active, err := ticketRepo.GetUploadTicketByHash(ctx, activeHash) + if err != nil { + t.Fatalf("active ticket pruned: %v", err) + } + if !active.ExpiresAt.After(now) { + t.Fatalf("active ticket corrupted: %#v", active) + } +} + +func TestCleanupCapsExpiredTicketPruningPerInvocation(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-cleanup-ticket-cap.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-ticket-cap")) + if err != nil { + t.Fatal(err) + } + ticketRepo := relational.NewMediaUploadTicketRepository(database) + now := time.Now().UTC() + // 超过单次调用上限:maxBatches * batchSize + 额外过期票据。 + expiredCount := cleanupTicketBatchSize*cleanupTicketMaxBatchesPerRun + 50 + expiredHashes := make([]string, 0, expiredCount) + for i := 0; i < expiredCount; i++ { + sum := sha256.Sum256([]byte(fmt.Sprintf("expired-cap-token-%d", i))) + hash := hex.EncodeToString(sum[:]) + expiredHashes = append(expiredHashes, hash) + if err := ticketRepo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hash, AssetID: fmt.Sprintf("vid_exp_cap_%04d", i), JobID: fmt.Sprintf("job_exp_cap_%d", i), + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(-time.Minute), CreatedAt: now.Add(-time.Hour), + }); err != nil { + t.Fatal(err) + } + } + activeSum := sha256.Sum256([]byte("active-cap-token")) + activeHash := hex.EncodeToString(activeSum[:]) + if err := ticketRepo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: activeHash, AssetID: "vid_active_cap_0000001", JobID: "job_active_cap", + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + ticketRepo, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + + if _, err := service.Cleanup(ctx); err != nil { + t.Fatal(err) + } + // 活跃票据必须保留。 + if _, err := ticketRepo.GetUploadTicketByHash(ctx, activeHash); err != nil { + t.Fatalf("active ticket must survive cleanup: %v", err) + } + // 第一次调用最多删除 cap 条,剩余过期票据仍在。 + remainingAfterFirst := 0 + for _, hash := range expiredHashes { + if _, err := ticketRepo.GetUploadTicketByHash(ctx, hash); err == nil { + remainingAfterFirst++ + } else if !errors.Is(err, repository.ErrNotFound) { + t.Fatal(err) + } + } + capPerRun := cleanupTicketBatchSize * cleanupTicketMaxBatchesPerRun + wantRemaining := expiredCount - capPerRun + if remainingAfterFirst != wantRemaining { + t.Fatalf("remaining expired after first cleanup = %d, want %d (cap=%d total=%d)", remainingAfterFirst, wantRemaining, capPerRun, expiredCount) + } + + // 后续调用继续回收剩余过期票据。 + if _, err := service.Cleanup(ctx); err != nil { + t.Fatal(err) + } + remainingAfterSecond := 0 + for _, hash := range expiredHashes { + if _, err := ticketRepo.GetUploadTicketByHash(ctx, hash); err == nil { + remainingAfterSecond++ + } else if !errors.Is(err, repository.ErrNotFound) { + t.Fatal(err) + } + } + if remainingAfterSecond != 0 { + t.Fatalf("remaining expired after second cleanup = %d, want 0", remainingAfterSecond) + } + if _, err := ticketRepo.GetUploadTicketByHash(ctx, activeHash); err != nil { + t.Fatalf("active ticket must still exist: %v", err) + } +} + +func TestCleanupPreservesMetadataWhenLocalObjectIsMissing(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-missing.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects")) + if err != nil { + t.Fatal(err) + } + repository := relational.NewMediaAssetRepository(database) + raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) + id := "img_missing_0000000000000001" + key, err := objects.SaveImage(ctx, id, "image/png", raw) + if err != nil { + t.Fatal(err) + } + if err := repository.CreateMediaAsset(ctx, mediadomain.Asset{ID: id, Kind: "image", StorageKey: key, MIMEType: "image/png", SizeBytes: int64(len(raw)), SHA256: strings.Repeat("a", 64), CreatedAt: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + if err := objects.Delete(ctx, key); err != nil { + t.Fatal(err) + } + service := NewService(repository, relational.NewMediaJobRepository(database), objects, nil, Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: int64(len(raw)), CleanupThresholdPercent: 50, CleanupInterval: 10 * time.Minute}) + if _, err := service.Cleanup(ctx); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cleanup error = %v", err) + } + if _, err := repository.GetMediaAsset(ctx, id); err != nil { + t.Fatalf("shared metadata was deleted: %v", err) + } +} + +func TestPublicImageURLUsesHotReloadedBase(t *testing.T) { + service := NewService(nil, nil, nil, nil, Config{PublicBaseURL: "https://config.example/base/"}) + if got := service.PublicImageURL("img_demo"); got != "https://config.example/base/v1/media/images/img_demo" { + t.Fatalf("configured URL = %q", got) + } + updated := service.runtimeConfig() + updated.PublicBaseURL = "https://runtime.example/api/" + service.UpdateConfig(updated) + if got := service.PublicImageURL("img_demo"); got != "https://runtime.example/api/v1/media/images/img_demo" { + t.Fatalf("hot-reloaded URL = %q", got) + } +} diff --git a/backend/internal/application/media/upload.go b/backend/internal/application/media/upload.go new file mode 100644 index 0000000000000000000000000000000000000000..5af476f779b2056ae0a09b1e1a39b490ca9b20cd --- /dev/null +++ b/backend/internal/application/media/upload.go @@ -0,0 +1,455 @@ +package media + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + // DefaultMaxVideoBytes 是视频上传与本地视频资产的安全上限(256 MiB)。 + DefaultMaxVideoBytes = 256 << 20 + // videoUploadTicketTTL 限制票据有效期,过期后不可再写入。 + videoUploadTicketTTL = 2 * time.Hour + // videoUploadWaitInterval 轮询本地资产就绪间隔。 + videoUploadWaitInterval = 500 * time.Millisecond +) + +var ( + ErrInvalidVideoUpload = errors.New("视频上传无效") + // ErrVideoUploadTooLarge 表示 body 超过票据/中间件体积上限;handler 映射为 HTTP 413。 + // 同时包装 ErrInvalidVideoUpload,便于统一归类为无效上传。 + ErrVideoUploadTooLarge = errors.New("视频超过体积上限") + ErrUploadTicketNotFound = errors.New("上传票据不存在") + ErrUploadTicketExpired = errors.New("上传票据已过期") + ErrUploadTicketConsumed = errors.New("上传票据已使用") + ErrUploadPublicBase = errors.New("公开 API 地址不可用于 XAI 视频上传") + ErrVideoUploadIncomplete = errors.New("视频尚未上传完成") + ErrUploadTicketsUnavailable = errors.New("视频上传票据仓储未配置") +) + +func errVideoTooLarge() error { + return fmt.Errorf("%w: %w", ErrInvalidVideoUpload, ErrVideoUploadTooLarge) +} + +type stagedVideo struct { + ID string + TempPath string + StorageKey string + MIMEType string + SizeBytes int64 + SHA256 string +} + +// IssueVideoUpload 签发一次性高熵 PUT 地址,供 XAI ZDR 视频写入。 +// 错误信息不得包含完整 URL 或 token 明文。 +func (s *Service) IssueVideoUpload(ctx context.Context, jobID string) (uploadURL, assetID string, err error) { + if s.tickets == nil { + return "", "", ErrUploadTicketsUnavailable + } + publicBase, err := s.httpsPublicBaseURL() + if err != nil { + return "", "", err + } + jobID = strings.TrimSpace(jobID) + if jobID == "" { + return "", "", fmt.Errorf("%w: 缺少视频任务 ID", ErrInvalidVideoUpload) + } + token, err := newUploadToken() + if err != nil { + return "", "", err + } + assetID, err = newVideoAssetID() + if err != nil { + return "", "", err + } + now := time.Now().UTC() + ticket := repository.MediaUploadTicket{ + TokenHash: hashUploadToken(token), + AssetID: assetID, + JobID: jobID, + MaxBytes: DefaultMaxVideoBytes, + AllowedMIME: "video/mp4", + ExpiresAt: now.Add(videoUploadTicketTTL), + CreatedAt: now, + } + if err := s.tickets.CreateUploadTicket(ctx, ticket); err != nil { + return "", "", err + } + // 提前绑定任务结果资产,便于轮询/清理感知进行中的本地目标。 + // 生产中 job 已存在:真实数据库错误必须中止签发,不得返回未绑定目标的 upload URL。 + // ErrNotFound 仅保留给隔离/测试占位 job 的既有行为,且不删除已创建票据。 + if err := s.tickets.BindJobResultAsset(ctx, jobID, assetID); err != nil && !errors.Is(err, repository.ErrNotFound) { + // 补偿删除刚创建的票据,避免 bind 失败后留下不可达行直至 TTL。 + if delErr := s.tickets.DeleteUploadTicketByHash(ctx, ticket.TokenHash); delErr != nil { + return "", "", fmt.Errorf("绑定视频任务结果资产失败: %w", errors.Join(err, fmt.Errorf("回滚上传票据失败: %w", delErr))) + } + return "", "", fmt.Errorf("绑定视频任务结果资产失败: %w", err) + } + uploadURL = publicBase + "/v1/media/uploads/" + token + return uploadURL, assetID, nil +} + +// WaitVideoUpload 等待 PUT 完成后资产元数据就绪。 +func (s *Service) WaitVideoUpload(ctx context.Context, assetID string) (contentType string, err error) { + assetID = strings.TrimSpace(assetID) + if assetID == "" { + return "", fmt.Errorf("%w: 缺少资产 ID", ErrInvalidVideoUpload) + } + ticker := time.NewTicker(videoUploadWaitInterval) + defer ticker.Stop() + for { + asset, getErr := s.assets.GetMediaAsset(ctx, assetID) + if getErr == nil && asset.Kind == "video" && asset.SizeBytes > 0 { + return asset.MIMEType, nil + } + if getErr != nil && !errors.Is(getErr, repository.ErrNotFound) { + return "", getErr + } + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return "", ErrVideoUploadIncomplete + } + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// ReceiveVideoUpload 处理公开 PUT:校验票据、流式限长、内容嗅探、原子提交并登记资产。 +// 票据本身即授权,无需客户端 API key。 +func (s *Service) ReceiveVideoUpload(ctx context.Context, rawToken string, contentType string, body io.Reader) (mediadomain.Asset, error) { + if s.tickets == nil { + return mediadomain.Asset{}, ErrUploadTicketsUnavailable + } + token := strings.TrimSpace(rawToken) + if !validUploadToken(token) { + return mediadomain.Asset{}, ErrUploadTicketNotFound + } + tokenHash := hashUploadToken(token) + now := time.Now().UTC() + // 先读取票据以做预检,再原子消费,防止重复成功写入。 + existing, err := s.tickets.GetUploadTicketByHash(ctx, tokenHash) + if errors.Is(err, repository.ErrNotFound) { + return mediadomain.Asset{}, ErrUploadTicketNotFound + } + if err != nil { + return mediadomain.Asset{}, err + } + if existing.ConsumedAt != nil { + return mediadomain.Asset{}, ErrUploadTicketConsumed + } + if !existing.ExpiresAt.After(now) { + return mediadomain.Asset{}, ErrUploadTicketExpired + } + // 若资产已存在则视为重复成功写入,拒绝覆盖;仓储故障必须向上传播,禁止继续写入或消费票据。 + if _, getErr := s.assets.GetMediaAsset(ctx, existing.AssetID); getErr == nil { + return mediadomain.Asset{}, ErrUploadTicketConsumed + } else if getErr != nil && !errors.Is(getErr, repository.ErrNotFound) { + return mediadomain.Asset{}, getErr + } + + // XAI ZDR 当前契约为 MP4:票据 AllowedMIME 约束声明类型与嗅探结果。 + allowedMIME := normalizeVideoMIME(existing.AllowedMIME) + if allowedMIME == "" { + allowedMIME = "video/mp4" + } + if !supportedVideoMIME(allowedMIME) { + return mediadomain.Asset{}, fmt.Errorf("%w: 票据 MIME 无效", ErrInvalidVideoUpload) + } + declaredMIME := normalizeVideoMIME(contentType) + if declaredMIME != "" && declaredMIME != allowedMIME { + return mediadomain.Asset{}, fmt.Errorf("%w: Content-Type 与票据不允许的类型不一致", ErrInvalidVideoUpload) + } + + staged, err := s.stageVideo(ctx, existing.AssetID, allowedMIME, body, existing.MaxBytes) + if err != nil { + return mediadomain.Asset{}, err + } + defer func() { _ = s.objects.AbortVideoUpload(context.WithoutCancel(ctx), staged.TempPath) }() + + // 原子消费票据:并发 PUT 仅一个能消费成功。 + ticket, consumed, err := s.tickets.ConsumeUploadTicket(ctx, tokenHash, now) + if err != nil { + return mediadomain.Asset{}, err + } + if !consumed { + return mediadomain.Asset{}, ErrUploadTicketConsumed + } + // 消费后若提交/登记失败,必须释放票据以便同 token 重试,避免永久烧毁。 + releaseTicket := true + defer func() { + if releaseTicket { + _, _ = s.tickets.ReleaseUploadTicket(context.WithoutCancel(ctx), tokenHash) + } + }() + + asset, err := s.commitStagedVideo(ctx, staged, now) + if err != nil { + return mediadomain.Asset{}, err + } + releaseTicket = false + _ = s.tickets.BindJobResultAsset(ctx, ticket.JobID, ticket.AssetID) + return asset, nil +} + +// SaveVideo 将 Provider 已生成的远程视频流式归档为本地媒体资产。 +func (s *Service) SaveVideo(ctx context.Context, jobID, contentType string, body io.Reader) (mediadomain.Asset, error) { + mimeType := normalizeVideoMIME(contentType) + if mimeType == "" { + mimeType = "video/mp4" + } + if !supportedVideoMIME(mimeType) { + return mediadomain.Asset{}, fmt.Errorf("%w: Content-Type 无效", ErrInvalidVideoUpload) + } + id, err := newVideoAssetID() + if err != nil { + return mediadomain.Asset{}, err + } + jobID = strings.TrimSpace(jobID) + bound := false + if s.tickets != nil && jobID != "" { + bindErr := s.tickets.BindJobResultAsset(ctx, jobID, id) + if bindErr != nil && !errors.Is(bindErr, repository.ErrNotFound) { + return mediadomain.Asset{}, fmt.Errorf("绑定视频任务结果资产失败: %w", bindErr) + } + bound = bindErr == nil + } + saved := false + defer func() { + if bound && !saved { + _ = s.tickets.BindJobResultAsset(context.WithoutCancel(ctx), jobID, "") + } + }() + staged, err := s.stageVideo(ctx, id, mimeType, body, DefaultMaxVideoBytes) + if err != nil { + return mediadomain.Asset{}, err + } + defer func() { _ = s.objects.AbortVideoUpload(context.WithoutCancel(ctx), staged.TempPath) }() + asset, err := s.commitStagedVideo(ctx, staged, time.Now().UTC()) + if err == nil { + saved = true + } + return asset, err +} + +func (s *Service) stageVideo(ctx context.Context, id, mimeType string, body io.Reader, maxBytes int64) (stagedVideo, error) { + if maxBytes <= 0 { + maxBytes = DefaultMaxVideoBytes + } + tempPath, storageKey, err := s.objects.BeginVideoUpload(ctx, id, mimeType) + if err != nil { + return stagedVideo{}, err + } + cleanup := true + defer func() { + if cleanup { + _ = s.objects.AbortVideoUpload(context.WithoutCancel(ctx), tempPath) + } + }() + file, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return stagedVideo{}, err + } + hasher := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(file, hasher), io.LimitReader(body, maxBytes+1)) + closeErr := file.Close() + if copyErr != nil { + if isBodyTooLargeError(copyErr) { + return stagedVideo{}, errVideoTooLarge() + } + return stagedVideo{}, copyErr + } + if closeErr != nil { + return stagedVideo{}, closeErr + } + if written == 0 { + return stagedVideo{}, fmt.Errorf("%w: 空内容", ErrInvalidVideoUpload) + } + if written > maxBytes { + return stagedVideo{}, errVideoTooLarge() + } + sniffed, err := sniffVideoFile(tempPath) + if err != nil { + return stagedVideo{}, err + } + if sniffed != mimeType { + return stagedVideo{}, fmt.Errorf("%w: 内容类型与声明类型不一致", ErrInvalidVideoUpload) + } + cleanup = false + return stagedVideo{ID: id, TempPath: tempPath, StorageKey: storageKey, MIMEType: mimeType, SizeBytes: written, SHA256: hex.EncodeToString(hasher.Sum(nil))}, nil +} + +func (s *Service) commitStagedVideo(ctx context.Context, staged stagedVideo, createdAt time.Time) (mediadomain.Asset, error) { + if err := s.objects.CommitVideoUpload(ctx, staged.TempPath, staged.StorageKey); err != nil { + return mediadomain.Asset{}, err + } + asset := mediadomain.Asset{ + ID: staged.ID, Kind: "video", StorageKey: staged.StorageKey, MIMEType: staged.MIMEType, + SizeBytes: staged.SizeBytes, SHA256: staged.SHA256, CreatedAt: createdAt, + } + if err := s.assets.CreateMediaAsset(ctx, asset); err != nil { + _ = s.objects.Delete(context.WithoutCancel(ctx), staged.StorageKey) + return mediadomain.Asset{}, err + } + if s.totalBytes.Add(asset.SizeBytes) > cleanupThresholdBytes(s.runtimeConfig()) { + select { + case s.cleanupSignal <- struct{}{}: + default: + } + } + return asset, nil +} + +// OpenVideo 读取视频元数据与正文。 +func (s *Service) OpenVideo(ctx context.Context, id string) (mediadomain.Asset, io.ReadCloser, error) { + asset, err := s.assets.GetMediaAsset(ctx, strings.TrimSpace(id)) + if errors.Is(err, repository.ErrNotFound) { + return mediadomain.Asset{}, nil, ErrAssetNotFound + } + if err != nil { + return mediadomain.Asset{}, nil, err + } + if asset.Kind != "video" { + return mediadomain.Asset{}, nil, ErrAssetNotFound + } + body, err := s.objects.Open(ctx, asset.StorageKey) + if errors.Is(err, os.ErrNotExist) { + return mediadomain.Asset{}, nil, ErrAssetNotFound + } + if err != nil { + return mediadomain.Asset{}, nil, err + } + return asset, body, nil +} + +func (s *Service) httpsPublicBaseURL() (string, error) { + base := strings.TrimRight(strings.TrimSpace(s.runtimeConfig().PublicBaseURL), "/") + if base == "" { + return "", ErrUploadPublicBase + } + parsed, err := url.ParseRequestURI(base) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", ErrUploadPublicBase + } + return base, nil +} + +func newUploadToken() (string, error) { + // 256 bit 熵,hex 编码;校验时再 SHA-256 存库。 + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("生成上传票据: %w", err) + } + return hex.EncodeToString(raw), nil +} + +func hashUploadToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func validUploadToken(token string) bool { + if len(token) != 64 { + return false + } + for _, c := range token { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func newVideoAssetID() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("生成视频资源 ID: %w", err) + } + return "vid_" + base64.RawURLEncoding.EncodeToString(raw), nil +} + +func supportedVideoMIME(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "video/mp4", "video/webm", "video/quicktime": + return true + default: + return false + } +} + +func normalizeVideoMIME(value string) string { + value = strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0])) + if value == "application/octet-stream" || value == "" { + return "" + } + return value +} + +func sniffVideoFile(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + header := make([]byte, 512) + n, err := io.ReadFull(file, header) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return "", err + } + if n == 0 { + return "", fmt.Errorf("%w: 空内容", ErrInvalidVideoUpload) + } + detected := http.DetectContentType(header[:n]) + // DetectContentType 对 mp4 通常返回 video/mp4;部分样本可能是 application/octet-stream。 + if supportedVideoMIME(detected) { + return detected, nil + } + if looksLikeMP4(header[:n]) { + return "video/mp4", nil + } + if looksLikeWebM(header[:n]) { + return "video/webm", nil + } + return "", fmt.Errorf("%w: 非视频内容", ErrInvalidVideoUpload) +} + +func looksLikeMP4(header []byte) bool { + if len(header) < 12 { + return false + } + // ISO BMFF: bytes 4-8 often "ftyp" + return string(header[4:8]) == "ftyp" +} + +func looksLikeWebM(header []byte) bool { + // EBML header 0x1A45DFA3 + return len(header) >= 4 && header[0] == 0x1A && header[1] == 0x45 && header[2] == 0xDF && header[3] == 0xA3 +} + +func isBodyTooLargeError(err error) bool { + if err == nil { + return false + } + var maxBytes *http.MaxBytesError + if errors.As(err, &maxBytes) { + return true + } + // 兼容部分包装后的 MaxBytesReader 文案。 + msg := err.Error() + return strings.Contains(msg, "http: request body too large") || strings.Contains(msg, "request body too large") +} diff --git a/backend/internal/application/media/upload_test.go b/backend/internal/application/media/upload_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e87aaed75aa4eb5ce3b8bb0f235ac14f68ae2dee --- /dev/null +++ b/backend/internal/application/media/upload_test.go @@ -0,0 +1,631 @@ +package media + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/infra/media" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestIssueAndReceiveVideoUploadOnce(t *testing.T) { + service, tickets, cleanup := newUploadTestService(t) + defer cleanup() + ctx := context.Background() + + // Public base must be HTTPS for XAI. + uploadURL, assetID, err := service.IssueVideoUpload(ctx, "video_job_test") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(uploadURL, "https://api.example/v1/media/uploads/") { + t.Fatalf("uploadURL = %s", uploadURL) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + if len(token) != 64 { + t.Fatalf("token entropy length = %d", len(token)) + } + + // Minimal ftyp box so content sniff accepts MP4. + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x01}, 64)...) + asset, err := service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if asset.ID != assetID || asset.Kind != "video" || asset.SizeBytes != int64(len(payload)) { + t.Fatalf("asset = %#v", asset) + } + + // Replay must fail and not create a second asset. + _, err = service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + if err == nil { + t.Fatal("expected replay rejection") + } + + // Ticket hash is stored, not plaintext token. + sum := sha256.Sum256([]byte(token)) + stored, err := tickets.GetUploadTicketByHash(ctx, hex.EncodeToString(sum[:])) + if err != nil || stored.ConsumedAt == nil { + t.Fatalf("ticket = %#v err=%v", stored, err) + } +} + +func TestSaveVideoArchivesProviderResultWithoutUploadTicket(t *testing.T) { + database, objects, tickets, cleanup := openUploadTestDeps(t) + defer cleanup() + ctx := context.Background() + binding := &bindingTicketRepository{MediaUploadTicketRepository: tickets} + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), relational.NewMediaJobRepository(database), binding, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x02}, 64)...) + asset, err := service.SaveVideo(ctx, "video_job_direct", "video/mp4", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if asset.ID == "" || asset.Kind != "video" || asset.MIMEType != "video/mp4" || asset.SizeBytes != int64(len(payload)) { + t.Fatalf("asset = %#v", asset) + } + stored, body, err := service.OpenVideo(ctx, asset.ID) + if err != nil { + t.Fatal(err) + } + defer body.Close() + raw, err := io.ReadAll(body) + if err != nil || !bytes.Equal(raw, payload) || stored.SHA256 == "" { + t.Fatalf("stored = %#v size=%d err=%v", stored, len(raw), err) + } + if binding.jobID != "video_job_direct" || binding.assetID != asset.ID { + t.Fatalf("binding job=%q asset=%q", binding.jobID, binding.assetID) + } +} + +type bindingTicketRepository struct { + repository.MediaUploadTicketRepository + jobID string + assetID string +} + +func (r *bindingTicketRepository) BindJobResultAsset(_ context.Context, jobID, assetID string) error { + r.jobID, r.assetID = jobID, assetID + return nil +} + +func TestReceiveVideoUploadRejectsExpiredAndOversize(t *testing.T) { + service, tickets, cleanup := newUploadTestService(t) + defer cleanup() + ctx := context.Background() + uploadURL, _, err := service.IssueVideoUpload(ctx, "video_job_expire") + if err != nil { + t.Fatal(err) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + sum := sha256.Sum256([]byte(token)) + hash := hex.EncodeToString(sum[:]) + // Force expire by rewriting ticket row through consume path simulation: delete and recreate expired. + existing, err := tickets.GetUploadTicketByHash(ctx, hash) + if err != nil { + t.Fatal(err) + } + if _, err := tickets.DeleteExpiredUploadTickets(ctx, time.Now().UTC().Add(3*time.Hour), 10); err != nil { + t.Fatal(err) + } + existing.ExpiresAt = time.Now().UTC().Add(-time.Minute) + existing.ConsumedAt = nil + if err := tickets.CreateUploadTicket(ctx, existing); err != nil { + t.Fatal(err) + } + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p'}, bytes.Repeat([]byte{1}, 32)...) + if _, err := service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)); err == nil { + t.Fatal("expected expired rejection") + } +} + +func TestReceiveVideoUploadConcurrentOnlyOneSucceeds(t *testing.T) { + service, _, cleanup := newUploadTestService(t) + defer cleanup() + ctx := context.Background() + uploadURL, _, err := service.IssueVideoUpload(ctx, "video_job_race") + if err != nil { + t.Fatal(err) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'm', 'p', '4', '2'}, bytes.Repeat([]byte{2}, 128)...) + + var wg sync.WaitGroup + results := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + _, putErr := service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + results <- putErr + }() + } + wg.Wait() + close(results) + success, fail := 0, 0 + for err := range results { + if err == nil { + success++ + } else { + fail++ + } + } + if success != 1 || fail != 7 { + t.Fatalf("success=%d fail=%d", success, fail) + } +} + +func TestReceiveVideoUploadRejectsDeclaredMIMEMismatch(t *testing.T) { + service, _, cleanup := newUploadTestService(t) + defer cleanup() + ctx := context.Background() + uploadURL, _, err := service.IssueVideoUpload(ctx, "video_job_mime_declared") + if err != nil { + t.Fatal(err) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x01}, 32)...) + _, err = service.ReceiveVideoUpload(ctx, token, "video/webm", bytes.NewReader(payload)) + if err == nil || !errors.Is(err, ErrInvalidVideoUpload) { + t.Fatalf("declared mismatch err = %v", err) + } + if !strings.Contains(err.Error(), "Content-Type") { + t.Fatalf("err = %v", err) + } +} + +func TestReceiveVideoUploadRejectsSniffedNonMP4UnderMP4Ticket(t *testing.T) { + service, _, cleanup := newUploadTestService(t) + defer cleanup() + ctx := context.Background() + uploadURL, _, err := service.IssueVideoUpload(ctx, "video_job_mime_sniff") + if err != nil { + t.Fatal(err) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + // WebM EBML header — 嗅探为 video/webm,与 MP4 票据冲突。 + webm := append([]byte{0x1A, 0x45, 0xDF, 0xA3}, bytes.Repeat([]byte{0x02}, 64)...) + _, err = service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(webm)) + if err == nil || !errors.Is(err, ErrInvalidVideoUpload) { + t.Fatalf("webm sniff err = %v", err) + } + // QuickTime 声明在 MP4 票据下直接拒绝(无需写入大 body)。 + uploadURL2, _, err := service.IssueVideoUpload(ctx, "video_job_mime_qt") + if err != nil { + t.Fatal(err) + } + token2 := strings.TrimPrefix(uploadURL2, "https://api.example/v1/media/uploads/") + _, err = service.ReceiveVideoUpload(ctx, token2, "video/quicktime", bytes.NewReader(webm)) + if err == nil || !errors.Is(err, ErrInvalidVideoUpload) { + t.Fatalf("quicktime declared err = %v", err) + } +} + +func TestReceiveVideoUploadTooLargeMapsToTooLargeError(t *testing.T) { + database, objects, tickets, cleanup := openUploadTestDeps(t) + defer cleanup() + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + tickets, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + ctx := context.Background() + // 小上限票据:避免分配 256MiB。 + tokenBytes := make([]byte, 32) + for i := range tokenBytes { + tokenBytes[i] = byte(i + 1) + } + token := hex.EncodeToString(tokenBytes) + sum := sha256.Sum256([]byte(token)) + now := time.Now().UTC() + if err := tickets.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hex.EncodeToString(sum[:]), AssetID: "vid_small_limit_000001", JobID: "job_small", + MaxBytes: 40, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + // ftyp 头 + 填充使体积 > 40。 + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x09}, 64)...) + _, err := service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + if err == nil || !errors.Is(err, ErrVideoUploadTooLarge) || !errors.Is(err, ErrInvalidVideoUpload) { + t.Fatalf("ticket max err = %v", err) + } + + // MaxBytesReader 路径:模拟中间件截断。 + token2Bytes := make([]byte, 32) + for i := range token2Bytes { + token2Bytes[i] = byte(i + 40) + } + token2 := hex.EncodeToString(token2Bytes) + sum2 := sha256.Sum256([]byte(token2)) + if err := tickets.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hex.EncodeToString(sum2[:]), AssetID: "vid_small_limit_000002", JobID: "job_maxbytes", + MaxBytes: 1 << 20, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + limited := http.MaxBytesReader(recorder, io.NopCloser(bytes.NewReader(payload)), 20) + _, err = service.ReceiveVideoUpload(ctx, token2, "video/mp4", limited) + if err == nil || !errors.Is(err, ErrVideoUploadTooLarge) { + t.Fatalf("MaxBytesReader err = %v", err) + } +} + +func TestIssueVideoUploadRequiresHTTPSPublicBase(t *testing.T) { + database, objects, tickets, cleanup := openUploadTestDeps(t) + defer cleanup() + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + tickets, objects, nil, + Config{PublicBaseURL: "http://127.0.0.1:8000", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + if _, _, err := service.IssueVideoUpload(context.Background(), "job"); err == nil { + t.Fatal("expected public base error") + } +} + +// bindFailTicketRepo 在 BindJobResultAsset 注入非 NotFound 失败,用于签发失败闭环。 +type bindFailTicketRepo struct { + repository.MediaUploadTicketRepository + bindErr error + deleteErr error + lastHash string + deleteCalls atomic.Int32 +} + +func (r *bindFailTicketRepo) CreateUploadTicket(ctx context.Context, ticket repository.MediaUploadTicket) error { + r.lastHash = ticket.TokenHash + return r.MediaUploadTicketRepository.CreateUploadTicket(ctx, ticket) +} + +func (r *bindFailTicketRepo) BindJobResultAsset(ctx context.Context, jobID, assetID string) error { + if r.bindErr != nil { + return r.bindErr + } + return r.MediaUploadTicketRepository.BindJobResultAsset(ctx, jobID, assetID) +} + +func (r *bindFailTicketRepo) DeleteUploadTicketByHash(ctx context.Context, tokenHash string) error { + r.deleteCalls.Add(1) + if r.deleteErr != nil { + return r.deleteErr + } + return r.MediaUploadTicketRepository.DeleteUploadTicketByHash(ctx, tokenHash) +} + +func TestIssueVideoUploadBindFailureStopsIssuance(t *testing.T) { + database, objects, tickets, cleanup := openUploadTestDeps(t) + defer cleanup() + ctx := context.Background() + injected := errors.New("injected bind database failure") + failRepo := &bindFailTicketRepo{MediaUploadTicketRepository: tickets, bindErr: injected} + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + failRepo, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + uploadURL, assetID, err := service.IssueVideoUpload(ctx, "video_job_bind_fail") + if err == nil { + t.Fatal("expected bind failure to stop issuance") + } + if !errors.Is(err, injected) { + t.Fatalf("err = %v", err) + } + if uploadURL != "" || assetID != "" { + t.Fatalf("must not return upload URL or asset on bind failure: url=%q asset=%q", uploadURL, assetID) + } + msg := err.Error() + if strings.Contains(msg, "/v1/media/uploads/") || strings.Contains(msg, "https://") { + t.Fatalf("error must not contain upload URL: %q", msg) + } + if failRepo.lastHash == "" { + t.Fatal("expected ticket create to record token hash") + } + if failRepo.deleteCalls.Load() != 1 { + t.Fatalf("delete compensation calls = %d, want 1", failRepo.deleteCalls.Load()) + } + // 失败票据必须被精确删除,不得残留到 TTL。 + if _, getErr := tickets.GetUploadTicketByHash(ctx, failRepo.lastHash); !errors.Is(getErr, repository.ErrNotFound) { + t.Fatalf("failed ticket should be compensated away: %v", getErr) + } + + // 补偿删除失败时须保留 bind 与 delete 两因,且仍不暴露 token/URL。 + deleteInjected := errors.New("injected delete compensation failure") + failBoth := &bindFailTicketRepo{ + MediaUploadTicketRepository: tickets, + bindErr: injected, + deleteErr: deleteInjected, + } + serviceBoth := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + failBoth, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + _, _, err = serviceBoth.IssueVideoUpload(ctx, "video_job_bind_delete_fail") + if err == nil { + t.Fatal("expected combined bind+delete failure") + } + if !errors.Is(err, injected) || !errors.Is(err, deleteInjected) { + t.Fatalf("combined err must retain both causes: %v", err) + } + if strings.Contains(err.Error(), "/v1/media/uploads/") || strings.Contains(err.Error(), "https://") { + t.Fatalf("combined error must not contain upload URL: %q", err.Error()) + } + + // ErrNotFound 仍允许占位/测试路径签发,且不得触发补偿删除。 + notFoundRepo := &bindFailTicketRepo{MediaUploadTicketRepository: tickets, bindErr: repository.ErrNotFound} + serviceOK := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + notFoundRepo, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + urlOK, assetOK, err := serviceOK.IssueVideoUpload(ctx, "video_job_bind_notfound") + if err != nil || urlOK == "" || assetOK == "" { + t.Fatalf("ErrNotFound bind should still issue: url=%q asset=%q err=%v", urlOK, assetOK, err) + } + if notFoundRepo.deleteCalls.Load() != 0 { + t.Fatalf("ErrNotFound path must not delete ticket: deletes=%d", notFoundRepo.deleteCalls.Load()) + } + if _, getErr := tickets.GetUploadTicketByHash(ctx, notFoundRepo.lastHash); getErr != nil { + t.Fatalf("placeholder ticket must remain after ErrNotFound bind: %v", getErr) + } +} + +// getFailAssetRepo 在 GetMediaAsset 注入仓储故障。 +type getFailAssetRepo struct { + repository.MediaAssetRepository + getErr error + getCalls atomic.Int32 +} + +func (r *getFailAssetRepo) GetMediaAsset(ctx context.Context, id string) (mediadomain.Asset, error) { + r.getCalls.Add(1) + if r.getErr != nil { + return mediadomain.Asset{}, r.getErr + } + return r.MediaAssetRepository.GetMediaAsset(ctx, id) +} + +// countingObjectStore 统计对象写入口,验证预检失败时无写入。 +type countingObjectStore struct { + inner repository.MediaObjectStorage + beginCalls atomic.Int32 + commitCalls atomic.Int32 +} + +func (s *countingObjectStore) SaveImage(ctx context.Context, id, mimeType string, data []byte) (string, error) { + return s.inner.SaveImage(ctx, id, mimeType, data) +} +func (s *countingObjectStore) SaveVideo(ctx context.Context, id, mimeType string, data []byte) (string, error) { + return s.inner.SaveVideo(ctx, id, mimeType, data) +} +func (s *countingObjectStore) BeginVideoUpload(ctx context.Context, id, mimeType string) (string, string, error) { + s.beginCalls.Add(1) + return s.inner.BeginVideoUpload(ctx, id, mimeType) +} +func (s *countingObjectStore) CommitVideoUpload(ctx context.Context, tempPath, storageKey string) error { + s.commitCalls.Add(1) + return s.inner.CommitVideoUpload(ctx, tempPath, storageKey) +} +func (s *countingObjectStore) AbortVideoUpload(ctx context.Context, tempPath string) error { + return s.inner.AbortVideoUpload(ctx, tempPath) +} +func (s *countingObjectStore) Open(ctx context.Context, storageKey string) (io.ReadCloser, error) { + return s.inner.Open(ctx, storageKey) +} +func (s *countingObjectStore) Delete(ctx context.Context, storageKey string) error { + return s.inner.Delete(ctx, storageKey) +} + +// consumeCountingTicketRepo 统计消费调用,验证预检失败时未消费。 +type consumeCountingTicketRepo struct { + repository.MediaUploadTicketRepository + consumeCalls atomic.Int32 +} + +func (r *consumeCountingTicketRepo) ConsumeUploadTicket(ctx context.Context, tokenHash string, now time.Time) (repository.MediaUploadTicket, bool, error) { + r.consumeCalls.Add(1) + return r.MediaUploadTicketRepository.ConsumeUploadTicket(ctx, tokenHash, now) +} + +func TestReceiveVideoUploadAssetLookupFailureNoWriteOrConsume(t *testing.T) { + database, objects, tickets, cleanup := openUploadTestDeps(t) + defer cleanup() + injected := errors.New("injected asset repository failure") + assets := &getFailAssetRepo{ + MediaAssetRepository: relational.NewMediaAssetRepository(database), + getErr: injected, + } + store := &countingObjectStore{inner: objects} + ticketWrap := &consumeCountingTicketRepo{MediaUploadTicketRepository: tickets} + service := NewServiceWithTickets( + assets, relational.NewMediaJobRepository(database), ticketWrap, store, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + ctx := context.Background() + // 先用健康服务签发票据,再切换到故障资产仓储接收。 + issueService := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + tickets, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + uploadURL, _, err := issueService.IssueVideoUpload(ctx, "video_job_asset_get_fail") + if err != nil { + t.Fatal(err) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x05}, 32)...) + _, err = service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + if err == nil || !errors.Is(err, injected) { + t.Fatalf("receive err = %v", err) + } + if assets.getCalls.Load() < 1 { + t.Fatal("expected GetMediaAsset preflight call") + } + if store.beginCalls.Load() != 0 { + t.Fatalf("object write must not start on asset lookup failure: begin=%d", store.beginCalls.Load()) + } + if store.commitCalls.Load() != 0 { + t.Fatalf("commit must not run: %d", store.commitCalls.Load()) + } + if ticketWrap.consumeCalls.Load() != 0 { + t.Fatalf("ticket must not be consumed on asset lookup failure: %d", ticketWrap.consumeCalls.Load()) + } + sum := sha256.Sum256([]byte(token)) + ticket, getErr := tickets.GetUploadTicketByHash(ctx, hex.EncodeToString(sum[:])) + if getErr != nil { + t.Fatal(getErr) + } + if ticket.ConsumedAt != nil { + t.Fatalf("ticket consumed_at should remain nil: %#v", ticket) + } +} + +// failingCommitStore 首次 CommitVideoUpload 失败,后续委托真实存储,用于验证票据释放与重试。 +type failingCommitStore struct { + inner repository.MediaObjectStorage + failRemaining atomic.Int32 + commitCalls atomic.Int32 +} + +func (s *failingCommitStore) SaveImage(ctx context.Context, id, mimeType string, data []byte) (string, error) { + return s.inner.SaveImage(ctx, id, mimeType, data) +} +func (s *failingCommitStore) SaveVideo(ctx context.Context, id, mimeType string, data []byte) (string, error) { + return s.inner.SaveVideo(ctx, id, mimeType, data) +} +func (s *failingCommitStore) BeginVideoUpload(ctx context.Context, id, mimeType string) (string, string, error) { + return s.inner.BeginVideoUpload(ctx, id, mimeType) +} +func (s *failingCommitStore) CommitVideoUpload(ctx context.Context, tempPath, storageKey string) error { + s.commitCalls.Add(1) + if s.failRemaining.Add(-1) >= 0 { + return errors.New("injected commit failure") + } + return s.inner.CommitVideoUpload(ctx, tempPath, storageKey) +} +func (s *failingCommitStore) AbortVideoUpload(ctx context.Context, tempPath string) error { + return s.inner.AbortVideoUpload(ctx, tempPath) +} +func (s *failingCommitStore) Open(ctx context.Context, storageKey string) (io.ReadCloser, error) { + return s.inner.Open(ctx, storageKey) +} +func (s *failingCommitStore) Delete(ctx context.Context, storageKey string) error { + return s.inner.Delete(ctx, storageKey) +} + +func TestReceiveVideoUploadCommitFailureReleasesTicketForRetry(t *testing.T) { + database, objects, tickets, cleanup := openUploadTestDeps(t) + defer cleanup() + store := &failingCommitStore{inner: objects} + store.failRemaining.Store(1) + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + tickets, store, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + ctx := context.Background() + uploadURL, assetID, err := service.IssueVideoUpload(ctx, "video_job_commit_fail") + if err != nil { + t.Fatal(err) + } + token := strings.TrimPrefix(uploadURL, "https://api.example/v1/media/uploads/") + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x03}, 48)...) + + _, err = service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + if err == nil || !strings.Contains(err.Error(), "injected commit failure") { + t.Fatalf("first receive err = %v", err) + } + // 票据必须已释放:未消费、未产生资产。 + sum := sha256.Sum256([]byte(token)) + ticket, err := tickets.GetUploadTicketByHash(ctx, hex.EncodeToString(sum[:])) + if err != nil { + t.Fatal(err) + } + if ticket.ConsumedAt != nil { + t.Fatalf("ticket should be released after commit failure, consumed_at=%v", ticket.ConsumedAt) + } + if _, getErr := relational.NewMediaAssetRepository(database).GetMediaAsset(ctx, assetID); getErr == nil { + t.Fatal("asset must not exist after commit failure") + } + + // 同 token 重试应成功(commit 第二次放行)。 + asset, err := service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("retry receive: %v", err) + } + if asset.ID != assetID || asset.Kind != "video" || asset.SizeBytes != int64(len(payload)) { + t.Fatalf("asset = %#v", asset) + } + if store.commitCalls.Load() < 2 { + t.Fatalf("commit calls = %d, want >= 2", store.commitCalls.Load()) + } + // 重放仍应拒绝。 + if _, err := service.ReceiveVideoUpload(ctx, token, "video/mp4", bytes.NewReader(payload)); err == nil { + t.Fatal("expected consumed rejection after successful upload") + } +} + +func newUploadTestService(t *testing.T) (*Service, *relational.MediaUploadTicketRepository, func()) { + t.Helper() + database, objects, tickets, cleanup := openUploadTestDeps(t) + service := NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + tickets, objects, nil, + Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + return service, tickets, cleanup +} + +func openUploadTestDeps(t *testing.T) (*relational.Database, *media.LocalStore, *relational.MediaUploadTicketRepository, func()) { + t.Helper() + dir := t.TempDir() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := media.NewLocalStore(filepath.Join(dir, "objects")) + if err != nil { + t.Fatal(err) + } + tickets := relational.NewMediaUploadTicketRepository(database) + return database, objects, tickets, func() { _ = database.Close() } +} + +// Silence unused imports retained for sniff/edge coverage helpers. +var ( + _ = http.StatusOK + _ = io.EOF + _ = os.ErrNotExist +) diff --git a/backend/internal/application/model/service.go b/backend/internal/application/model/service.go new file mode 100644 index 0000000000000000000000000000000000000000..2e5394f728eb17a97c8da8e9295eea52addbe29b --- /dev/null +++ b/backend/internal/application/model/service.go @@ -0,0 +1,571 @@ +package model + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" + "github.com/chenyme/grok2api/backend/internal/repository" + "golang.org/x/sync/singleflight" +) + +const defaultModelSyncWorkers = 25 +const syncFailurePersistTimeout = 5 * time.Second + +var ( + ErrInvalidFilter = errors.New("模型筛选条件无效") + ErrInvalidInput = errors.New("模型参数无效") + ErrNotFound = errors.New("模型不存在") + ErrConflict = errors.New("模型名称冲突") +) + +type UpdateInput struct { + PublicID *string + Enabled *bool + AccountIDs *[]uint64 +} + +type CreateInput struct { + PublicID string + Provider account.Provider + UpstreamModel string + Capability modeldomain.Capability + Enabled bool + AccountIDs []uint64 +} + +type AccountOption struct { + ID uint64 + Name string +} + +type ListFilter struct { + Provider string + Providers []string + Tiers []string + Status string + ActiveScope bool + Sort repository.SortQuery +} + +// Service 负责上游模型发现、内部来源路由与对外模型名称维护。 +type Service struct { + models repository.ModelRepository + accounts repository.AccountRepository + account *accountapp.Service + providers *provider.Registry + bulkPool *batch.Pool + logger *slog.Logger + syncAll singleflight.Group +} + +func NewService(models repository.ModelRepository, accounts repository.AccountRepository, accountService *accountapp.Service, providers *provider.Registry) *Service { + return &Service{models: models, accounts: accounts, account: accountService, providers: providers, bulkPool: batch.NewPool(defaultModelSyncWorkers), logger: slog.Default()} +} + +func (s *Service) SetBulkPool(pool *batch.Pool) { + if pool != nil { + s.bulkPool = pool + } +} + +func (s *Service) SetLogger(logger *slog.Logger) { + if logger != nil { + s.logger = logger + } +} + +func (s *Service) List(ctx context.Context, page, pageSize int, search string, filter ListFilter) ([]modeldomain.Route, int64, error) { + page, pageSize = normalizePage(page, pageSize) + if !validProviderFilter(filter.Provider) || !validProviderFilters(filter.Providers) || !validTierFilters(filter.Tiers) || !validModelFilter(filter.Status, "", "enabled", "disabled") || !repository.IsValidSort(filter.Sort, "publicId", "upstreamModel", "status", "provider", "accountSupport", "lastSyncedAt") { + return nil, 0, ErrInvalidFilter + } + var enabled *bool + if filter.Status != "" { + value := filter.Status == "enabled" + enabled = &value + } + return s.models.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Offset: (page - 1) * pageSize, Limit: pageSize, Search: search, Sort: filter.Sort}, Filter: repository.ModelListFilter{Provider: filter.Provider, Providers: filter.Providers, Tiers: filter.Tiers, Enabled: enabled, ActiveScope: filter.ActiveScope}}) +} + +func validProviderFilter(value string) bool { + return value == "" || account.Provider(value).IsValid() +} + +func validProviderFilters(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if !account.Provider(value).IsValid() { + return false + } + if _, exists := seen[value]; exists { + return false + } + seen[value] = struct{}{} + } + return true +} + +func validTierFilters(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if value != "free" && value != "super" { + return false + } + if _, exists := seen[value]; exists { + return false + } + seen[value] = struct{}{} + } + return true +} + +func validModelFilter(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func (s *Service) ListEnabled(ctx context.Context) ([]modeldomain.Route, error) { + return s.models.ListEnabled(ctx) +} + +func (s *Service) ListEnabledForClientKey(ctx context.Context, key clientkeydomain.Key) ([]modeldomain.Route, error) { + scope, valid := clientkeydomain.NormalizeAccountScope(clientkeydomain.AccountScope{Providers: key.ProviderScope, Tiers: key.TierScope}) + if !valid { + return nil, ErrInvalidFilter + } + if !scope.IsRestricted() { + return s.models.ListEnabled(ctx) + } + providers := scope.Providers.Values() + if len(providers) == 1 && providers[0] == "all" { + providers = nil + } + tiers := scope.Tiers.Values() + if len(tiers) == 1 && tiers[0] == "all" { + tiers = nil + } + return s.models.ListEnabledForScope(ctx, repository.ModelListFilter{Providers: providers, Tiers: tiers}) +} + +func (s *Service) Get(ctx context.Context, id uint64) (modeldomain.Route, error) { + return s.models.Get(ctx, id) +} + +// GetByPublicID 每次读取共享主数据库,保证多实例下的路由禁用立即生效。 +func (s *Service) GetByPublicID(ctx context.Context, publicID string) (modeldomain.Route, error) { + return s.models.GetByPublicID(ctx, publicID) +} + +func (s *Service) GetByPublicIDCandidates(ctx context.Context, publicID string) ([]modeldomain.Route, error) { + return s.models.GetByPublicIDCandidates(ctx, publicID) +} + +func (s *Service) GetByProviderUpstream(ctx context.Context, providerValue account.Provider, upstreamModel string) (modeldomain.Route, error) { + return s.models.GetByProviderUpstream(ctx, providerValue, upstreamModel) +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (modeldomain.Route, error) { + publicID, validPublicID := modeldomain.NormalizePublicID(input.Provider, input.PublicID) + if !validPublicID { + return modeldomain.Route{}, invalidInput("publicId 不能为空、不能携带其他 Provider 前缀,且长度不能超过 255 个字符") + } + upstreamModel, validUpstreamModel := modeldomain.NormalizeUpstreamModel(input.Provider, input.UpstreamModel) + if !validUpstreamModel { + return modeldomain.Route{}, invalidInput("upstreamModel 必须属于所选 Provider 且长度为 1-255 个字符") + } + definition, err := s.validateProviderCapability(input.Provider, input.Capability) + if err != nil { + return modeldomain.Route{}, err + } + if definition.ModelCatalog == provider.ModelCatalogStatic && s.providers.QuotaMode(input.Provider, upstreamModel) == "" { + return modeldomain.Route{}, invalidInput(fmt.Sprintf("%s 仅支持内置模型目录中的上游模型", definition.ModelNamespace)) + } + accountIDs, err := s.validateBoundAccounts(ctx, input.Provider, input.AccountIDs) + if err != nil { + return modeldomain.Route{}, err + } + value := modeldomain.Route{ + PublicID: publicID, Provider: input.Provider, UpstreamModel: upstreamModel, + Capability: input.Capability, Origin: modeldomain.OriginManual, Enabled: input.Enabled, + } + created, err := s.models.Create(ctx, value, accountIDs) + return created, mapRepositoryError(err) +} + +func (s *Service) Update(ctx context.Context, id uint64, input UpdateInput) (modeldomain.Route, error) { + value, err := s.models.Get(ctx, id) + if err != nil { + return modeldomain.Route{}, mapRepositoryError(err) + } + if input.PublicID != nil { + publicID, ok := modeldomain.NormalizePublicID(value.Provider, *input.PublicID) + if !ok { + return modeldomain.Route{}, invalidInput("publicId 不能为空、不能携带其他 Provider 前缀,且长度不能超过 255 个字符") + } + value.PublicID = publicID + } + if input.Enabled != nil { + value.Enabled = *input.Enabled + } + var accountIDs *[]uint64 + if input.AccountIDs != nil { + validated, validateErr := s.validateBoundAccounts(ctx, value.Provider, *input.AccountIDs) + if validateErr != nil { + return modeldomain.Route{}, validateErr + } + accountIDs = &validated + } + updated, err := s.models.Update(ctx, value, accountIDs) + return updated, mapRepositoryError(err) +} + +func (s *Service) Delete(ctx context.Context, id uint64) error { + if id == 0 { + return invalidInput("模型 ID 无效") + } + return mapRepositoryError(s.models.Delete(ctx, id)) +} + +func (s *Service) BatchDelete(ctx context.Context, ids []uint64) (int64, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, err + } + return s.models.DeleteMany(ctx, values) +} + +func (s *Service) ListBindableAccounts(ctx context.Context, providerValue account.Provider) ([]AccountOption, error) { + if !providerValue.IsValid() { + return nil, invalidInput("账号来源无效") + } + values, _, err := s.accounts.List(ctx, repository.AccountListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 1000}, + Filter: repository.AccountListFilter{Provider: string(providerValue)}, + }) + if err != nil { + return nil, err + } + result := make([]AccountOption, 0, len(values)) + for _, value := range values { + result = append(result, AccountOption{ID: value.ID, Name: value.Name}) + } + return result, nil +} + +func (s *Service) validateProviderCapability(providerValue account.Provider, capability modeldomain.Capability) (provider.Definition, error) { + if !providerValue.IsValid() || s.providers == nil { + return provider.Definition{}, invalidInput("provider 无效") + } + definition, ok := s.providers.Definition(providerValue) + if !ok { + return provider.Definition{}, invalidInput("provider 未注册能力定义") + } + if !definition.SupportsModelCapability(capability) { + return provider.Definition{}, invalidInput(fmt.Sprintf("%s 不支持 %s 能力", definition.ModelNamespace, capability)) + } + return definition, nil +} + +func (s *Service) validateBoundAccounts(ctx context.Context, providerValue account.Provider, ids []uint64) ([]uint64, error) { + if len(ids) > 1000 { + return nil, invalidInput("单个模型最多绑定 1000 个账号") + } + unique := make(map[uint64]struct{}, len(ids)) + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + return nil, invalidInput("绑定账号 ID 无效") + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + result = append(result, id) + } + if len(result) == 0 { + return result, nil + } + values, _, err := s.accounts.List(ctx, repository.AccountListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 1000}, + Filter: repository.AccountListFilter{Provider: string(providerValue)}, + }) + if err != nil { + return nil, err + } + available := make(map[uint64]bool, len(values)) + for _, value := range values { + available[value.ID] = true + } + for _, id := range result { + if !available[id] { + return nil, invalidInput(fmt.Sprintf("账号 %d 不存在或与模型来源不匹配", id)) + } + } + return result, nil +} + +// BatchSetEnabled 批量更新模型路由启停状态。 +func (s *Service) BatchSetEnabled(ctx context.Context, ids []uint64, enabled bool) (int64, error) { + values, err := normalizeBatchIDs(ids) + if err != nil { + return 0, err + } + updated, err := s.models.UpdateManyEnabled(ctx, values, enabled) + return updated, err +} + +// Sync 从全部启用账号同步模型能力,并按 Provider 幂等更新公开路由表。 +func (s *Service) Sync(ctx context.Context) (int, error) { + result := s.syncAll.DoChan("all", func() (any, error) { + return s.syncAllAccounts(ctx) + }) + select { + case <-ctx.Done(): + return 0, ctx.Err() + case value := <-result: + if value.Err != nil { + return 0, value.Err + } + return value.Val.(int), nil + } +} + +func (s *Service) syncAllAccounts(ctx context.Context) (int, error) { + if s.providers == nil { + return 0, fmt.Errorf("Provider 注册表未初始化") + } + providerValues := s.providers.Providers() + if len(providerValues) == 0 { + return 0, fmt.Errorf("没有已注册的 Provider") + } + credentials := make([]account.Credential, 0) + for _, providerValue := range providerValues { + values, err := s.accounts.ListEnabled(ctx, providerValue) + if err != nil { + return 0, err + } + credentials = append(credentials, values...) + } + if len(credentials) == 0 { + return 0, fmt.Errorf("没有可用于模型同步的账号") + } + results, summary, runErr := batch.Map(ctx, credentials, batch.Options{Workers: s.bulkPool.Limit(), Pool: s.bulkPool}, func(workCtx context.Context, value account.Credential) ([]string, error) { + adapter, ok := s.providers.Models(value.Provider) + if !ok { + return nil, fmt.Errorf("Provider %s 未注册模型同步能力", value.Provider) + } + return s.syncAccountCapabilities(workCtx, value, adapter) + }) + pool := s.bulkPool.Snapshot() + s.logger.Info("model_bulk_sync_completed", "total", summary.Total, "submitted", summary.Submitted, "succeeded", summary.Succeeded, "failed", summary.Failed, "panicked", summary.Panicked, "duration_ms", summary.Duration.Milliseconds(), "canceled", summary.Canceled, "pool_limit", pool.Limit, "pool_active", pool.Active, "pool_queued", pool.Queued, "pool_peak", pool.Peak, "error", runErr) + if runErr != nil { + return 0, runErr + } + + uniqueModels := make(map[account.Provider]map[string]struct{}, len(providerValues)) + succeeded := 0 + var lastErr error + for index, result := range results { + if result.Err != nil { + var panicErr *batch.PanicError + if errors.As(result.Err, &panicErr) { + s.logger.Error("model_sync_panicked", "account_id", credentials[index].ID, "error", panicErr, "stack", string(panicErr.Stack)) + } + lastErr = result.Err + continue + } + succeeded++ + providerModels := uniqueModels[credentials[index].Provider] + if providerModels == nil { + providerModels = make(map[string]struct{}) + uniqueModels[credentials[index].Provider] = providerModels + } + for _, value := range result.Value { + value = strings.TrimSpace(value) + if value != "" { + providerModels[value] = struct{}{} + } + } + } + if succeeded == 0 { + if lastErr != nil { + return 0, lastErr + } + return 0, fmt.Errorf("没有账号成功同步模型") + } + syncedModels := 0 + for _, providerValue := range providerValues { + providerModels := uniqueModels[providerValue] + if len(providerModels) == 0 { + continue + } + models := make([]string, 0, len(providerModels)) + for value := range providerModels { + models = append(models, value) + } + if err := s.models.UpsertDiscovered(ctx, providerValue, models); err != nil { + return 0, err + } + syncedModels += len(models) + } + return syncedModels, nil +} + +// HasSuccessfulAccountSync 判断账号是否已有成功模型能力快照,不触发上游请求。 +func (s *Service) HasSuccessfulAccountSync(ctx context.Context, accountID uint64) (bool, error) { + return s.models.HasSuccessfulAccountSync(ctx, accountID) +} + +// SyncAccount 只同步指定账号,并把该账号发现的模型合并到公开路由目录。 +func (s *Service) SyncAccount(ctx context.Context, accountID uint64) (int, error) { + credential, err := s.accounts.Get(ctx, accountID) + if err != nil { + return 0, err + } + adapter, ok := s.providers.Models(credential.Provider) + if !ok { + return 0, fmt.Errorf("Provider %s 未注册", credential.Provider) + } + models, err := s.syncAccountCapabilities(ctx, credential, adapter) + if err != nil { + return 0, err + } + if err := s.models.UpsertDiscovered(ctx, credential.Provider, models); err != nil { + return 0, err + } + return len(models), nil +} + +// SyncAccounts 使用共享同步池追赶指定账号的模型能力,不扩大为全量同步。 +func (s *Service) SyncAccounts(ctx context.Context, accountIDs []uint64) (int, int, error) { + ids, err := normalizeBatchIDs(accountIDs) + if err != nil { + return 0, 0, err + } + results, summary, runErr := batch.Map(ctx, ids, batch.Options{Workers: s.bulkPool.Limit(), Pool: s.bulkPool}, func(workCtx context.Context, id uint64) (int, error) { + return s.SyncAccount(workCtx, id) + }) + for index, result := range results { + if result.Err == nil { + continue + } + var panicErr *batch.PanicError + if errors.As(result.Err, &panicErr) { + s.logger.Error("model_startup_sync_panicked", "account_id", ids[index], "error", panicErr, "stack", string(panicErr.Stack)) + } + } + s.logger.Info("model_startup_sync_completed", "total", summary.Total, "succeeded", summary.Succeeded, "failed", summary.Failed, "canceled", summary.Canceled, "error", runErr) + return summary.Succeeded, summary.Failed, runErr +} + +func (s *Service) syncAccountCapabilities(ctx context.Context, value account.Credential, adapter provider.ModelCatalogAdapter) ([]string, error) { + attemptedAt := time.Now().UTC() + credential, err := s.account.EnsureCredential(ctx, value, false) + if err != nil { + s.markCapabilitySyncFailed(value.ID, attemptedAt, err) + return nil, err + } + values, err := adapter.ListModels(ctx, credential) + if err != nil { + s.markCapabilitySyncFailed(credential.ID, attemptedAt, err) + return nil, err + } + models := normalizeDiscoveredModels(values) + if normalizer, ok := adapter.(provider.AccountModelCapabilityNormalizer); ok { + var billing *account.Billing + snapshot, billingErr := s.accounts.GetBilling(ctx, credential.ID) + if billingErr == nil { + billing = &snapshot + } else if !errors.Is(billingErr, repository.ErrNotFound) { + // Billing 不存在按 Unknown 处理;其他仓储错误保留失败语义。 + s.markCapabilitySyncFailed(credential.ID, attemptedAt, billingErr) + return nil, billingErr + } + models = normalizeDiscoveredModels(normalizer.NormalizeAccountModelCapabilities(models, billing, credential)) + } + if err := s.models.ReplaceAccountCapabilities(ctx, credential.ID, models, attemptedAt); err != nil { + s.markCapabilitySyncFailed(credential.ID, attemptedAt, err) + return nil, err + } + return models, nil +} + +func normalizeDiscoveredModels(values []string) []string { + unique := make(map[string]struct{}, len(values)) + models := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := unique[value]; exists { + continue + } + unique[value] = struct{}{} + models = append(models, value) + } + return models +} + +// markCapabilitySyncFailed 使用独立短超时保存失败状态,避免请求取消后丢失账号能力诊断信息。 +func (s *Service) markCapabilitySyncFailed(accountID uint64, attemptedAt time.Time, cause error) { + ctx, cancel := context.WithTimeout(context.Background(), syncFailurePersistTimeout) + defer cancel() + _ = s.models.MarkAccountCapabilitySyncFailed(ctx, accountID, attemptedAt, cause.Error()) +} + +func normalizePage(page, pageSize int) (int, int) { + return repository.NormalizePage(page, pageSize, repository.DefaultPageSize) +} + +func normalizeBatchIDs(ids []uint64) ([]uint64, error) { + if len(ids) == 0 { + return nil, invalidInput("至少选择一个模型") + } + if len(ids) > repository.MaxPageSize { + return nil, invalidInput(fmt.Sprintf("单次最多处理 %d 个模型", repository.MaxPageSize)) + } + seen := make(map[uint64]struct{}, len(ids)) + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + return nil, invalidInput("模型 ID 无效") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result, nil +} + +// invalidInput 为可安全返回给管理端的模型参数错误附加稳定语义。 +func invalidInput(message string) error { + return fmt.Errorf("%w: %s", ErrInvalidInput, message) +} + +// mapRepositoryError 将仓储错误转换为模型应用错误。 +func mapRepositoryError(err error) error { + if errors.Is(err, repository.ErrNotFound) { + return ErrNotFound + } + if errors.Is(err, repository.ErrConflict) { + return ErrConflict + } + return err +} diff --git a/backend/internal/application/model/service_test.go b/backend/internal/application/model/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d83b7de939f649334013e607704973cf16581f46 --- /dev/null +++ b/backend/internal/application/model/service_test.go @@ -0,0 +1,426 @@ +package model + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestModelProviderFilterAcceptsOnlyKnownProviders(t *testing.T) { + for _, value := range []string{"", string(account.ProviderBuild), string(account.ProviderWeb), string(account.ProviderConsole)} { + if !validProviderFilter(value) { + t.Fatalf("known provider rejected: %q", value) + } + } + if validProviderFilter("cli") { + t.Fatal("unsupported provider filter was accepted") + } +} + +func TestSyncAggregatesCapabilitiesFromAllAccounts(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-sync.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + first, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "basic", SourceKey: "basic", EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + second, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "premium", SourceKey: "premium", EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + webAccount, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, Name: "web-super", SourceKey: "web-super", EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + adapter := &modelCapabilityAdapter{models: map[uint64][]string{ + first.ID: {"grok-basic"}, + second.ID: {"grok-basic", "grok-premium"}, + }} + webAdapter := &modelCapabilityAdapter{provider: account.ProviderWeb, models: map[uint64][]string{ + webAccount.ID: {"grok-chat-fast", "grok-chat-auto"}, + }} + registry := provider.NewRegistry(adapter, webAdapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, cipher, nil) + service := NewService(modelRepo, accountRepo, accountService, registry) + + count, err := service.Sync(ctx) + if err != nil || count != 4 { + t.Fatalf("sync count = %d, err = %v", count, err) + } + if attempts := adapter.attemptCount(); attempts != 2 { + t.Fatalf("attempts = %d", attempts) + } + if attempts := webAdapter.attemptCount(); attempts != 1 { + t.Fatalf("web attempts = %d", attempts) + } + candidates, err := accountRepo.ListRoutingCandidates(ctx, account.ProviderBuild, 0, "grok-premium", "") + if err != nil { + t.Fatal(err) + } + support := make(map[uint64]bool, len(candidates)) + for _, candidate := range candidates { + if !candidate.ModelCapabilityKnown { + t.Fatalf("capability unknown for account %d", candidate.Credential.ID) + } + support[candidate.Credential.ID] = candidate.SupportsModel + } + if support[first.ID] || !support[second.ID] { + t.Fatalf("support = %#v", support) + } + webCandidates, err := accountRepo.ListRoutingCandidates(ctx, account.ProviderWeb, 0, "grok-chat-auto", "") + if err != nil { + t.Fatal(err) + } + if len(webCandidates) != 1 || !webCandidates[0].ModelCapabilityKnown || !webCandidates[0].SupportsModel { + t.Fatalf("web candidates = %#v", webCandidates) + } +} + +func TestSyncAccountNormalizesBuildVideo15ByBillingSuper(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-build-video15.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + + // Super on primary Build: upstream exposes only grok-4.5 and fallback is disabled. + superPrimary, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "super-primary", SourceKey: "super-primary", + EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + // Super with fallback: the upstream catalog includes 1.5 and other models. + superFallback, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "super-fallback", SourceKey: "super-fallback", + EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive, + BuildAPIFallback: true, + }) + if err != nil { + t.Fatal(err) + } + // Free: Billing has no paid signal, but the upstream catalog exposes 1.5. + freeAccount, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "free-fallback", SourceKey: "free-fallback", + EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive, + BuildAPIFallback: true, + }) + if err != nil { + t.Fatal(err) + } + // Unknown: no Billing snapshot and the upstream catalog exposes 1.5. + unknownAccount, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "unknown", SourceKey: "unknown", + EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + // Web must not implement capability normalization; persist its catalog unchanged. + webAccount, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierSuper, Name: "web", SourceKey: "web", + EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + if err := accountRepo.SaveBilling(ctx, account.Billing{AccountID: superPrimary.ID, MonthlyLimit: 100, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accountRepo.SaveBilling(ctx, account.Billing{AccountID: superFallback.ID, OnDemandCap: 50, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accountRepo.SaveBilling(ctx, account.Billing{AccountID: freeAccount.ID, Used: 1, PlanName: "free", SyncedAt: now}); err != nil { + t.Fatal(err) + } + + const video15 = "grok-imagine-video-1.5" + buildAdapter := &buildCapabilityNormalizerAdapter{modelCapabilityAdapter: &modelCapabilityAdapter{models: map[uint64][]string{ + superPrimary.ID: {"grok-4.5"}, + superFallback.ID: {"grok-4.5", video15, "grok-code-fast-1", video15}, + freeAccount.ID: {"grok-4.5", video15}, + unknownAccount.ID: {video15, "grok-4.5"}, + }}} + webAdapter := &modelCapabilityAdapter{provider: account.ProviderWeb, models: map[uint64][]string{ + webAccount.ID: {"grok-chat-fast", "grok-imagine-video"}, + }} + registry := provider.NewRegistry(buildAdapter, webAdapter) + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), memory.NewStickyStore(), registry, cipher, nil) + service := NewService(modelRepo, accountRepo, accountService, registry) + + for _, id := range []uint64{superPrimary.ID, superFallback.ID, freeAccount.ID, unknownAccount.ID, webAccount.ID} { + if _, err := service.SyncAccount(ctx, id); err != nil { + t.Fatalf("sync account %d: %v", id, err) + } + } + + assertSupports := func(accountID uint64, upstream string, want bool) { + t.Helper() + candidates, listErr := accountRepo.ListRoutingCandidates(ctx, account.ProviderBuild, 0, upstream, "") + if listErr != nil { + t.Fatal(listErr) + } + for _, candidate := range candidates { + if candidate.Credential.ID != accountID { + continue + } + if !candidate.ModelCapabilityKnown { + t.Fatalf("account %d capability unknown for %s", accountID, upstream) + } + if candidate.SupportsModel != want { + t.Fatalf("account %d supports %s = %v, want %v", accountID, upstream, candidate.SupportsModel, want) + } + return + } + t.Fatalf("account %d missing from candidates for %s", accountID, upstream) + } + + assertSupports(superPrimary.ID, video15, true) + assertSupports(superFallback.ID, video15, true) + assertSupports(freeAccount.ID, video15, false) + assertSupports(unknownAccount.ID, video15, false) + assertSupports(superPrimary.ID, "grok-4.5", true) + assertSupports(superFallback.ID, "grok-code-fast-1", true) + assertSupports(freeAccount.ID, "grok-4.5", true) + + // Build 1.5 routes default to the video capability. + route, err := modelRepo.GetByPublicID(ctx, "Build/"+video15) + if err != nil || route.Capability != modeldomain.CapabilityVideo { + t.Fatalf("build video route = %#v, err = %v", route, err) + } + // Web does not use Build normalization and still supports its catalog models. + webCandidates, err := accountRepo.ListRoutingCandidates(ctx, account.ProviderWeb, 0, "grok-imagine-video", "") + if err != nil || len(webCandidates) != 1 || !webCandidates[0].SupportsModel { + t.Fatalf("web candidates = %#v, err = %v", webCandidates, err) + } +} + +func TestSyncAccountRunsUpstreamDiscoveryConcurrently(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-account-sync.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + + const accountCount = 10 + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + adapter := &modelCapabilityAdapter{ + models: make(map[uint64][]string, accountCount), + entered: make(chan struct{}, accountCount), + release: make(chan struct{}), + } + accountIDs := make([]uint64, 0, accountCount) + for index := range accountCount { + value, _, createErr := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: fmt.Sprintf("account-%d", index), SourceKey: fmt.Sprintf("account-%d", index), + EncryptedAccessToken: encrypted, ExpiresAt: time.Now().Add(time.Hour), AuthStatus: account.AuthStatusActive, + }) + if createErr != nil { + t.Fatal(createErr) + } + accountIDs = append(accountIDs, value.ID) + adapter.models[value.ID] = []string{"grok-shared"} + } + registry := provider.NewRegistry(adapter) + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), memory.NewStickyStore(), registry, cipher, nil) + service := NewService(modelRepo, accountRepo, accountService, registry) + + results := make(chan error, accountCount) + for _, accountID := range accountIDs { + go func() { + _, syncErr := service.SyncAccount(ctx, accountID) + results <- syncErr + }() + } + deadline := time.NewTimer(time.Second) + for range accountCount { + select { + case <-adapter.entered: + case <-deadline.C: + close(adapter.release) + t.Fatalf("upstream discovery peak = %d, want %d", adapter.peak.Load(), accountCount) + } + } + deadline.Stop() + close(adapter.release) + for range accountCount { + if syncErr := <-results; syncErr != nil { + t.Fatal(syncErr) + } + } + if adapter.peak.Load() != accountCount { + t.Fatalf("upstream discovery peak = %d, want %d", adapter.peak.Load(), accountCount) + } +} + +type modelCapabilityAdapter struct { + provider account.Provider + mu sync.Mutex + models map[uint64][]string + attempts []uint64 + entered chan struct{} + release chan struct{} + active atomic.Int64 + peak atomic.Int64 +} + +// buildCapabilityNormalizerAdapter simulates a Build Adapter implementing optional capability normalization. +type buildCapabilityNormalizerAdapter struct { + *modelCapabilityAdapter +} + +func (a *buildCapabilityNormalizerAdapter) NormalizeAccountModelCapabilities(models []string, billing *account.Billing, credential account.Credential) []string { + // Match cli.Adapter rules: Super (paid or entitlement) ensures 1.5; otherwise remove it exactly. Ignore BuildAPIFallback. + const video15 = "grok-imagine-video-1.5" + super := account.IsBuildSuper(credential, billing) + result := make([]string, 0, len(models)+1) + seen := make(map[string]struct{}, len(models)+1) + hasVideo15 := false + for _, modelName := range models { + if modelName == "" { + continue + } + if _, exists := seen[modelName]; exists { + continue + } + if modelName == video15 { + if !super { + continue + } + hasVideo15 = true + } + seen[modelName] = struct{}{} + result = append(result, modelName) + } + if super && !hasVideo15 { + result = append(result, video15) + } + return result +} + +func (a *modelCapabilityAdapter) Provider() account.Provider { + if a.provider == "" { + return account.ProviderBuild + } + return a.provider +} +func (a *modelCapabilityAdapter) Definition() provider.Definition { + return provider.Definition{Provider: a.Provider()} +} +func (a *modelCapabilityAdapter) ListModels(ctx context.Context, credential account.Credential) ([]string, error) { + a.mu.Lock() + a.attempts = append(a.attempts, credential.ID) + models := append([]string(nil), a.models[credential.ID]...) + a.mu.Unlock() + if a.entered == nil { + return models, nil + } + current := a.active.Add(1) + defer a.active.Add(-1) + for { + peak := a.peak.Load() + if current <= peak || a.peak.CompareAndSwap(peak, current) { + break + } + } + a.entered <- struct{}{} + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-a.release: + return models, nil + } +} +func (a *modelCapabilityAdapter) attemptCount() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.attempts) +} +func (a *modelCapabilityAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{StatusCode: http.StatusOK}, nil +} +func (a *modelCapabilityAdapter) GetBilling(context.Context, account.Credential) (account.Billing, error) { + return account.Billing{}, nil +} +func (a *modelCapabilityAdapter) RefreshCredential(context.Context, account.Credential) (provider.RefreshedCredential, error) { + return provider.RefreshedCredential{}, nil +} +func (a *modelCapabilityAdapter) StartDeviceAuthorization(context.Context) (provider.DeviceAuthorization, error) { + return provider.DeviceAuthorization{}, nil +} +func (a *modelCapabilityAdapter) PollDeviceAuthorization(context.Context, string) (provider.CredentialSeed, error) { + return provider.CredentialSeed{}, nil +} +func (a *modelCapabilityAdapter) ParseImportedCredentials([]byte) ([]provider.CredentialSeed, error) { + return nil, nil +} +func (a *modelCapabilityAdapter) MarshalCredentials([]provider.CredentialSeed) ([]byte, error) { + return nil, nil +} diff --git a/backend/internal/application/quotarecovery/service.go b/backend/internal/application/quotarecovery/service.go new file mode 100644 index 0000000000000000000000000000000000000000..f24d198165a67f1dd56d56d958f766f3a6dc50bc --- /dev/null +++ b/backend/internal/application/quotarecovery/service.go @@ -0,0 +1,147 @@ +package quotarecovery + +import ( + "context" + "log/slog" + "sync" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/pkg/batch" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + defaultRecoveryWorkers = 25 + recoveryClaimLease = 2 * time.Minute + recoveryProbeTimeout = 30 * time.Second + recoveryReconcileEvery = time.Minute + recoveryReconcileLimit = 1000 +) + +type quotaSynchronizer interface { + RefreshQuotaMode(ctx context.Context, accountID uint64, mode string) (accountdomain.QuotaWindow, error) + ListDueQuotaWindows(ctx context.Context, now time.Time, limit int) ([]accountdomain.QuotaWindow, error) +} + +type Service struct { + logger *slog.Logger + queue repository.QuotaRecoveryQueue + syncer quotaSynchronizer + mu sync.RWMutex + base time.Duration + max time.Duration + bulkPool *batch.Pool +} + +func NewService(logger *slog.Logger, queue repository.QuotaRecoveryQueue, syncer quotaSynchronizer, base, max time.Duration) *Service { + return &Service{logger: logger, queue: queue, syncer: syncer, base: base, max: max, bulkPool: batch.NewPool(defaultRecoveryWorkers)} +} + +func (s *Service) SetBulkPool(pool *batch.Pool) { + if pool != nil { + s.bulkPool = pool + } +} + +func (s *Service) UpdateConfig(base, max time.Duration) { + s.mu.Lock() + s.base, s.max = base, max + s.mu.Unlock() +} + +func (s *Service) Run(ctx context.Context) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + reconcileTicker := time.NewTicker(recoveryReconcileEvery) + defer reconcileTicker.Stop() + s.reconcileDue(ctx, time.Now().UTC()) + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.runDue(ctx, now.UTC()) + case now := <-reconcileTicker.C: + s.reconcileDue(ctx, now.UTC()) + } + } +} + +func (s *Service) runDue(ctx context.Context, now time.Time) { + workers := s.bulkPool.Limit() + values, err := s.queue.ClaimDueQuotaRecoveries(ctx, now, workers, recoveryClaimLease) + if err != nil { + s.logger.Warn("quota_recovery_claim_failed", "error", err) + return + } + results, summary, runErr := batch.Run(ctx, values, batch.Options{Workers: workers, Pool: s.bulkPool}, func(workCtx context.Context, value accountdomain.QuotaRecoveryEvent) error { + s.runOne(workCtx, now, value) + return nil + }) + for index, result := range results { + if panicErr, ok := result.Err.(*batch.PanicError); ok { + s.logger.Error("quota_recovery_panicked", "account_id", values[index].AccountID, "mode", values[index].Mode, "error", panicErr, "stack", string(panicErr.Stack)) + } + } + if runErr != nil { + s.logger.Warn("quota_recovery_batch_canceled", "submitted", summary.Submitted, "completed", summary.Completed, "error", runErr) + } +} + +func (s *Service) runOne(ctx context.Context, now time.Time, value accountdomain.QuotaRecoveryEvent) { + probeCtx, cancel := context.WithTimeout(ctx, recoveryProbeTimeout) + window, probeErr := s.syncer.RefreshQuotaMode(probeCtx, value.AccountID, value.Mode) + cancel() + if probeErr == nil && window.Remaining > 0 { + if err := s.queue.AckQuotaRecovery(ctx, value); err != nil { + s.logger.Warn("quota_recovery_ack_failed", "account_id", value.AccountID, "mode", value.Mode, "error", err) + } + return + } + if ctx.Err() != nil { + return + } + value.Attempts++ + if probeErr == nil && window.ResetAt != nil && window.ResetAt.After(now) { + value.DueAt = *window.ResetAt + } else { + value.DueAt = now.Add(s.backoff(value.Attempts)) + } + if err := s.queue.RescheduleQuotaRecovery(ctx, value); err != nil { + s.logger.Warn("quota_recovery_reschedule_failed", "account_id", value.AccountID, "mode", value.Mode, "error", err) + } +} + +func (s *Service) reconcileDue(ctx context.Context, now time.Time) { + windows, err := s.syncer.ListDueQuotaWindows(ctx, now, recoveryReconcileLimit) + if err != nil { + s.logger.Warn("quota_recovery_reconcile_failed", "error", err) + return + } + for _, window := range windows { + if err := s.queue.EnsureQuotaRecovery(ctx, accountdomain.QuotaRecoveryEvent{AccountID: window.AccountID, Mode: window.Mode, DueAt: now}); err != nil { + s.logger.Warn("quota_recovery_reconcile_schedule_failed", "account_id", window.AccountID, "mode", window.Mode, "error", err) + } + } +} + +func (s *Service) backoff(attempt int) time.Duration { + s.mu.RLock() + base, maximum := s.base, s.max + s.mu.RUnlock() + if base <= 0 { + base = 30 * time.Second + } + if maximum < base { + maximum = 30 * time.Minute + } + value := base + for index := 1; index < attempt && value < maximum; index++ { + value *= 2 + } + if value > maximum { + return maximum + } + return value +} diff --git a/backend/internal/application/quotarecovery/service_test.go b/backend/internal/application/quotarecovery/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e8edcd5afa07481ecf16cf50c9aa66f95a0d3102 --- /dev/null +++ b/backend/internal/application/quotarecovery/service_test.go @@ -0,0 +1,118 @@ +package quotarecovery + +import ( + "context" + "io" + "log/slog" + "sync" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestRunDueClaimsBoundedBatchAndProcessesConcurrently(t *testing.T) { + now := time.Now().UTC() + events := make([]accountdomain.QuotaRecoveryEvent, defaultRecoveryWorkers) + for index := range events { + events[index] = accountdomain.QuotaRecoveryEvent{AccountID: uint64(index + 1), Mode: "fast", DueAt: now} + } + queue := "aQueueStub{claimed: events} + syncer := "aSyncStub{} + service := NewService(testLogger(), queue, syncer, time.Second, time.Minute) + + service.runDue(context.Background(), now) + + if queue.claimLimit != defaultRecoveryWorkers || queue.claimLease != recoveryClaimLease { + t.Fatalf("claim limit = %d, lease = %s", queue.claimLimit, queue.claimLease) + } + if syncer.maxConcurrent != defaultRecoveryWorkers { + t.Fatalf("max concurrent probes = %d", syncer.maxConcurrent) + } + if queue.acked != defaultRecoveryWorkers { + t.Fatalf("acked events = %d", queue.acked) + } +} + +func TestReconcileDueRestoresMissingQueueEvents(t *testing.T) { + now := time.Now().UTC() + queue := "aQueueStub{} + syncer := "aSyncStub{due: []accountdomain.QuotaWindow{{AccountID: 7, Mode: "expert", Remaining: 0, ResetAt: &now}}} + service := NewService(testLogger(), queue, syncer, time.Second, time.Minute) + + service.reconcileDue(context.Background(), now) + + if len(queue.scheduled) != 1 || queue.scheduled[0].AccountID != 7 || queue.scheduled[0].Mode != "expert" { + t.Fatalf("scheduled = %#v", queue.scheduled) + } +} + +type quotaQueueStub struct { + mu sync.Mutex + claimed []accountdomain.QuotaRecoveryEvent + claimLimit int + claimLease time.Duration + acked int + scheduled []accountdomain.QuotaRecoveryEvent + ensured []accountdomain.QuotaRecoveryEvent +} + +func (q *quotaQueueStub) EnsureQuotaRecovery(_ context.Context, value accountdomain.QuotaRecoveryEvent) error { + q.mu.Lock() + q.ensured = append(q.ensured, value) + q.scheduled = append(q.scheduled, value) + q.mu.Unlock() + return nil +} + +func (q *quotaQueueStub) ScheduleQuotaRecovery(_ context.Context, value accountdomain.QuotaRecoveryEvent) error { + q.mu.Lock() + q.scheduled = append(q.scheduled, value) + q.mu.Unlock() + return nil +} + +func (q *quotaQueueStub) ClaimDueQuotaRecoveries(_ context.Context, _ time.Time, limit int, lease time.Duration) ([]accountdomain.QuotaRecoveryEvent, error) { + q.claimLimit, q.claimLease = limit, lease + return q.claimed, nil +} + +func (q *quotaQueueStub) AckQuotaRecovery(_ context.Context, _ accountdomain.QuotaRecoveryEvent) error { + q.mu.Lock() + q.acked++ + q.mu.Unlock() + return nil +} + +func (q *quotaQueueStub) RescheduleQuotaRecovery(context.Context, accountdomain.QuotaRecoveryEvent) error { + return nil +} + +type quotaSyncStub struct { + mu sync.Mutex + current int + maxConcurrent int + due []accountdomain.QuotaWindow +} + +func (s *quotaSyncStub) RefreshQuotaMode(_ context.Context, accountID uint64, mode string) (accountdomain.QuotaWindow, error) { + s.mu.Lock() + s.current++ + if s.current > s.maxConcurrent { + s.maxConcurrent = s.current + } + s.mu.Unlock() + time.Sleep(20 * time.Millisecond) + s.mu.Lock() + s.current-- + s.mu.Unlock() + return accountdomain.QuotaWindow{AccountID: accountID, Mode: mode, Remaining: 1}, nil +} + +func (s *quotaSyncStub) ListDueQuotaWindows(context.Context, time.Time, int) ([]accountdomain.QuotaWindow, error) { + return s.due, nil +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/backend/internal/application/settings/service.go b/backend/internal/application/settings/service.go new file mode 100644 index 0000000000000000000000000000000000000000..a0564bdf08ea238172fe0b51f17411c4ff54f691 --- /dev/null +++ b/backend/internal/application/settings/service.go @@ -0,0 +1,670 @@ +package settings + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "github.com/chenyme/grok2api/backend/internal/infra/config" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +var ( + ErrInvalidInput = errors.New("运行设置参数无效") + ErrConflict = errors.New("运行设置已被其他会话更新") +) + +// ProviderBuildConfig 是管理接口使用的 Provider 可编辑输入。 +type ProviderBuildConfig struct { + BaseURL string + FallbackBaseURL string + ClientVersion string + ClientIdentifier string + TokenAuth string + UserAgent string + ResponseHeaderTimeout string +} + +// ProviderBuildRecommendation 表示当前网关已完成兼容回归的 Grok Build 协议基线。 +type ProviderBuildRecommendation struct { + ClientVersion string + UserAgent string +} + +type ProviderWebConfig struct { + BaseURL string + StatsigMode string + StatsigManualValue string + StatsigManualConfigured bool + StatsigSignerURL string + ClearanceMode string + FlareSolverrURL string + ClearanceTimeout string + ClearanceRefresh string + QuotaTimeout string + ChatTimeout string + ImageTimeout string + VideoTimeout string + MediaConcurrency int + AllowNSFW bool + RecoveryBackoffBase string + RecoveryBackoffMax string + // ClearanceProvided distinguishes older admin clients that predate the + // managed-clearance fields from an explicit update to those fields. + ClearanceProvided bool +} + +type ProviderConsoleConfig struct { + BaseURL string + ChatTimeout string +} + +// ServerConfig 是管理接口使用的推理入口容量输入。 +type ServerConfig struct { + MaxConcurrentRequests int +} + +// BatchConfig 是管理接口使用的批量任务并发输入。 +type BatchConfig struct { + ImportConcurrency int + ConversionConcurrency int + SyncConcurrency int + RefreshConcurrency int + RandomDelay string +} + +type MediaConfig struct { + MaxImageBytes int64 + MaxTotalBytes int64 + CleanupThresholdPercent int + CleanupInterval string +} + +// FrontendConfig 是管理接口使用的公开 API 地址输入。 +type FrontendConfig struct { + PublicAPIBaseURL string +} + +// RoutingConfig 是管理接口使用的路由可编辑输入。 +type RoutingConfig struct { + StickyTTL string + CooldownBase string + CooldownMax string + CapacityWait string + MaxAttempts int + PreferFreeBuild bool + SegmentedSelector SegmentedSelectorConfig + SegmentedSelectorProvided bool +} + +type SegmentedSelectorConfig struct { + Enabled bool + MinCandidates int + WindowSize int +} + +// AuditConfig 是管理接口使用的审计可编辑输入。 +type AuditConfig struct { + BufferSize int + BatchSize int + FlushInterval string + CommitDelayMS int +} + +// ClientKeyDefaultsConfig 是管理接口使用的密钥默认限制输入。 +type ClientKeyDefaultsConfig struct { + RPMLimit int + MaxConcurrent int +} + +// AccountsConfig 是管理接口使用的账号池维护策略输入。 +type AccountsConfig struct { + MarkBuildForbiddenReauth bool + BuildForbiddenReauthCodes []string + AutoCleanReauthEnabled bool + AutoCleanReauthInterval string + AutoCleanReauthMinAge string + AutoCleanIncludeDisabled bool + // MarkBuildForbiddenReauthProvided preserves the value when an older management client omits the field. + MarkBuildForbiddenReauthProvided bool + // BuildForbiddenReauthCodesProvided preserves the configured codes when an older management client omits the field. + BuildForbiddenReauthCodesProvided bool +} + +// EditableConfig 聚合管理端允许修改的运行参数。 +type EditableConfig struct { + Server ServerConfig + ProviderBuild ProviderBuildConfig + ProviderWeb ProviderWebConfig + ProviderConsole ProviderConsoleConfig + Batch BatchConfig + Media MediaConfig + Frontend FrontendConfig + Routing RoutingConfig + Audit AuditConfig + ClientKeyDefaults ClientKeyDefaultsConfig + Accounts AccountsConfig + // AccountsProvided 区分旧管理端未发送 accounts 与显式提交默认值。 + AccountsProvided bool +} + +// Snapshot 表示当前运行设置和需要重启才能生效的字段。 +type Snapshot struct { + Config EditableConfig + RecommendedProviderBuild ProviderBuildRecommendation + UpdatedAt time.Time + Revision uint64 + RestartRequired []string +} + +// Service 管理允许在线修改的配置,并向后台任务广播配置变更。 +type Service struct { + mu sync.RWMutex + updateMu sync.Mutex + cfg config.Config + updatedAt time.Time + revision uint64 + activeBufferSize int + activeMediaConcurrency int + repository repository.RuntimeSettingsRepository + notify func(context.Context) + apply func(config.Config) +} + +func NewService(cfg config.Config, updatedAt time.Time, revision uint64, repository repository.RuntimeSettingsRepository, notify func(context.Context), apply func(config.Config)) *Service { + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + return &Service{cfg: cfg, updatedAt: updatedAt, revision: revision, activeBufferSize: cfg.Audit.BufferSize, activeMediaConcurrency: cfg.Provider.Web.MediaConcurrency, repository: repository, notify: notify, apply: apply} +} + +// LoadPersisted 将数据库运行设置覆盖到代码默认配置,并执行完整边界校验。 +func LoadPersisted(ctx context.Context, base config.Config, repository repository.RuntimeSettingsRepository) (config.Config, time.Time, uint64, error) { + value, updatedAt, revision, found, err := repository.Get(ctx) + if err != nil { + return config.Config{}, time.Time{}, 0, err + } + if !found { + return base, time.Time{}, 0, nil + } + // 持久化层使用强类型时长,避免数据库格式受 HTTP DTO 字符串影响。 + loaded := applyDomainConfig(base, value) + if err := loaded.Validate(); err != nil { + return config.Config{}, time.Time{}, 0, fmt.Errorf("校验运行设置: %w", err) + } + return loaded, updatedAt, revision, nil +} + +// Get 返回当前生效的可编辑设置快照。 +func (s *Service) Get() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshotLocked() +} + +// PublicAPIBaseURL 返回运行设置、配置文件或内置默认值解析后的公开 API 根地址。 +func (s *Service) PublicAPIBaseURL() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.Frontend.EffectivePublicAPIBaseURL() +} + +// Update 校验并持久化运行设置,再原子替换进程内配置。 +func (s *Service) Update(ctx context.Context, expectedRevision uint64, input EditableConfig) (Snapshot, error) { + s.updateMu.Lock() + defer s.updateMu.Unlock() + + s.mu.RLock() + current := s.cfg + currentRevision := s.revision + s.mu.RUnlock() + if expectedRevision != currentRevision { + return Snapshot{}, ErrConflict + } + next, err := mergeEditable(current, input) + if err != nil { + return Snapshot{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + updatedAt, revision, err := s.repository.Save(ctx, toDomainConfig(next), currentRevision) + if err != nil { + if errors.Is(err, repository.ErrConflict) { + return Snapshot{}, ErrConflict + } + return Snapshot{}, err + } + + s.mu.Lock() + s.cfg = next + s.updatedAt = updatedAt + s.revision = revision + result := s.snapshotLocked() + apply := s.apply + s.mu.Unlock() + + if apply != nil { + apply(next) + } + if s.notify != nil { + s.notify(ctx) + } + return result, nil +} + +// ReloadPersisted 在收到其他实例的变更通知后,从主数据库重载并应用运行设置。 +func (s *Service) ReloadPersisted(ctx context.Context) error { + s.updateMu.Lock() + defer s.updateMu.Unlock() + value, updatedAt, revision, found, err := s.repository.Get(ctx) + if err != nil || !found { + return err + } + s.mu.RLock() + current := s.cfg + currentRevision := s.revision + s.mu.RUnlock() + if revision <= currentRevision { + return nil + } + next := applyDomainConfig(current, value) + if err := next.Validate(); err != nil { + return fmt.Errorf("校验重载运行设置: %w", err) + } + s.mu.Lock() + s.cfg = next + s.updatedAt = updatedAt + s.revision = revision + apply := s.apply + s.mu.Unlock() + if apply != nil { + apply(next) + } + return nil +} + +func applyDomainConfig(base config.Config, value settingsdomain.Config) config.Config { + // 旧版运行设置没有 Server 字段,反序列化后为零;升级时沿用当前配置默认值。 + if value.Server.MaxConcurrentRequests > 0 { + base.Server.MaxConcurrentRequests = value.Server.MaxConcurrentRequests + } + capacityWait := value.Routing.CapacityWait + if capacityWait <= 0 { + capacityWait = base.Routing.CapacityWait.Value() + } + base.Provider.Build = config.BuildProviderConfig{ + BaseURL: value.ProviderBuild.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(value.ProviderBuild.FallbackBaseURL), + ClientVersion: value.ProviderBuild.ClientVersion, ClientIdentifier: value.ProviderBuild.ClientIdentifier, + TokenAuth: value.ProviderBuild.TokenAuth, UserAgent: value.ProviderBuild.UserAgent, + ResponseHeaderTimeout: config.Duration(value.ProviderBuild.ResponseHeaderTimeout), + } + if value.ProviderBuild.ResponseHeaderTimeout <= 0 { + base.Provider.Build.ResponseHeaderTimeout = config.Duration(settingsdomain.DefaultBuildResponseHeaderTimeout) + } + clearanceMode := strings.TrimSpace(value.ProviderWeb.ClearanceMode) + if clearanceMode == "" { + clearanceMode = base.Provider.Web.ClearanceMode + } + flareSolverrURL := strings.TrimSpace(value.ProviderWeb.FlareSolverrURL) + if flareSolverrURL == "" { + flareSolverrURL = base.Provider.Web.FlareSolverrURL + } + clearanceTimeout := value.ProviderWeb.ClearanceTimeout + if clearanceTimeout <= 0 { + clearanceTimeout = base.Provider.Web.ClearanceTimeout.Value() + } + clearanceRefresh := value.ProviderWeb.ClearanceRefresh + if clearanceRefresh <= 0 { + clearanceRefresh = base.Provider.Web.ClearanceRefresh.Value() + } + base.Provider.Web = config.WebProviderConfig{ + BaseURL: value.ProviderWeb.BaseURL, QuotaTimeout: config.Duration(value.ProviderWeb.QuotaTimeout), + StatsigMode: value.ProviderWeb.StatsigMode, StatsigManualValue: value.ProviderWeb.StatsigManualValue, StatsigSignerURL: value.ProviderWeb.StatsigSignerURL, + ClearanceMode: clearanceMode, FlareSolverrURL: flareSolverrURL, + ClearanceTimeout: config.Duration(clearanceTimeout), ClearanceRefresh: config.Duration(clearanceRefresh), + ChatTimeout: config.Duration(value.ProviderWeb.ChatTimeout), ImageTimeout: config.Duration(value.ProviderWeb.ImageTimeout), + VideoTimeout: config.Duration(value.ProviderWeb.VideoTimeout), + MediaConcurrency: value.ProviderWeb.MediaConcurrency, AllowNSFW: value.ProviderWeb.AllowNSFW, + RecoveryBackoffBase: config.Duration(value.ProviderWeb.RecoveryBackoffBase), RecoveryBackoffMax: config.Duration(value.ProviderWeb.RecoveryBackoffMax), + } + // Console 是后续版本新增的完整配置段;旧 JSON 整段缺失时沿用代码默认值。 + if value.ProviderConsole != (settingsdomain.ProviderConsoleConfig{}) { + base.Provider.Console = config.ConsoleProviderConfig{ + BaseURL: value.ProviderConsole.BaseURL, ChatTimeout: config.Duration(value.ProviderConsole.ChatTimeout), + } + } + randomDelay := time.Duration(-1) + if value.Batch.RandomDelay != nil { + randomDelay = *value.Batch.RandomDelay + } + base.Batch = config.BatchConfig{ + ImportConcurrency: value.Batch.ImportConcurrency, ConversionConcurrency: value.Batch.ConversionConcurrency, + SyncConcurrency: value.Batch.SyncConcurrency, RefreshConcurrency: value.Batch.RefreshConcurrency, + RandomDelay: config.Duration(randomDelay), + } + base.Media.MaxImageBytes = value.Media.MaxImageBytes + base.Media.MaxTotalBytes = value.Media.MaxTotalBytes + base.Media.CleanupThresholdPercent = value.Media.CleanupThresholdPercent + base.Media.CleanupInterval = config.Duration(value.Media.CleanupInterval) + base.Frontend.PublicAPIBaseURLOverride = strings.TrimSpace(value.Frontend.PublicAPIBaseURL) + segmentedEnabled := base.Routing.SegmentedSelectorEnabled + segmentedMinCandidates := base.Routing.SegmentedMinCandidates + segmentedWindowSize := base.Routing.SegmentedWindowSize + if value.Routing.SegmentedSelector != nil { + segmentedEnabled = value.Routing.SegmentedSelector.ActiveEnabled + segmentedMinCandidates = value.Routing.SegmentedSelector.MinCandidates + segmentedWindowSize = value.Routing.SegmentedSelector.WindowSize + } + base.Routing = config.RoutingConfig{ + StickyTTL: config.Duration(value.Routing.StickyTTL), CooldownBase: config.Duration(value.Routing.CooldownBase), + CooldownMax: config.Duration(value.Routing.CooldownMax), CapacityWait: config.Duration(capacityWait), MaxAttempts: value.Routing.MaxAttempts, + PreferFreeBuild: value.Routing.PreferFreeBuild, + SegmentedSelectorEnabled: segmentedEnabled, + SegmentedMinCandidates: segmentedMinCandidates, + SegmentedWindowSize: segmentedWindowSize, + ReasoningReplayEnabled: base.Routing.ReasoningReplayEnabled, ReasoningReplayTTL: base.Routing.ReasoningReplayTTL, + ReasoningReplayMaxEntries: base.Routing.ReasoningReplayMaxEntries, + } + commitDelay := base.Audit.CommitDelay.Value() + if value.Audit.CommitDelay > 0 { + commitDelay = value.Audit.CommitDelay + } + base.Audit = config.AuditConfig{ + BufferSize: value.Audit.BufferSize, BatchSize: value.Audit.BatchSize, FlushInterval: config.Duration(value.Audit.FlushInterval), + CommitDelay: config.Duration(commitDelay), + LedgerMode: base.Audit.LedgerMode, LedgerFailureThreshold: base.Audit.LedgerFailureThreshold, + LedgerUnhealthyGrace: base.Audit.LedgerUnhealthyGrace, LedgerQueueHighWatermarkPct: base.Audit.LedgerQueueHighWatermarkPct, + } + base.ClientKeyDefaults = config.ClientKeyDefaultsConfig{ + RPMLimit: value.ClientKeyDefaults.RPMLimit, MaxConcurrent: value.ClientKeyDefaults.MaxConcurrent, + } + // Accounts 为后续新增段;旧持久化缺字段时沿用代码默认(全部关闭)。 + if value.Accounts.AutoCleanReauthInterval > 0 { + base.Accounts.AutoCleanReauthInterval = config.Duration(value.Accounts.AutoCleanReauthInterval) + } + if value.Accounts.AutoCleanReauthMinAge > 0 { + base.Accounts.AutoCleanReauthMinAge = config.Duration(value.Accounts.AutoCleanReauthMinAge) + } + base.Accounts.AutoCleanReauthEnabled = value.Accounts.AutoCleanReauthEnabled + base.Accounts.AutoCleanIncludeDisabled = value.Accounts.AutoCleanIncludeDisabled + base.Accounts.MarkBuildForbiddenReauth = value.Accounts.MarkBuildForbiddenReauth + if value.Accounts.BuildForbiddenReauthCodes != nil { + base.Accounts.BuildForbiddenReauthCodes = append([]string(nil), value.Accounts.BuildForbiddenReauthCodes...) + } + return base +} + +func toDomainConfig(value config.Config) settingsdomain.Config { + randomDelay := value.Batch.RandomDelay.Value() + return settingsdomain.Config{ + Server: settingsdomain.ServerConfig{MaxConcurrentRequests: value.Server.MaxConcurrentRequests}, + ProviderBuild: settingsdomain.ProviderBuildConfig{ + BaseURL: value.Provider.Build.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(value.Provider.Build.FallbackBaseURL), + ClientVersion: value.Provider.Build.ClientVersion, ClientIdentifier: value.Provider.Build.ClientIdentifier, + TokenAuth: value.Provider.Build.TokenAuth, UserAgent: value.Provider.Build.UserAgent, + ResponseHeaderTimeout: value.Provider.Build.ResponseHeaderTimeout.Value(), + }, + ProviderWeb: settingsdomain.ProviderWebConfig{ + BaseURL: value.Provider.Web.BaseURL, QuotaTimeout: value.Provider.Web.QuotaTimeout.Value(), + StatsigMode: value.Provider.Web.StatsigMode, StatsigManualValue: value.Provider.Web.StatsigManualValue, + StatsigSignerURL: value.Provider.Web.StatsigSignerURL, + ClearanceMode: value.Provider.Web.ClearanceMode, FlareSolverrURL: value.Provider.Web.FlareSolverrURL, + ClearanceTimeout: value.Provider.Web.ClearanceTimeout.Value(), ClearanceRefresh: value.Provider.Web.ClearanceRefresh.Value(), + ChatTimeout: value.Provider.Web.ChatTimeout.Value(), ImageTimeout: value.Provider.Web.ImageTimeout.Value(), + VideoTimeout: value.Provider.Web.VideoTimeout.Value(), + MediaConcurrency: value.Provider.Web.MediaConcurrency, AllowNSFW: value.Provider.Web.AllowNSFW, + RecoveryBackoffBase: value.Provider.Web.RecoveryBackoffBase.Value(), RecoveryBackoffMax: value.Provider.Web.RecoveryBackoffMax.Value(), + }, + ProviderConsole: settingsdomain.ProviderConsoleConfig{ + BaseURL: value.Provider.Console.BaseURL, ChatTimeout: value.Provider.Console.ChatTimeout.Value(), + }, + Batch: settingsdomain.BatchConfig{ + ImportConcurrency: value.Batch.ImportConcurrency, ConversionConcurrency: value.Batch.ConversionConcurrency, + SyncConcurrency: value.Batch.SyncConcurrency, RefreshConcurrency: value.Batch.RefreshConcurrency, + RandomDelay: &randomDelay, + }, + Media: settingsdomain.MediaConfig{ + MaxImageBytes: value.Media.MaxImageBytes, MaxTotalBytes: value.Media.MaxTotalBytes, + CleanupThresholdPercent: value.Media.CleanupThresholdPercent, CleanupInterval: value.Media.CleanupInterval.Value(), + }, + Frontend: settingsdomain.FrontendConfig{ + PublicAPIBaseURL: value.Frontend.PublicAPIBaseURLOverride, + }, + Routing: settingsdomain.RoutingConfig{ + StickyTTL: value.Routing.StickyTTL.Value(), CooldownBase: value.Routing.CooldownBase.Value(), + CooldownMax: value.Routing.CooldownMax.Value(), CapacityWait: value.Routing.CapacityWait.Value(), MaxAttempts: value.Routing.MaxAttempts, + PreferFreeBuild: value.Routing.PreferFreeBuild, + SegmentedSelector: &settingsdomain.SegmentedSelectorConfig{ + ActiveEnabled: value.Routing.SegmentedSelectorEnabled, + MinCandidates: value.Routing.SegmentedMinCandidates, WindowSize: value.Routing.SegmentedWindowSize, + }, + }, + Audit: settingsdomain.AuditConfig{ + BufferSize: value.Audit.BufferSize, BatchSize: value.Audit.BatchSize, FlushInterval: value.Audit.FlushInterval.Value(), CommitDelay: value.Audit.CommitDelay.Value(), + }, + ClientKeyDefaults: settingsdomain.ClientKeyDefaultsConfig{ + RPMLimit: value.ClientKeyDefaults.RPMLimit, MaxConcurrent: value.ClientKeyDefaults.MaxConcurrent, + }, + Accounts: settingsdomain.AccountsConfig{ + MarkBuildForbiddenReauth: value.Accounts.MarkBuildForbiddenReauth, + BuildForbiddenReauthCodes: append([]string(nil), value.Accounts.BuildForbiddenReauthCodes...), + AutoCleanReauthEnabled: value.Accounts.AutoCleanReauthEnabled, + AutoCleanReauthInterval: value.Accounts.AutoCleanReauthInterval.Value(), + AutoCleanReauthMinAge: value.Accounts.AutoCleanReauthMinAge.Value(), + AutoCleanIncludeDisabled: value.Accounts.AutoCleanIncludeDisabled, + }, + } +} + +func (s *Service) snapshotLocked() Snapshot { + restartRequired := []string{} + if s.cfg.Audit.BufferSize != s.activeBufferSize { + restartRequired = append(restartRequired, "audit.bufferSize") + } + if s.cfg.Provider.Web.MediaConcurrency != s.activeMediaConcurrency { + restartRequired = append(restartRequired, "providerWeb.mediaConcurrency") + } + return Snapshot{ + Config: toEditable(s.cfg), + RecommendedProviderBuild: ProviderBuildRecommendation{ + ClientVersion: config.RecommendedBuildClientVersion, + UserAgent: config.RecommendedBuildUserAgent, + }, + UpdatedAt: s.updatedAt, Revision: s.revision, RestartRequired: restartRequired, + } +} + +func mergeEditable(current config.Config, input EditableConfig) (config.Config, error) { + if input.Audit.CommitDelayMS < 0 { + return config.Config{}, errors.New("audit.commitDelayMS 不能为负数") + } + next := current + next.Server.MaxConcurrentRequests = input.Server.MaxConcurrentRequests + next.Provider.Build.BaseURL = strings.TrimSpace(input.ProviderBuild.BaseURL) + next.Provider.Build.FallbackBaseURL = config.NormalizeBuildFallbackBaseURL(input.ProviderBuild.FallbackBaseURL) + next.Provider.Build.ClientVersion = strings.TrimSpace(input.ProviderBuild.ClientVersion) + next.Provider.Build.ClientIdentifier = strings.TrimSpace(input.ProviderBuild.ClientIdentifier) + if tokenAuth := strings.TrimSpace(input.ProviderBuild.TokenAuth); tokenAuth != "" { + next.Provider.Build.TokenAuth = tokenAuth + } + next.Provider.Build.UserAgent = strings.TrimSpace(input.ProviderBuild.UserAgent) + next.Provider.Web.BaseURL = strings.TrimSpace(input.ProviderWeb.BaseURL) + next.Provider.Web.StatsigMode = strings.TrimSpace(input.ProviderWeb.StatsigMode) + next.Provider.Web.StatsigSignerURL = strings.TrimSpace(input.ProviderWeb.StatsigSignerURL) + if input.ProviderWeb.ClearanceProvided { + next.Provider.Web.ClearanceMode = strings.TrimSpace(input.ProviderWeb.ClearanceMode) + next.Provider.Web.FlareSolverrURL = strings.TrimSpace(input.ProviderWeb.FlareSolverrURL) + } + if next.Provider.Web.StatsigMode == config.StatsigModeManual { + if value := strings.TrimSpace(input.ProviderWeb.StatsigManualValue); value != "" { + next.Provider.Web.StatsigManualValue = value + } + } else { + next.Provider.Web.StatsigManualValue = "" + } + next.Provider.Web.MediaConcurrency = input.ProviderWeb.MediaConcurrency + next.Provider.Web.AllowNSFW = input.ProviderWeb.AllowNSFW + next.Provider.Console.BaseURL = strings.TrimSpace(input.ProviderConsole.BaseURL) + next.Batch = config.BatchConfig{ + ImportConcurrency: input.Batch.ImportConcurrency, ConversionConcurrency: input.Batch.ConversionConcurrency, + SyncConcurrency: input.Batch.SyncConcurrency, RefreshConcurrency: input.Batch.RefreshConcurrency, + } + next.Media.MaxImageBytes = input.Media.MaxImageBytes + next.Media.MaxTotalBytes = input.Media.MaxTotalBytes + next.Media.CleanupThresholdPercent = input.Media.CleanupThresholdPercent + next.Frontend.PublicAPIBaseURLOverride = strings.TrimSpace(input.Frontend.PublicAPIBaseURL) + next.Routing.MaxAttempts = input.Routing.MaxAttempts + next.Routing.PreferFreeBuild = input.Routing.PreferFreeBuild + if input.Routing.SegmentedSelectorProvided { + next.Routing.SegmentedSelectorEnabled = input.Routing.SegmentedSelector.Enabled + next.Routing.SegmentedMinCandidates = input.Routing.SegmentedSelector.MinCandidates + next.Routing.SegmentedWindowSize = input.Routing.SegmentedSelector.WindowSize + } + next.Audit.BufferSize = input.Audit.BufferSize + next.Audit.BatchSize = input.Audit.BatchSize + if input.Audit.CommitDelayMS > 0 { + next.Audit.CommitDelay = config.Duration(time.Duration(input.Audit.CommitDelayMS) * time.Millisecond) + } + next.ClientKeyDefaults.RPMLimit = input.ClientKeyDefaults.RPMLimit + next.ClientKeyDefaults.MaxConcurrent = input.ClientKeyDefaults.MaxConcurrent + if input.AccountsProvided { + if input.Accounts.MarkBuildForbiddenReauthProvided { + next.Accounts.MarkBuildForbiddenReauth = input.Accounts.MarkBuildForbiddenReauth + } + if input.Accounts.BuildForbiddenReauthCodesProvided { + next.Accounts.BuildForbiddenReauthCodes = normalizeForbiddenCodes(input.Accounts.BuildForbiddenReauthCodes) + } + next.Accounts.AutoCleanReauthEnabled = input.Accounts.AutoCleanReauthEnabled + next.Accounts.AutoCleanIncludeDisabled = input.Accounts.AutoCleanIncludeDisabled + } + + type durationInput struct { + path string + value string + set func(config.Duration) + } + durations := []durationInput{ + {"routing.stickyTTL", input.Routing.StickyTTL, func(value config.Duration) { next.Routing.StickyTTL = value }}, + {"routing.cooldownBase", input.Routing.CooldownBase, func(value config.Duration) { next.Routing.CooldownBase = value }}, + {"routing.cooldownMax", input.Routing.CooldownMax, func(value config.Duration) { next.Routing.CooldownMax = value }}, + {"routing.capacityWait", input.Routing.CapacityWait, func(value config.Duration) { next.Routing.CapacityWait = value }}, + {"audit.flushInterval", input.Audit.FlushInterval, func(value config.Duration) { next.Audit.FlushInterval = value }}, + {"providerWeb.quotaTimeout", input.ProviderWeb.QuotaTimeout, func(value config.Duration) { next.Provider.Web.QuotaTimeout = value }}, + {"providerWeb.chatTimeout", input.ProviderWeb.ChatTimeout, func(value config.Duration) { next.Provider.Web.ChatTimeout = value }}, + {"providerWeb.imageTimeout", input.ProviderWeb.ImageTimeout, func(value config.Duration) { next.Provider.Web.ImageTimeout = value }}, + {"providerWeb.videoTimeout", input.ProviderWeb.VideoTimeout, func(value config.Duration) { next.Provider.Web.VideoTimeout = value }}, + {"providerWeb.recoveryBackoffBase", input.ProviderWeb.RecoveryBackoffBase, func(value config.Duration) { next.Provider.Web.RecoveryBackoffBase = value }}, + {"providerWeb.recoveryBackoffMax", input.ProviderWeb.RecoveryBackoffMax, func(value config.Duration) { next.Provider.Web.RecoveryBackoffMax = value }}, + {"providerConsole.chatTimeout", input.ProviderConsole.ChatTimeout, func(value config.Duration) { next.Provider.Console.ChatTimeout = value }}, + {"media.cleanupInterval", input.Media.CleanupInterval, func(value config.Duration) { next.Media.CleanupInterval = value }}, + {"batch.randomDelay", input.Batch.RandomDelay, func(value config.Duration) { next.Batch.RandomDelay = value }}, + } + if strings.TrimSpace(input.ProviderBuild.ResponseHeaderTimeout) != "" { + durations = append(durations, durationInput{"providerBuild.responseHeaderTimeout", input.ProviderBuild.ResponseHeaderTimeout, func(value config.Duration) { next.Provider.Build.ResponseHeaderTimeout = value }}) + } + if input.ProviderWeb.ClearanceProvided { + durations = append(durations, + durationInput{"providerWeb.clearanceTimeout", input.ProviderWeb.ClearanceTimeout, func(value config.Duration) { next.Provider.Web.ClearanceTimeout = value }}, + durationInput{"providerWeb.clearanceRefresh", input.ProviderWeb.ClearanceRefresh, func(value config.Duration) { next.Provider.Web.ClearanceRefresh = value }}, + ) + } + if input.AccountsProvided { + durations = append(durations, + durationInput{"accounts.autoCleanReauthInterval", input.Accounts.AutoCleanReauthInterval, func(value config.Duration) { next.Accounts.AutoCleanReauthInterval = value }}, + durationInput{"accounts.autoCleanReauthMinAge", input.Accounts.AutoCleanReauthMinAge, func(value config.Duration) { next.Accounts.AutoCleanReauthMinAge = value }}, + ) + } + for _, item := range durations { + value, err := time.ParseDuration(strings.TrimSpace(item.value)) + if err != nil { + return config.Config{}, fmt.Errorf("%s 必须是有效时长", item.path) + } + item.set(config.Duration(value)) + } + if err := next.Validate(); err != nil { + return config.Config{}, err + } + return next, nil +} + +func toEditable(cfg config.Config) EditableConfig { + return EditableConfig{ + Server: ServerConfig{MaxConcurrentRequests: cfg.Server.MaxConcurrentRequests}, + ProviderBuild: ProviderBuildConfig{ + BaseURL: cfg.Provider.Build.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(cfg.Provider.Build.FallbackBaseURL), + ClientVersion: cfg.Provider.Build.ClientVersion, ClientIdentifier: cfg.Provider.Build.ClientIdentifier, + TokenAuth: cfg.Provider.Build.TokenAuth, UserAgent: cfg.Provider.Build.UserAgent, + ResponseHeaderTimeout: cfg.Provider.Build.ResponseHeaderTimeout.String(), + }, + ProviderWeb: ProviderWebConfig{ + BaseURL: cfg.Provider.Web.BaseURL, QuotaTimeout: cfg.Provider.Web.QuotaTimeout.String(), + StatsigMode: cfg.Provider.Web.StatsigMode, StatsigManualConfigured: strings.TrimSpace(cfg.Provider.Web.StatsigManualValue) != "", + StatsigSignerURL: cfg.Provider.Web.StatsigSignerURL, + ClearanceMode: cfg.Provider.Web.ClearanceMode, FlareSolverrURL: cfg.Provider.Web.FlareSolverrURL, + ClearanceTimeout: cfg.Provider.Web.ClearanceTimeout.String(), ClearanceRefresh: cfg.Provider.Web.ClearanceRefresh.String(), + ChatTimeout: cfg.Provider.Web.ChatTimeout.String(), ImageTimeout: cfg.Provider.Web.ImageTimeout.String(), + VideoTimeout: cfg.Provider.Web.VideoTimeout.String(), + MediaConcurrency: cfg.Provider.Web.MediaConcurrency, AllowNSFW: cfg.Provider.Web.AllowNSFW, + RecoveryBackoffBase: cfg.Provider.Web.RecoveryBackoffBase.String(), RecoveryBackoffMax: cfg.Provider.Web.RecoveryBackoffMax.String(), + }, + ProviderConsole: ProviderConsoleConfig{ + BaseURL: cfg.Provider.Console.BaseURL, ChatTimeout: cfg.Provider.Console.ChatTimeout.String(), + }, + Batch: BatchConfig{ + ImportConcurrency: cfg.Batch.ImportConcurrency, ConversionConcurrency: cfg.Batch.ConversionConcurrency, + SyncConcurrency: cfg.Batch.SyncConcurrency, RefreshConcurrency: cfg.Batch.RefreshConcurrency, + RandomDelay: cfg.Batch.RandomDelay.String(), + }, + Media: MediaConfig{ + MaxImageBytes: cfg.Media.MaxImageBytes, MaxTotalBytes: cfg.Media.MaxTotalBytes, + CleanupThresholdPercent: cfg.Media.CleanupThresholdPercent, CleanupInterval: cfg.Media.CleanupInterval.String(), + }, + Frontend: FrontendConfig{ + PublicAPIBaseURL: cfg.Frontend.PublicAPIBaseURLOverride, + }, + Routing: RoutingConfig{ + StickyTTL: cfg.Routing.StickyTTL.String(), CooldownBase: cfg.Routing.CooldownBase.String(), + CooldownMax: cfg.Routing.CooldownMax.String(), CapacityWait: cfg.Routing.CapacityWait.String(), MaxAttempts: cfg.Routing.MaxAttempts, + PreferFreeBuild: cfg.Routing.PreferFreeBuild, + SegmentedSelector: SegmentedSelectorConfig{ + Enabled: cfg.Routing.SegmentedSelectorEnabled, MinCandidates: cfg.Routing.SegmentedMinCandidates, + WindowSize: cfg.Routing.SegmentedWindowSize, + }, + SegmentedSelectorProvided: true, + }, + Audit: AuditConfig{ + BufferSize: cfg.Audit.BufferSize, BatchSize: cfg.Audit.BatchSize, FlushInterval: cfg.Audit.FlushInterval.String(), CommitDelayMS: int(cfg.Audit.CommitDelay.Value() / time.Millisecond), + }, + ClientKeyDefaults: ClientKeyDefaultsConfig{RPMLimit: cfg.ClientKeyDefaults.RPMLimit, MaxConcurrent: cfg.ClientKeyDefaults.MaxConcurrent}, + Accounts: AccountsConfig{ + MarkBuildForbiddenReauth: cfg.Accounts.MarkBuildForbiddenReauth, + BuildForbiddenReauthCodes: append([]string(nil), cfg.Accounts.BuildForbiddenReauthCodes...), + MarkBuildForbiddenReauthProvided: true, + BuildForbiddenReauthCodesProvided: true, + AutoCleanReauthEnabled: cfg.Accounts.AutoCleanReauthEnabled, + AutoCleanReauthInterval: cfg.Accounts.AutoCleanReauthInterval.String(), + AutoCleanReauthMinAge: cfg.Accounts.AutoCleanReauthMinAge.String(), + AutoCleanIncludeDisabled: cfg.Accounts.AutoCleanIncludeDisabled, + }, + AccountsProvided: true, + } +} + +func normalizeForbiddenCodes(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + code := strings.ToLower(strings.TrimSpace(value)) + if code == "" { + continue + } + if _, exists := seen[code]; exists { + continue + } + seen[code] = struct{}{} + result = append(result, code) + } + return result +} diff --git a/backend/internal/application/settings/service_test.go b/backend/internal/application/settings/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2e79a681a16738478f7057e2ba364a82b309d235 --- /dev/null +++ b/backend/internal/application/settings/service_test.go @@ -0,0 +1,661 @@ +package settings + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "github.com/chenyme/grok2api/backend/internal/infra/config" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type runtimeSettingsRepositoryStub struct { + value settingsdomain.Config + updatedAt time.Time + revision uint64 + found bool + getCount int +} + +func (r *runtimeSettingsRepositoryStub) Get(context.Context) (settingsdomain.Config, time.Time, uint64, bool, error) { + r.getCount++ + return r.value, r.updatedAt, r.revision, r.found, nil +} + +func (r *runtimeSettingsRepositoryStub) Save(_ context.Context, value settingsdomain.Config, expectedRevision uint64) (time.Time, uint64, error) { + if expectedRevision != r.revision { + return time.Time{}, 0, repository.ErrConflict + } + r.value = value + r.updatedAt = time.Now().UTC() + r.revision++ + r.found = true + return r.updatedAt, r.revision, nil +} + +func TestUpdatePersistsAppliesAndReportsRestart(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, repository, nil, func(next config.Config) { applied = next }) + input := service.Get().Config + input.Server.MaxConcurrentRequests = 2048 + input.ProviderBuild.ResponseHeaderTimeout = "7m" + input.Routing.MaxAttempts = 5 + input.Routing.PreferFreeBuild = true + input.Routing.SegmentedSelector = SegmentedSelectorConfig{Enabled: true, MinCandidates: 5000, WindowSize: 96} + input.Audit.BufferSize = cfg.Audit.BufferSize + 1 + input.Media.MaxTotalBytes = 2 << 30 + input.Media.CleanupThresholdPercent = 75 + input.Media.CleanupInterval = "5m" + input.Frontend.PublicAPIBaseURL = "https://public.example.com" + input.ProviderConsole.BaseURL = "https://console.example.com" + input.ProviderConsole.ChatTimeout = "6m" + input.Batch = BatchConfig{ImportConcurrency: 26, ConversionConcurrency: 27, SyncConcurrency: 28, RefreshConcurrency: 29, RandomDelay: "750ms"} + + snapshot, err := service.Update(context.Background(), service.Get().Revision, input) + if err != nil { + t.Fatal(err) + } + if applied.Routing.MaxAttempts != 5 || !applied.Routing.PreferFreeBuild || !applied.Routing.SegmentedSelectorEnabled || applied.Routing.SegmentedMinCandidates != 5000 || applied.Routing.SegmentedWindowSize != 96 { + t.Fatalf("runtime configuration was not applied: %#v", applied.Routing) + } + if applied.Server.MaxConcurrentRequests != 2048 { + t.Fatalf("server configuration was not applied: %#v", applied.Server) + } + if applied.Provider.Build.ResponseHeaderTimeout.Value() != 7*time.Minute { + t.Fatalf("Build response header timeout was not applied: %s", applied.Provider.Build.ResponseHeaderTimeout.Value()) + } + if applied.Media.MaxTotalBytes != 2<<30 || applied.Media.CleanupThresholdPercent != 75 || applied.Media.CleanupInterval.Value() != 5*time.Minute { + t.Fatalf("media configuration was not applied: %#v", applied.Media) + } + if applied.Frontend.PublicAPIBaseURLOverride != "https://public.example.com" || applied.Frontend.EffectivePublicAPIBaseURL() != "https://public.example.com" { + t.Fatalf("frontend configuration was not applied: %#v", applied.Frontend) + } + if applied.Batch.ImportConcurrency != 26 || applied.Batch.ConversionConcurrency != 27 || applied.Batch.SyncConcurrency != 28 || applied.Batch.RefreshConcurrency != 29 || applied.Batch.RandomDelay.Value() != 750*time.Millisecond { + t.Fatalf("batch configuration was not applied: %#v", applied.Batch) + } + if applied.Provider.Console.BaseURL != "https://console.example.com" || applied.Provider.Console.ChatTimeout.Value() != 6*time.Minute { + t.Fatalf("console configuration was not applied: %#v", applied.Provider.Console) + } + if len(snapshot.RestartRequired) != 1 || snapshot.RestartRequired[0] != "audit.bufferSize" { + t.Fatalf("restartRequired = %#v", snapshot.RestartRequired) + } + reloaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if reloaded.Server.MaxConcurrentRequests != 2048 || reloaded.Provider.Build.ResponseHeaderTimeout.Value() != 7*time.Minute || reloaded.Routing.MaxAttempts != 5 || !reloaded.Routing.PreferFreeBuild || !reloaded.Routing.SegmentedSelectorEnabled || reloaded.Routing.SegmentedMinCandidates != 5000 || reloaded.Routing.SegmentedWindowSize != 96 || reloaded.Audit.BufferSize != input.Audit.BufferSize || reloaded.Media.MaxTotalBytes != 2<<30 || reloaded.Media.CleanupThresholdPercent != 75 || reloaded.Batch.SyncConcurrency != 28 || reloaded.Batch.RandomDelay.Value() != 750*time.Millisecond || reloaded.Provider.Console.BaseURL != "https://console.example.com" { + t.Fatalf("configuration was not persisted") + } +} + +func TestUpdateRejectsBuildResponseHeaderTimeoutOutsideSafeRange(t *testing.T) { + for _, value := range []string{"29s", "31m"} { + t.Run(value, func(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + input := service.Get().Config + input.ProviderBuild.ResponseHeaderTimeout = value + if _, err := service.Update(context.Background(), 0, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("error = %v", err) + } + if repository.found { + t.Fatal("invalid Build response header timeout was persisted") + } + }) + } +} + +func TestUpdateValidatesMaxAttemptsRange(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, repository, nil, func(next config.Config) { applied = next }) + + input := service.Get().Config + input.Routing.MaxAttempts = 200 + snapshot, err := service.Update(context.Background(), 0, input) + if err != nil { + t.Fatalf("maximum maxAttempts was rejected: %v", err) + } + if applied.Routing.MaxAttempts != 200 || snapshot.Config.Routing.MaxAttempts != 200 { + t.Fatalf("maximum maxAttempts was not applied: applied=%d snapshot=%d", applied.Routing.MaxAttempts, snapshot.Config.Routing.MaxAttempts) + } + + input = snapshot.Config + input.Routing.MaxAttempts = -1 + snapshot, err = service.Update(context.Background(), snapshot.Revision, input) + if err != nil { + t.Fatalf("unlimited maxAttempts was rejected: %v", err) + } + if applied.Routing.MaxAttempts != -1 || snapshot.Config.Routing.MaxAttempts != -1 { + t.Fatalf("unlimited maxAttempts was not applied: applied=%d snapshot=%d", applied.Routing.MaxAttempts, snapshot.Config.Routing.MaxAttempts) + } + + input = snapshot.Config + input.Routing.MaxAttempts = 201 + if _, err := service.Update(context.Background(), snapshot.Revision, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("maxAttempts above maximum error = %v", err) + } + if repository.value.Routing.MaxAttempts != -1 { + t.Fatalf("invalid maxAttempts was persisted: %d", repository.value.Routing.MaxAttempts) + } +} + +func TestLoadPersistedKeepsSegmentedSelectorDefaultsForOlderPayload(t *testing.T) { + cfg := testConfig(t) + cfg.Routing.SegmentedSelectorEnabled = true + cfg.Routing.SegmentedMinCandidates = 4321 + cfg.Routing.SegmentedWindowSize = 72 + value := toDomainConfig(cfg) + value.Routing.SegmentedSelector = nil + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if !loaded.Routing.SegmentedSelectorEnabled || loaded.Routing.SegmentedMinCandidates != 4321 || loaded.Routing.SegmentedWindowSize != 72 { + t.Fatalf("segmented selector defaults were not preserved: %#v", loaded.Routing) + } +} + +func TestLoadPersistedPreservesExplicitlyDisabledSegmentedSelector(t *testing.T) { + cfg := testConfig(t) + cfg.Routing.SegmentedSelectorEnabled = true + value := toDomainConfig(cfg) + value.Routing.SegmentedSelector = &settingsdomain.SegmentedSelectorConfig{ + ActiveEnabled: false, MinCandidates: 6000, WindowSize: 128, + } + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Routing.SegmentedSelectorEnabled || loaded.Routing.SegmentedMinCandidates != 6000 || loaded.Routing.SegmentedWindowSize != 128 { + t.Fatalf("explicit segmented selector settings were not preserved: %#v", loaded.Routing) + } +} + +func TestLegacyShadowSettingCannotEnableSegmentedSelector(t *testing.T) { + var persisted settingsdomain.Config + if err := json.Unmarshal([]byte(`{"Routing":{"SegmentedSelector":{"ActiveEnabled":false,"Enabled":true,"MinCandidates":3000,"WindowSize":64,"SamplePercent":100}}}`), &persisted); err != nil { + t.Fatal(err) + } + loaded := applyDomainConfig(testConfig(t), persisted) + if loaded.Routing.SegmentedSelectorEnabled { + t.Fatal("legacy shadow-only setting enabled the authoritative segmented selector") + } +} + +func TestLoadPersistedKeepsConsoleDefaultsWhenFieldIsMissing(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.ProviderConsole = settingsdomain.ProviderConsoleConfig{} + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Provider.Console != cfg.Provider.Console { + t.Fatalf("console config = %#v, want %#v", loaded.Provider.Console, cfg.Provider.Console) + } +} + +func TestLoadPersistedKeepsClearanceDefaultsForOlderPayload(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.ProviderWeb.ClearanceMode = "" + value.ProviderWeb.FlareSolverrURL = "" + value.ProviderWeb.ClearanceTimeout = 0 + value.ProviderWeb.ClearanceRefresh = 0 + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Provider.Web.ClearanceMode != cfg.Provider.Web.ClearanceMode || loaded.Provider.Web.FlareSolverrURL != cfg.Provider.Web.FlareSolverrURL || loaded.Provider.Web.ClearanceTimeout != cfg.Provider.Web.ClearanceTimeout || loaded.Provider.Web.ClearanceRefresh != cfg.Provider.Web.ClearanceRefresh { + t.Fatalf("clearance config = %#v, want %#v", loaded.Provider.Web, cfg.Provider.Web) + } +} + +func TestSnapshotIncludesRecommendedBuildBaseline(t *testing.T) { + service := NewService(testConfig(t), time.Time{}, 0, &runtimeSettingsRepositoryStub{}, nil, nil) + recommended := service.Get().RecommendedProviderBuild + if recommended.ClientVersion != config.RecommendedBuildClientVersion || recommended.UserAgent != config.RecommendedBuildUserAgent { + t.Fatalf("recommended build = %#v", recommended) + } +} + +func TestUpdateRejectsBatchConcurrencyOutsideSafeRange(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + input := service.Get().Config + input.Batch.ConversionConcurrency = 51 + if _, err := service.Update(context.Background(), 0, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("error = %v", err) + } + if repository.found { + t.Fatal("invalid batch settings were persisted") + } +} + +func TestBatchRandomDelayCanBeDisabledAndPersisted(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + input := service.Get().Config + input.Batch.RandomDelay = "0s" + if _, err := service.Update(context.Background(), 0, input); err != nil { + t.Fatal(err) + } + if repository.value.Batch.RandomDelay == nil || *repository.value.Batch.RandomDelay != 0 { + t.Fatalf("persisted random delay = %#v", repository.value.Batch.RandomDelay) + } + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Batch.RandomDelay.Value() != 0 { + t.Fatalf("loaded random delay = %s", loaded.Batch.RandomDelay.Value()) + } +} + +func TestUpdateRejectsInvalidDurationWithoutChangingConfig(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + input := service.Get().Config + input.Routing.StickyTTL = "tomorrow" + if _, err := service.Update(context.Background(), service.Get().Revision, input); err == nil { + t.Fatal("expected invalid duration error") + } + if service.Get().Config.Routing.StickyTTL != cfg.Routing.StickyTTL.String() || repository.found { + t.Fatal("invalid update changed or persisted runtime configuration") + } +} + +func TestStatsigManualValueIsWriteOnlyAndClearedByURLMode(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + manual := base64.RawStdEncoding.EncodeToString(make([]byte, 70)) + input := service.Get().Config + input.ProviderWeb.StatsigMode = config.StatsigModeManual + input.ProviderWeb.StatsigManualValue = manual + + snapshot, err := service.Update(context.Background(), service.Get().Revision, input) + if err != nil { + t.Fatal(err) + } + if snapshot.Config.ProviderWeb.StatsigManualValue != "" || !snapshot.Config.ProviderWeb.StatsigManualConfigured { + t.Fatalf("manual value leaked in snapshot: %#v", snapshot.Config.ProviderWeb) + } + if repository.value.ProviderWeb.StatsigManualValue != manual { + t.Fatal("manual value was not persisted") + } + + keep := service.Get().Config + if _, err := service.Update(context.Background(), service.Get().Revision, keep); err != nil { + t.Fatalf("blank write-only value did not preserve existing value: %v", err) + } + if repository.value.ProviderWeb.StatsigManualValue != manual { + t.Fatal("blank write-only value cleared the existing manual value") + } + + urlMode := service.Get().Config + urlMode.ProviderWeb.StatsigMode = config.StatsigModeURL + if _, err := service.Update(context.Background(), service.Get().Revision, urlMode); err != nil { + t.Fatal(err) + } + if repository.value.ProviderWeb.StatsigManualValue != "" { + t.Fatal("URL mode retained the manual x-statsig-id") + } +} + +func TestLoadPersistedRejectsIncompleteStatsigPayload(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.ProviderWeb.StatsigMode = "" + value.ProviderWeb.StatsigSignerURL = "" + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + if _, _, _, err := LoadPersisted(context.Background(), cfg, repository); err == nil { + t.Fatal("incomplete Statsig settings were accepted") + } +} + +func TestLoadPersistedRejectsIncompleteBatchPayload(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.Batch = settingsdomain.BatchConfig{} + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + if _, _, _, err := LoadPersisted(context.Background(), cfg, repository); err == nil { + t.Fatal("incomplete batch settings were accepted") + } +} + +func TestLoadPersistedBackfillsMissingServerConcurrency(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.Server = settingsdomain.ServerConfig{} + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Server.MaxConcurrentRequests != cfg.Server.MaxConcurrentRequests { + t.Fatalf("maxConcurrentRequests = %d, want %d", loaded.Server.MaxConcurrentRequests, cfg.Server.MaxConcurrentRequests) + } +} + +func TestLoadPersistedBackfillsMissingConsoleSection(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.ProviderConsole = settingsdomain.ProviderConsoleConfig{} + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Provider.Console != cfg.Provider.Console { + t.Fatalf("console = %#v, want %#v", loaded.Provider.Console, cfg.Provider.Console) + } +} + +func TestLoadPersistedRejectsPartiallyInvalidConsoleSection(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.ProviderConsole.BaseURL = "" + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + + if _, _, _, err := LoadPersisted(context.Background(), cfg, repository); err == nil { + t.Fatal("partially invalid Console settings were accepted") + } +} + +func TestReloadPersistedAppliesOnlyNewerVersion(t *testing.T) { + cfg := testConfig(t) + updatedAt := time.Now().UTC() + repository := &runtimeSettingsRepositoryStub{value: toDomainConfig(cfg), updatedAt: updatedAt, revision: 1, found: true} + applyCount := 0 + service := NewService(cfg, updatedAt, 1, repository, nil, func(config.Config) { applyCount++ }) + + if err := service.ReloadPersisted(context.Background()); err != nil { + t.Fatal(err) + } + if applyCount != 0 { + t.Fatalf("unchanged settings applied %d times", applyCount) + } + repository.value.Routing.MaxAttempts = cfg.Routing.MaxAttempts + 1 + repository.updatedAt = updatedAt.Add(time.Second) + repository.revision = 2 + if err := service.ReloadPersisted(context.Background()); err != nil { + t.Fatal(err) + } + if applyCount != 1 || service.Get().Config.Routing.MaxAttempts != cfg.Routing.MaxAttempts+1 { + t.Fatalf("newer settings were not applied") + } +} + +func TestUpdateRejectsStaleRevision(t *testing.T) { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + input := service.Get().Config + input.Routing.MaxAttempts++ + if _, err := service.Update(context.Background(), 1, input); !errors.Is(err, ErrConflict) { + t.Fatalf("stale revision error = %v", err) + } +} + +func testConfig(t *testing.T) config.Config { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(`secrets: + jwtSecret: "12345678901234567890123456789012" + credentialEncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + return cfg +} + +func TestLoadPersistedKeepsYAMLFrontendWhenUnset(t *testing.T) { + cfg := testConfig(t) + cfg.Frontend.PublicAPIBaseURL = "http://yaml.example.com" + value := toDomainConfig(cfg) + value.Frontend = settingsdomain.FrontendConfig{} + repository := &runtimeSettingsRepositoryStub{ + value: value, + found: true, revision: 1, updatedAt: time.Now().UTC(), + } + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if loaded.Frontend.PublicAPIBaseURL != "http://yaml.example.com" || loaded.Frontend.PublicAPIBaseURLOverride != "" || loaded.Frontend.EffectivePublicAPIBaseURL() != "http://yaml.example.com" { + t.Fatalf("frontend = %#v", loaded.Frontend) + } +} + +func TestUpdateEmptyFrontendOverrideFallsBackToYAML(t *testing.T) { + cfg := testConfig(t) + cfg.Frontend.PublicAPIBaseURL = "http://yaml.example.com" + cfg.Frontend.PublicAPIBaseURLOverride = "http://runtime.example.com" + repository := &runtimeSettingsRepositoryStub{} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, repository, nil, func(next config.Config) { applied = next }) + input := service.Get().Config + input.Frontend.PublicAPIBaseURL = "" + if _, err := service.Update(context.Background(), 0, input); err != nil { + t.Fatal(err) + } + if applied.Frontend.PublicAPIBaseURLOverride != "" || applied.Frontend.EffectivePublicAPIBaseURL() != "http://yaml.example.com" { + t.Fatalf("frontend fallback = %#v", applied.Frontend) + } + if repository.value.Frontend.PublicAPIBaseURL != "" { + t.Fatalf("persisted override = %q", repository.value.Frontend.PublicAPIBaseURL) + } +} + +func TestApplyDomainConfigAccountsDefaults(t *testing.T) { + base := testConfig(t) + // 旧持久化 JSON 无 Accounts 段时,应保持代码默认:关闭 + 10m + 1h。 + loaded := applyDomainConfig(base, settingsdomain.Config{ + Server: settingsdomain.ServerConfig{MaxConcurrentRequests: base.Server.MaxConcurrentRequests}, + ProviderBuild: settingsdomain.ProviderBuildConfig{ + BaseURL: base.Provider.Build.BaseURL, FallbackBaseURL: base.Provider.Build.FallbackBaseURL, + ClientVersion: base.Provider.Build.ClientVersion, ClientIdentifier: base.Provider.Build.ClientIdentifier, + TokenAuth: base.Provider.Build.TokenAuth, UserAgent: base.Provider.Build.UserAgent, + }, + ProviderWeb: settingsdomain.ProviderWebConfig{ + BaseURL: base.Provider.Web.BaseURL, QuotaTimeout: base.Provider.Web.QuotaTimeout.Value(), + StatsigMode: base.Provider.Web.StatsigMode, StatsigManualValue: base.Provider.Web.StatsigManualValue, + StatsigSignerURL: base.Provider.Web.StatsigSignerURL, + ChatTimeout: base.Provider.Web.ChatTimeout.Value(), ImageTimeout: base.Provider.Web.ImageTimeout.Value(), + VideoTimeout: base.Provider.Web.VideoTimeout.Value(), MediaConcurrency: base.Provider.Web.MediaConcurrency, + AllowNSFW: base.Provider.Web.AllowNSFW, + RecoveryBackoffBase: base.Provider.Web.RecoveryBackoffBase.Value(), RecoveryBackoffMax: base.Provider.Web.RecoveryBackoffMax.Value(), + }, + Batch: settingsdomain.BatchConfig{ + ImportConcurrency: base.Batch.ImportConcurrency, ConversionConcurrency: base.Batch.ConversionConcurrency, + SyncConcurrency: base.Batch.SyncConcurrency, RefreshConcurrency: base.Batch.RefreshConcurrency, + RandomDelay: func() *time.Duration { d := base.Batch.RandomDelay.Value(); return &d }(), + }, + Media: settingsdomain.MediaConfig{ + MaxImageBytes: base.Media.MaxImageBytes, MaxTotalBytes: base.Media.MaxTotalBytes, + CleanupThresholdPercent: base.Media.CleanupThresholdPercent, CleanupInterval: base.Media.CleanupInterval.Value(), + }, + Routing: settingsdomain.RoutingConfig{ + StickyTTL: base.Routing.StickyTTL.Value(), CooldownBase: base.Routing.CooldownBase.Value(), + CooldownMax: base.Routing.CooldownMax.Value(), CapacityWait: base.Routing.CapacityWait.Value(), + MaxAttempts: base.Routing.MaxAttempts, PreferFreeBuild: base.Routing.PreferFreeBuild, + }, + Audit: settingsdomain.AuditConfig{ + BufferSize: base.Audit.BufferSize, BatchSize: base.Audit.BatchSize, FlushInterval: base.Audit.FlushInterval.Value(), + }, + ClientKeyDefaults: settingsdomain.ClientKeyDefaultsConfig{ + RPMLimit: base.ClientKeyDefaults.RPMLimit, MaxConcurrent: base.ClientKeyDefaults.MaxConcurrent, + }, + }) + if loaded.Accounts.AutoCleanReauthEnabled || loaded.Accounts.AutoCleanIncludeDisabled { + t.Fatalf("accounts flags should stay false: %#v", loaded.Accounts) + } + if loaded.Accounts.AutoCleanReauthInterval.Value() != 10*time.Minute || loaded.Accounts.AutoCleanReauthMinAge.Value() != time.Hour { + t.Fatalf("accounts defaults = %#v", loaded.Accounts) + } + if loaded.Audit.CommitDelay.Value() != base.Audit.CommitDelay.Value() { + t.Fatalf("audit commit delay = %s", loaded.Audit.CommitDelay.Value()) + } +} + +func TestUpdateAuditCommitDelayRoundTrip(t *testing.T) { + cfg := testConfig(t) + repo := &runtimeSettingsRepositoryStub{} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, repo, nil, func(next config.Config) { applied = next }) + input := service.Get().Config + input.Audit.CommitDelayMS = 12 + snapshot, err := service.Update(context.Background(), service.Get().Revision, input) + if err != nil { + t.Fatal(err) + } + if applied.Audit.CommitDelay.Value() != 12*time.Millisecond || snapshot.Config.Audit.CommitDelayMS != 12 { + t.Fatalf("applied=%s snapshot=%d", applied.Audit.CommitDelay.Value(), snapshot.Config.Audit.CommitDelayMS) + } +} + +func TestUpdateRejectsNegativeAuditCommitDelay(t *testing.T) { + cfg := testConfig(t) + service := NewService(cfg, time.Time{}, 0, &runtimeSettingsRepositoryStub{}, nil, nil) + input := service.Get().Config + input.Audit.CommitDelayMS = -1 + if _, err := service.Update(context.Background(), service.Get().Revision, input); err == nil { + t.Fatal("negative audit commit delay was accepted") + } +} + +func TestUpdateAccountsAutoCleanRoundTrip(t *testing.T) { + cfg := testConfig(t) + repo := &runtimeSettingsRepositoryStub{} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, repo, nil, func(next config.Config) { applied = next }) + input := service.Get().Config + input.Accounts = AccountsConfig{ + MarkBuildForbiddenReauth: true, MarkBuildForbiddenReauthProvided: true, + BuildForbiddenReauthCodes: []string{" Permission-Denied ", "permission-denied", "team-access-denied"}, BuildForbiddenReauthCodesProvided: true, + AutoCleanReauthEnabled: true, AutoCleanReauthInterval: "5m", + AutoCleanReauthMinAge: "2h", AutoCleanIncludeDisabled: true, + } + snapshot, err := service.Update(context.Background(), service.Get().Revision, input) + if err != nil { + t.Fatal(err) + } + if !applied.Accounts.MarkBuildForbiddenReauth || !applied.Accounts.AutoCleanReauthEnabled || !applied.Accounts.AutoCleanIncludeDisabled { + t.Fatalf("applied accounts = %#v", applied.Accounts) + } + if len(applied.Accounts.BuildForbiddenReauthCodes) != 2 || applied.Accounts.BuildForbiddenReauthCodes[0] != "permission-denied" || applied.Accounts.BuildForbiddenReauthCodes[1] != "team-access-denied" { + t.Fatalf("normalized Build forbidden codes = %#v", applied.Accounts.BuildForbiddenReauthCodes) + } + if applied.Accounts.AutoCleanReauthInterval.Value() != 5*time.Minute || applied.Accounts.AutoCleanReauthMinAge.Value() != 2*time.Hour { + t.Fatalf("applied accounts durations = %#v", applied.Accounts) + } + if !snapshot.Config.Accounts.MarkBuildForbiddenReauth || !snapshot.Config.Accounts.AutoCleanReauthEnabled || snapshot.Config.Accounts.AutoCleanReauthInterval != "5m" { + t.Fatalf("snapshot accounts = %#v", snapshot.Config.Accounts) + } +} + +func TestUpdateWithoutAccountsPreservesCurrentAutoCleanConfig(t *testing.T) { + cfg := testConfig(t) + cfg.Accounts.AutoCleanReauthEnabled = true + cfg.Accounts.MarkBuildForbiddenReauth = true + cfg.Accounts.BuildForbiddenReauthCodes = []string{"custom-denial"} + cfg.Accounts.AutoCleanReauthInterval = config.Duration(7 * time.Minute) + cfg.Accounts.AutoCleanReauthMinAge = config.Duration(3 * time.Hour) + cfg.Accounts.AutoCleanIncludeDisabled = true + repo := &runtimeSettingsRepositoryStub{} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, repo, nil, func(next config.Config) { applied = next }) + input := service.Get().Config + input.Accounts = AccountsConfig{} + input.AccountsProvided = false + input.Server.MaxConcurrentRequests++ + if _, err := service.Update(context.Background(), 0, input); err != nil { + t.Fatal(err) + } + if !applied.Accounts.MarkBuildForbiddenReauth || !applied.Accounts.AutoCleanReauthEnabled || !applied.Accounts.AutoCleanIncludeDisabled || applied.Accounts.AutoCleanReauthInterval.Value() != 7*time.Minute || applied.Accounts.AutoCleanReauthMinAge.Value() != 3*time.Hour { + t.Fatalf("accounts changed by legacy update: %#v", applied.Accounts) + } +} + +func TestUpdateWithoutBuildForbiddenFieldPreservesCurrentPolicy(t *testing.T) { + cfg := testConfig(t) + cfg.Accounts.MarkBuildForbiddenReauth = true + cfg.Accounts.BuildForbiddenReauthCodes = []string{"custom-denial"} + var applied config.Config + service := NewService(cfg, time.Time{}, 0, &runtimeSettingsRepositoryStub{}, nil, func(next config.Config) { applied = next }) + input := service.Get().Config + input.Accounts.MarkBuildForbiddenReauth = false + input.Accounts.MarkBuildForbiddenReauthProvided = false + input.Accounts.BuildForbiddenReauthCodes = nil + input.Accounts.BuildForbiddenReauthCodesProvided = false + input.Server.MaxConcurrentRequests++ + if _, err := service.Update(context.Background(), 0, input); err != nil { + t.Fatal(err) + } + if !applied.Accounts.MarkBuildForbiddenReauth || len(applied.Accounts.BuildForbiddenReauthCodes) != 1 || applied.Accounts.BuildForbiddenReauthCodes[0] != "custom-denial" { + t.Fatalf("legacy update changed the Build forbidden-account policy: %#v", applied.Accounts) + } +} + +func TestLoadPersistedKeepsDefaultBuildForbiddenCodesForOlderPayload(t *testing.T) { + cfg := testConfig(t) + value := toDomainConfig(cfg) + value.Accounts.BuildForbiddenReauthCodes = nil + repository := &runtimeSettingsRepositoryStub{value: value, found: true} + loaded, _, _, err := LoadPersisted(context.Background(), cfg, repository) + if err != nil { + t.Fatal(err) + } + if len(loaded.Accounts.BuildForbiddenReauthCodes) != 1 || loaded.Accounts.BuildForbiddenReauthCodes[0] != "permission-denied" { + t.Fatalf("Build forbidden code defaults were not preserved: %#v", loaded.Accounts.BuildForbiddenReauthCodes) + } +} + +func TestUpdateRejectsInvalidBuildForbiddenCodes(t *testing.T) { + for _, codes := range [][]string{{}, {"contains spaces"}, func() []string { + values := make([]string, 33) + for index := range values { + values[index] = fmt.Sprintf("denial-%d", index) + } + return values + }()} { + cfg := testConfig(t) + repository := &runtimeSettingsRepositoryStub{} + service := NewService(cfg, time.Time{}, 0, repository, nil, nil) + input := service.Get().Config + input.Accounts.BuildForbiddenReauthCodes = codes + input.Accounts.BuildForbiddenReauthCodesProvided = true + if _, err := service.Update(context.Background(), 0, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("codes %#v: error = %v", codes, err) + } + if repository.found { + t.Fatalf("invalid codes were persisted: %#v", codes) + } + } +} diff --git a/backend/internal/application/updatecheck/service.go b/backend/internal/application/updatecheck/service.go new file mode 100644 index 0000000000000000000000000000000000000000..523875a12a2181f39d49b542f47e437a64ccd438 --- /dev/null +++ b/backend/internal/application/updatecheck/service.go @@ -0,0 +1,263 @@ +package updatecheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + latestReleaseAPI = "https://api.github.com/repos/chenyme/grok2api/releases/latest" + maxReleaseBytes = 1 << 20 + maxNotesRunes = 4096 +) + +type Status string + +const ( + StatusUnchecked Status = "unchecked" + StatusUpToDate Status = "up_to_date" + StatusUpdateAvailable Status = "update_available" + StatusCheckFailed Status = "check_failed" +) + +type Snapshot struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + Status Status `json:"status"` + CheckedAt *time.Time `json:"checkedAt"` + ReleaseURL string `json:"releaseUrl"` + ReleaseNotes string `json:"releaseNotes"` + Error string `json:"error"` +} + +type Service struct { + current string + client *http.Client + now func() time.Time + + mu sync.RWMutex + snapshot Snapshot + checks singleflight.Group +} + +func NewService(currentVersion string, client *http.Client) *Service { + currentVersion = strings.TrimSpace(currentVersion) + if currentVersion == "" { + currentVersion = "dev" + } + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + return &Service{ + current: currentVersion, + client: client, + now: time.Now, + snapshot: Snapshot{ + CurrentVersion: currentVersion, + Status: StatusUnchecked, + }, + } +} + +func (s *Service) Snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return cloneSnapshot(s.snapshot) +} + +func (s *Service) Check(ctx context.Context) Snapshot { + result, err, _ := s.checks.Do("latest", func() (any, error) { + return s.fetchLatest(ctx) + }) + s.mu.Lock() + defer s.mu.Unlock() + if err != nil { + s.snapshot.Status = StatusCheckFailed + s.snapshot.Error = err.Error() + return cloneSnapshot(s.snapshot) + } + release := result.(latestRelease) + checkedAt := s.now().UTC() + current, currentOK := parseSemanticVersion(s.current) + latest, latestOK := parseSemanticVersion(release.Tag) + if !currentOK || !latestOK { + s.snapshot.LatestVersion = release.Tag + s.snapshot.ReleaseURL = release.URL + s.snapshot.ReleaseNotes = release.Notes + s.snapshot.Status = StatusCheckFailed + s.snapshot.Error = "当前版本或最新版本不是有效的语义化版本,无法比较" + return cloneSnapshot(s.snapshot) + } + available := compareSemanticVersion(latest, current) > 0 + s.snapshot = Snapshot{ + CurrentVersion: s.current, LatestVersion: release.Tag, + UpdateAvailable: available, CheckedAt: &checkedAt, + ReleaseURL: release.URL, ReleaseNotes: release.Notes, + Status: StatusUpToDate, + } + if available { + s.snapshot.Status = StatusUpdateAvailable + } + return cloneSnapshot(s.snapshot) +} + +type latestRelease struct { + Tag string + URL string + Notes string +} + +func (s *Service) fetchLatest(ctx context.Context) (latestRelease, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseAPI, nil) + if err != nil { + return latestRelease{}, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "grok2api/"+s.current) + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + response, err := s.client.Do(request) + if err != nil { + return latestRelease{}, fmt.Errorf("检查 GitHub Release 失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return latestRelease{}, fmt.Errorf("GitHub Release 检查失败(HTTP %d)", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxReleaseBytes+1)) + if err != nil { + return latestRelease{}, fmt.Errorf("读取 GitHub Release 响应: %w", err) + } + if len(data) > maxReleaseBytes { + return latestRelease{}, errors.New("GitHub Release 响应超过安全上限") + } + var payload struct { + Tag string `json:"tag_name"` + Body string `json:"body"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return latestRelease{}, fmt.Errorf("解析 GitHub Release 响应: %w", err) + } + payload.Tag = strings.TrimSpace(payload.Tag) + if payload.Tag == "" { + return latestRelease{}, errors.New("GitHub Release 未返回版本号") + } + return latestRelease{ + Tag: payload.Tag, + URL: "https://github.com/chenyme/grok2api/releases/tag/" + url.PathEscape(payload.Tag), + Notes: truncateRunes(strings.TrimSpace(payload.Body), maxNotesRunes), + }, nil +} + +type semanticVersion struct { + major, minor, patch uint64 + prerelease string +} + +func parseSemanticVersion(value string) (semanticVersion, bool) { + value = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "v")) + if before, _, ok := strings.Cut(value, "+"); ok { + value = before + } + prerelease := "" + if before, after, ok := strings.Cut(value, "-"); ok { + value, prerelease = before, after + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return semanticVersion{}, false + } + numbers := make([]uint64, 3) + for index, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return semanticVersion{}, false + } + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return semanticVersion{}, false + } + numbers[index] = value + } + return semanticVersion{major: numbers[0], minor: numbers[1], patch: numbers[2], prerelease: prerelease}, true +} + +func compareSemanticVersion(left, right semanticVersion) int { + for _, pair := range [][2]uint64{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if left.prerelease == right.prerelease { + return 0 + } + leftHotfix, leftHotfixNumber := projectHotfix(left.prerelease) + rightHotfix, rightHotfixNumber := projectHotfix(right.prerelease) + if leftHotfix && rightHotfix { + if leftHotfixNumber < rightHotfixNumber { + return -1 + } + if leftHotfixNumber > rightHotfixNumber { + return 1 + } + return 0 + } + if leftHotfix { + return 1 + } + if rightHotfix { + return -1 + } + if left.prerelease == "" { + return 1 + } + if right.prerelease == "" { + return -1 + } + return strings.Compare(left.prerelease, right.prerelease) +} + +func projectHotfix(value string) (bool, uint64) { + const prefix = "hotfix." + if !strings.HasPrefix(value, prefix) { + return false, 0 + } + part := strings.TrimPrefix(value, prefix) + if part == "" || (len(part) > 1 && part[0] == '0') { + return false, 0 + } + number, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return false, 0 + } + return true, number +} + +func truncateRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func cloneSnapshot(value Snapshot) Snapshot { + if value.CheckedAt != nil { + checkedAt := *value.CheckedAt + value.CheckedAt = &checkedAt + } + return value +} diff --git a/backend/internal/application/updatecheck/service_test.go b/backend/internal/application/updatecheck/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f99af2d78bd68651850433eb960aef78e80786f3 --- /dev/null +++ b/backend/internal/application/updatecheck/service_test.go @@ -0,0 +1,90 @@ +package updatecheck + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func TestCheckFindsLatestRelease(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != latestReleaseAPI || request.Header.Get("User-Agent") != "grok2api/v3.0.0" { + t.Fatalf("request = %#v", request) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v3.0.1","body":"Release notes"}`)), Header: make(http.Header)}, nil + })} + service := NewService("v3.0.0", client) + now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) + service.now = func() time.Time { return now } + snapshot := service.Check(context.Background()) + if snapshot.Status != StatusUpdateAvailable || !snapshot.UpdateAvailable || snapshot.LatestVersion != "v3.0.1" || snapshot.CheckedAt == nil || !snapshot.CheckedAt.Equal(now) { + t.Fatalf("snapshot = %#v", snapshot) + } + if snapshot.ReleaseURL != "https://github.com/chenyme/grok2api/releases/tag/v3.0.1" || snapshot.ReleaseNotes != "Release notes" { + t.Fatalf("release = %#v", snapshot) + } +} + +func TestCheckFailureKeepsLastSuccessfulRelease(t *testing.T) { + fail := false + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + if fail { + return nil, errors.New("network down") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v3.0.0","body":"Stable"}`)), Header: make(http.Header)}, nil + })} + service := NewService("v3.0.0", client) + first := service.Check(context.Background()) + fail = true + second := service.Check(context.Background()) + if first.Status != StatusUpToDate || second.Status != StatusCheckFailed || second.LatestVersion != "v3.0.0" || second.CheckedAt == nil || second.Error == "" { + t.Fatalf("first=%#v second=%#v", first, second) + } +} + +func TestSemanticVersionComparison(t *testing.T) { + stable, ok := parseSemanticVersion("v3.0.1") + if !ok { + t.Fatal("stable version was rejected") + } + older, _ := parseSemanticVersion("3.0.0") + prerelease, _ := parseSemanticVersion("v3.0.1-rc.1") + if compareSemanticVersion(stable, older) <= 0 || compareSemanticVersion(prerelease, stable) >= 0 { + t.Fatal("semantic version ordering is invalid") + } + if _, ok := parseSemanticVersion("dev"); ok { + t.Fatal("development version was accepted as semver") + } + base, _ := parseSemanticVersion("v3.0.8") + hotfix1, _ := parseSemanticVersion("v3.0.8-hotfix.1") + hotfix2, _ := parseSemanticVersion("v3.0.8-hotfix.2") + sameBasePrerelease, _ := parseSemanticVersion("v3.0.8-rc.1") + next, _ := parseSemanticVersion("v3.0.9") + if compareSemanticVersion(hotfix1, base) <= 0 || compareSemanticVersion(hotfix2, hotfix1) <= 0 || compareSemanticVersion(next, hotfix2) <= 0 { + t.Fatal("project hotfix ordering is invalid") + } + if compareSemanticVersion(hotfix1, sameBasePrerelease) <= 0 { + t.Fatal("project hotfix should follow ordinary prereleases") + } +} + +func TestCheckFindsHotfixAfterStableRelease(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v3.0.8-hotfix.1"}`)), Header: make(http.Header)}, nil + })} + service := NewService("v3.0.8", client) + snapshot := service.Check(context.Background()) + if snapshot.Status != StatusUpdateAvailable || !snapshot.UpdateAvailable { + t.Fatalf("snapshot = %#v", snapshot) + } +} diff --git a/backend/internal/buildinfo/version.go b/backend/internal/buildinfo/version.go new file mode 100644 index 0000000000000000000000000000000000000000..36543956214cdc2018ac8fe4faa52fbd60099572 --- /dev/null +++ b/backend/internal/buildinfo/version.go @@ -0,0 +1,45 @@ +package buildinfo + +import ( + "os" + "path/filepath" + "runtime/debug" + "strings" +) + +// Version 可在发布构建时通过 -ldflags -X 注入。 +var Version string + +// CurrentVersion 返回当前运行实例的版本。源码运行优先读取仓库 VERSION, +// 容器和发行包可将 VERSION 放在可执行文件同目录。 +func CurrentVersion() string { + if value := cleanVersion(Version); value != "" { + return value + } + if value := cleanVersion(os.Getenv("GROK2API_VERSION")); value != "" { + return value + } + candidates := []string{"VERSION", filepath.Join("..", "VERSION")} + if executable, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(executable), "VERSION")) + } + for _, candidate := range candidates { + if data, err := os.ReadFile(candidate); err == nil { + if value := cleanVersion(string(data)); value != "" { + return value + } + } + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + return "dev" +} + +func cleanVersion(value string) string { + value = strings.TrimSpace(value) + if len(value) > 128 || strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return "" + } + return value +} diff --git a/backend/internal/cli/run.go b/backend/internal/cli/run.go new file mode 100644 index 0000000000000000000000000000000000000000..fa6d2dbebb230db9a9e049ddf650effdeab3622a --- /dev/null +++ b/backend/internal/cli/run.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/chenyme/grok2api/backend/internal/app" + "github.com/chenyme/grok2api/backend/internal/infra/config" + "github.com/chenyme/grok2api/backend/internal/infra/observability" +) + +// Run 解析启动参数并运行后端服务。 +func Run(args []string) error { + options, err := parseOptions(args) + if err != nil { + return err + } + cfg, err := config.Load(options.configPath) + if err != nil { + return err + } + if options.listen != "" { + cfg.Server.Listen = options.listen + if err := cfg.Validate(); err != nil { + return err + } + } + logger := observability.NewLogger() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + application, err := app.New(ctx, cfg, logger) + if err != nil { + return err + } + defer application.Close() + return application.Run(ctx) +} + +type runOptions struct { + configPath string + listen string +} + +func parseOptions(args []string) (runOptions, error) { + options := runOptions{configPath: defaultConfigPath()} + for index := 0; index < len(args); index++ { + switch args[index] { + case "--config": + if index+1 >= len(args) { + return runOptions{}, errors.New("--config 缺少路径") + } + options.configPath = args[index+1] + index++ + case "--listen": + if index+1 >= len(args) { + return runOptions{}, errors.New("--listen 缺少地址") + } + options.listen = args[index+1] + index++ + default: + return runOptions{}, fmt.Errorf("不支持的启动参数: %s", args[index]) + } + } + return options, nil +} + +func defaultConfigPath() string { + for _, candidate := range []string{"config.yaml", filepath.Join("..", "config.yaml")} { + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() { + return candidate + } + } + return "config.yaml" +} diff --git a/backend/internal/cli/run_test.go b/backend/internal/cli/run_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b8d74b5dde493ee3c8fab70012a744c25c296797 --- /dev/null +++ b/backend/internal/cli/run_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfigPathFindsRepositoryRoot(t *testing.T) { + root := t.TempDir() + backendDir := filepath.Join(root, "backend") + if err := os.Mkdir(backendDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte("server: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(backendDir) + + if path := defaultConfigPath(); path != filepath.Join("..", "config.yaml") { + t.Fatalf("defaultConfigPath() = %q", path) + } +} + +func TestParseOptionsSupportsContainerListenOverride(t *testing.T) { + options, err := parseOptions([]string{"--config", "/app/config.yaml", "--listen", "0.0.0.0:8000"}) + if err != nil { + t.Fatal(err) + } + if options.configPath != "/app/config.yaml" || options.listen != "0.0.0.0:8000" { + t.Fatalf("options = %#v", options) + } + if _, err := parseOptions([]string{"--listen"}); err == nil { + t.Fatal("missing --listen value was accepted") + } +} diff --git a/backend/internal/domain/account/account.go b/backend/internal/domain/account/account.go new file mode 100644 index 0000000000000000000000000000000000000000..f6608266203881f5e00d0c92ef370d60c2a85f02 --- /dev/null +++ b/backend/internal/domain/account/account.go @@ -0,0 +1,546 @@ +package account + +import ( + "crypto/sha256" + "encoding/binary" + "strings" + "time" +) + +// Provider 表示上游能力来源。 +type Provider string + +const ( + ProviderBuild Provider = "grok_build" + ProviderWeb Provider = "grok_web" + ProviderConsole Provider = "grok_console" +) + +// LinkedAccount 表示同一上游用户在另一 Provider 下的弱关联账号。 +// 关联只用于出口身份与管理端展示,不共享额度、健康或路由状态。 +type LinkedAccount struct { + ID uint64 + Provider Provider + Name string + Email string + UserID string +} + +var providers = [...]Provider{ProviderBuild, ProviderWeb, ProviderConsole} + +// Providers 返回按产品展示和后台维护顺序排列的稳定 Provider 集合。 +func Providers() []Provider { + return append([]Provider(nil), providers[:]...) +} + +// IsValid 判断 Provider 是否属于当前系统固定支持的渠道。 +func (p Provider) IsValid() bool { + switch p { + case ProviderBuild, ProviderWeb, ProviderConsole: + return true + default: + return false + } +} + +// ModelNamespace 返回内部模型路由使用的稳定渠道命名空间。 +func (p Provider) ModelNamespace() string { + switch p { + case ProviderBuild: + return "Build" + case ProviderWeb: + return "Web" + case ProviderConsole: + return "Console" + default: + return "" + } +} + +type AuthType string + +const ( + AuthTypeOAuth AuthType = "oauth" + AuthTypeSSO AuthType = "sso" +) + +type WebTier string + +const ( + WebTierAuto WebTier = "auto" + WebTierBasic WebTier = "basic" + WebTierSuper WebTier = "super" + WebTierHeavy WebTier = "heavy" +) + +// BuildRouteMode 控制 Build 账号的推理地址;模型、Billing 与 OAuth 不受影响。 +type BuildRouteMode string + +// CurrentWebTermsVersion 是 Grok Web 当前要求接受的产品服务协议版本。 +// accounts.x.ai 的账号协议使用独立版本,不与该值混用。 +const CurrentWebTermsVersion = 5 + +const ( + BuildRouteAuto BuildRouteMode = "auto" + BuildRouteBuild BuildRouteMode = "build" + BuildRouteXAI BuildRouteMode = "xai" +) + +func (m BuildRouteMode) IsValid() bool { + switch m { + case BuildRouteAuto, BuildRouteBuild, BuildRouteXAI: + return true + default: + return false + } +} + +const ( + DefaultPriority = 1 + DefaultMaxConcurrent = 8 + DefaultMinimumRemaining = 0 + MaxConcurrent = 256 +) + +// AuthStatus 表示账号凭据的认证状态。 +type AuthStatus string + +const ( + AuthStatusActive AuthStatus = "active" + AuthStatusReauthRequired AuthStatus = "reauthRequired" +) + +// EgressAssignmentMode 表示账号出口节点的维护方式。手工绑定绝不会被 +// 自动均衡任务迁移,自动绑定才允许在健康或容量变化时重新分配。 +type EgressAssignmentMode string + +const ( + EgressAssignmentManual EgressAssignmentMode = "manual" + EgressAssignmentAuto EgressAssignmentMode = "auto" +) + +func (value EgressAssignmentMode) IsValid() bool { + return value == EgressAssignmentManual || value == EgressAssignmentAuto +} + +// Credential 表示持久化的上游 OAuth 账号。 +type Credential struct { + ID uint64 + Provider Provider + AuthType AuthType + Name string + Email string + UserID string + TeamID string + SourceKey string + OIDCClientID string + EncryptedAccessToken string + EncryptedRefreshToken string + EncryptedCloudflareCookie string + ExpiresAt time.Time + RefreshDueAt *time.Time + LastRefreshAt *time.Time + RefreshFailureCount int + LastRefreshErrorStatus int + LastRefreshErrorCode string + LastRefreshErrorMessage string + LastRefreshErrorResponse string + RefreshPermanent bool + Enabled bool + AuthStatus AuthStatus + // ReauthMarkedAt 仅在切入 reauthRequired 时写入;恢复 active 时清空。自动清理以该时刻为 minAge 锚点。 + ReauthMarkedAt *time.Time + Priority int + MaxConcurrent int + MinimumRemaining float64 + FailureCount int + CooldownUntil *time.Time + LastError string + LastUsedAt *time.Time + ObservedModel string + ObservedModelAt *time.Time + WebTier WebTier + WebTierSyncedAt *time.Time + // EgressIdentity 是不含凭据和个人信息的稳定出口身份。 + // 关联到同一 Web 账号的 Build/Console 只共享该值,不共享任何运行状态。 + EgressIdentity string + // EgressNodeID 是账号显式绑定的出口节点。0 表示沿用当前 scope 的 + // 池选择逻辑;非零值必须优先使用该节点,不能悄悄回退到其他代理。 + EgressNodeID uint64 + EgressAssignmentMode EgressAssignmentMode + EgressAssignedAt *time.Time + // WebNSFWEnabledAt 记录 Grok Web 上游首次确认 NSFW 已成功开启的时间。 + // 普通导入、额度同步和凭据更新不得清除。 + WebNSFWEnabledAt *time.Time + // WebTermsAcceptedAt 记录 Grok Web 上游确认当前版本完整服务协议已接受的时间。 + // 关联渠道只共享该展示状态,不获得修改 Web 资料的能力。 + WebTermsAcceptedAt *time.Time + // WebTermsAcceptedVersion 记录已完成的 Grok Web 产品协议版本。 + // 历史数据默认为 0,必须补执行当前版本后才视为完整接受。 + WebTermsAcceptedVersion int + // WebBirthDateSetAt 记录 Grok Web 上游首次确认生日已设置的时间。 + // 该字段用于避免批量脚本重复请求不可修改的生日接口。 + WebBirthDateSetAt *time.Time + LinkedAccountID uint64 + LinkedAccountName string + LinkedProvider Provider + LinkedAccounts []LinkedAccount + // BuildAPIFallback 仅记录 grok_build 曾因当次 Build 403 成功回退到 XAI。 + // 它不参与路由;每个新请求仍先走 Build,只有当次严格 403 才可尝试 XAI。 + // token refresh / SSO 转换 / 普通 upsert / 重启不得清除。 + BuildAPIFallback bool + // BuildRouteMode 是管理员设置的账号级推理地址策略。 + // auto 使用 bot flag / Build 403 自动规则;build 与 xai 分别强制单一地址。 + BuildRouteMode BuildRouteMode + // BuildSuperEntitled 仅对 grok_build 有效:管理员已确认该账号具备 Super/1.5 entitlement。 + // 不替代 Billing 快照,不等同于 BuildAPIFallback,也不表示请求应走 XAI。 + // 普通导入/upsert/token refresh/SSO 转换不得清除;仅显式管理员 PATCH 可改。 + BuildSuperEntitled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// CredentialMaterial contains the encrypted provider secrets and refresh +// metadata loaded only after routing selects an account. +type CredentialMaterial struct { + AccountID uint64 + Provider Provider + AuthType AuthType + OIDCClientID string + EncryptedAccessToken string + EncryptedRefreshToken string + EncryptedCloudflareCookie string + ExpiresAt time.Time + RefreshDueAt *time.Time + LastRefreshAt *time.Time + RefreshFailureCount int + LastRefreshErrorStatus int + LastRefreshErrorCode string + LastRefreshErrorMessage string + LastRefreshErrorResponse string + RefreshPermanent bool + UpdatedAt time.Time +} + +// ApplyTo merges credential material into the matching routing account. +// A mismatch leaves the value unchanged so callers cannot attach one +// account's secrets to another account. +func (m CredentialMaterial) ApplyTo(value Credential) (Credential, bool) { + if m.AccountID == 0 || value.ID != m.AccountID || m.Provider == "" || value.Provider != m.Provider { + return value, false + } + value.AuthType = m.AuthType + value.OIDCClientID = m.OIDCClientID + value.EncryptedAccessToken = m.EncryptedAccessToken + value.EncryptedRefreshToken = m.EncryptedRefreshToken + value.EncryptedCloudflareCookie = m.EncryptedCloudflareCookie + value.ExpiresAt = m.ExpiresAt + value.RefreshDueAt = m.RefreshDueAt + value.LastRefreshAt = m.LastRefreshAt + value.RefreshFailureCount = m.RefreshFailureCount + value.LastRefreshErrorStatus = m.LastRefreshErrorStatus + value.LastRefreshErrorCode = m.LastRefreshErrorCode + value.LastRefreshErrorMessage = m.LastRefreshErrorMessage + value.LastRefreshErrorResponse = m.LastRefreshErrorResponse + value.RefreshPermanent = m.RefreshPermanent + return value, true +} + +// CredentialRefreshDueAt 将账号稳定地分散到到期前 5~8 分钟,避免同批导入账号同时刷新。 +func CredentialRefreshDueAt(accountID uint64, expiresAt time.Time) time.Time { + if expiresAt.IsZero() { + return time.Time{} + } + var identity [8]byte + binary.BigEndian.PutUint64(identity[:], accountID) + digest := sha256.Sum256(identity[:]) + jitterSeconds := binary.BigEndian.Uint16(digest[:2]) % 181 + return expiresAt.UTC().Add(-5*time.Minute - time.Duration(jitterSeconds)*time.Second) +} + +type QuotaSource string + +const ( + QuotaSourceDefault QuotaSource = "default" + QuotaSourceEstimated QuotaSource = "estimated" + QuotaSourceUpstream QuotaSource = "upstream" +) + +// QuotaWindow 表示 Grok Web 单个模式的请求额度窗口。 +type QuotaWindow struct { + AccountID uint64 + Mode string + Remaining int + Total int + UsagePercent float64 + Breakdown []QuotaBreakdown + WindowSeconds int + ResetAt *time.Time + SyncedAt *time.Time + Source QuotaSource + UpdatedAt time.Time +} + +// QuotaBreakdown 保存上游周额度中的产品枚举及其使用百分比。 +type QuotaBreakdown struct { + ProductCode int + UsagePercent float64 +} + +const ( + QuotaProductThirdParty = 0 + QuotaProductAPI = 1 + QuotaProductBuild = 2 + QuotaProductPlugins = 3 + QuotaProductChat = 4 + QuotaProductImagine = 5 + QuotaProductVoice = 6 +) + +type QuotaRecoveryEvent struct { + AccountID uint64 + Mode string + DueAt time.Time + Attempts int + ClaimToken string +} + +type BillingHistoryEntry struct { + Year int + Month int + PeriodType string + PeriodStart string + PeriodEnd string + IncludedUsed float64 + OnDemandUsed float64 + TotalUsed float64 +} + +// Billing 表示账号最近一次额度快照。 +type Billing struct { + AccountID uint64 + PlanCode string + PlanName string + MonthlyLimit float64 + Used float64 + OnDemandCap float64 + OnDemandUsed float64 + PrepaidBalance float64 + CreditUsagePercent float64 + IsUnifiedBillingUser bool + OnDemandEnabled *bool + TopUpMethod string + UsagePeriodType string + UsagePeriodStart string + UsagePeriodEnd string + BillingPeriodStart string + BillingPeriodEnd string + History []BillingHistoryEntry + SyncedAt time.Time +} + +// PeriodEnd 返回上游账期结束时间,无法解析时返回 false。 +func (b Billing) PeriodEnd() (time.Time, bool) { + if b.CreditUsagePercent >= 100 { + if value, ok := parseBillingTime(b.UsagePeriodEnd); ok { + return value, true + } + } + return parseBillingTime(b.BillingPeriodEnd) +} + +func parseBillingTime(raw string) (time.Time, bool) { + if raw == "" { + return time.Time{}, false + } + value, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return value.UTC(), true +} + +// QuotaRecoveryKind 区分需要真实流量探测的 Free 额度和需要 Billing 探测的付费账期。 +type QuotaRecoveryKind string + +const ( + QuotaRecoveryKindFree QuotaRecoveryKind = "free" + QuotaRecoveryKindPaid QuotaRecoveryKind = "paid" +) + +// QuotaRecoveryStatus 表示 Free 额度耗尽后的持久化恢复状态。 +type QuotaRecoveryStatus string + +const ( + QuotaRecoveryStatusActive QuotaRecoveryStatus = "active" + QuotaRecoveryStatusExhausted QuotaRecoveryStatus = "exhausted" + QuotaRecoveryStatusProbing QuotaRecoveryStatus = "probing" +) + +// QuotaRecovery 保存额度耗尽后的单次恢复探测状态。 +type QuotaRecovery struct { + AccountID uint64 + Kind QuotaRecoveryKind + Status QuotaRecoveryStatus + ConfirmedUsed int64 + ConfirmedLimit int64 + ExhaustedAt *time.Time + NextProbeAt *time.Time + LastConfirmedAt *time.Time + UpdatedAt time.Time +} + +// RoutingCandidate 聚合账号选择热路径所需的持久化快照。 +type RoutingCandidate struct { + Credential Credential + Billing *Billing + QuotaWindow *QuotaWindow + QuotaRecovery *QuotaRecovery + ModelQuotaBlock *ModelQuotaBlock + ModelCapabilityKnown bool + SupportsModel bool +} + +// RoutingAccountBase contains provider-level routing state reusable across +// models. Credential material is hydrated only after an account is selected. +type RoutingAccountBase struct { + Credential Credential + Billing *Billing + QuotaRecovery *QuotaRecovery + QuotaWindow *QuotaWindow +} + +// RoutingAccountOverlay contains model-specific eligibility state. +type RoutingAccountOverlay struct { + AccountID uint64 + Bound bool + ModelCapabilityKnown bool + SupportsModel bool + ModelQuotaBlock *ModelQuotaBlock +} + +type RoutingOverlaySnapshot struct { + HasBindings bool + Values []RoutingAccountOverlay +} + +// ModelQuotaBlock 表示账号的单模型配额暂不可用,不影响该账号上的其他模型。 +type ModelQuotaBlock struct { + AccountID uint64 + UpstreamModel string + Reason string + CooldownUntil time.Time + UpdatedAt time.Time +} + +// DeviceSession 表示一次短期 Device OAuth 授权流程。 +type DeviceSession struct { + ID string + DeviceCode string + UserCode string + VerificationURI string + VerificationURIComplete string + Interval time.Duration + NextPollAt time.Time + ExpiresAt time.Time +} + +// Remaining 返回当前月剩余额度。 +func (b Billing) Remaining() float64 { + remaining := b.MonthlyLimit - b.Used + if remaining < 0 { + return 0 + } + return remaining +} + +// IsPaid 判断 Billing 快照是否呈现 Super/paid 信号。 +// 语义与 SQL accountPaidBillingPredicate 及 QuotaView paid 分支一致: +// 已确认的付费订阅名称,或任一付费额度字段为正,即为 paid。 +// creditUsagePercent 只是当前周期使用率,Free 与 paid 都可能存在,不能参与判级。 +// 无快照时应由调用方按 Unknown 处理,不得调用本方法。 +// 注意:零 Billing + 管理员确认的 BuildSuperEntitled 由 IsBuildSuper 统一判定,不经本方法。 +func (b Billing) IsPaid() bool { + return isPaidBillingPlan(b.PlanCode) || isPaidBillingPlan(b.PlanName) || + b.MonthlyLimit > 0 || b.OnDemandCap > 0 || b.OnDemandUsed > 0 || b.PrepaidBalance > 0 +} + +// HasFreeProfileSignal accepts an explicit Free or Basic plan name. +func (b Billing) HasFreeProfileSignal() bool { + return isFreeBillingPlan(b.PlanCode) || isFreeBillingPlan(b.PlanName) +} + +// HasInferredFreeProfileSignal accepts a successful zero-value billing snapshot +// with no plan name. The upstream omits the Free plan name in this response. +// A non-zero usage or paid balance remains unknown unless another Free signal +// is available, preventing ordinary weekly billing from being misclassified. +func (b Billing) HasInferredFreeProfileSignal() bool { + if b.SyncedAt.IsZero() || strings.TrimSpace(b.PlanCode) != "" || strings.TrimSpace(b.PlanName) != "" { + return false + } + return b.MonthlyLimit == 0 && b.Used == 0 && + b.OnDemandCap == 0 && b.OnDemandUsed == 0 && + b.PrepaidBalance == 0 && b.CreditUsagePercent == 0 +} + +func normalizeBillingPlan(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + return strings.NewReplacer(" ", "", "_", "", "-", "", "+", "plus").Replace(value) +} + +func isPaidBillingPlan(value string) bool { + switch normalizeBillingPlan(value) { + case "super", "supergrok", "supergrokpro", "supergrokheavy", "supergroklite", + "grokpro", "xpremium", "xpremiumplus", "apikey": + return true + default: + return false + } +} + +func isFreeBillingPlan(value string) bool { + switch normalizeBillingPlan(value) { + case "free", "grokfree", "freetier", "basic", "grokbasic", "xbasic": + return true + default: + return false + } +} + +// IsBuildSuper 判定 Grok Build 账号是否为 Super:Billing IsPaid 或管理员确认 BuildSuperEntitled。 +// 非 Build Provider 恒为 false。与 SQL accountBuildSuperPredicate 语义一致。 +func IsBuildSuper(credential Credential, billing *Billing) bool { + if credential.Provider != ProviderBuild { + return false + } + if credential.BuildSuperEntitled { + return true + } + return billing != nil && billing.IsPaid() +} + +// IsKnownFreeBuild 判断候选是否是已确认的 Grok Build Free 账号。 +// Super(Billing paid 或 BuildSuperEntitled)优先,避免旧的响应模型或恢复记录把 Super 错分为 Free。 +func (c RoutingCandidate) IsKnownFreeBuild() bool { + if c.Credential.Provider != ProviderBuild { + return false + } + if IsBuildSuper(c.Credential, c.Billing) { + return false + } + if c.QuotaRecovery != nil && c.QuotaRecovery.Kind == QuotaRecoveryKindFree { + return true + } + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(c.Credential.ObservedModel)), "-build-free") { + return true + } + return c.Billing != nil && (c.Billing.HasFreeProfileSignal() || c.Billing.HasInferredFreeProfileSignal()) +} + +// IsExhausted 判断额度快照是否已达到账号保留阈值。 +func (b Billing) IsExhausted(minimum float64) bool { + if b.MonthlyLimit > 0 && b.Remaining() <= minimum { + return true + } + return b.CreditUsagePercent >= 100 && (b.OnDemandCap > 0 || b.UsagePeriodType != "") +} diff --git a/backend/internal/domain/account/account_test.go b/backend/internal/domain/account/account_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5abad468c2af9d43dacf80fd9169f2d254d6dccf --- /dev/null +++ b/backend/internal/domain/account/account_test.go @@ -0,0 +1,137 @@ +package account + +import ( + "testing" + "time" +) + +func TestBillingIsPaidMatchesSQLSignals(t *testing.T) { + if (Billing{}).IsPaid() { + t.Fatal("empty billing must be Unknown/free, not paid") + } + for _, billing := range []Billing{ + {MonthlyLimit: 1}, + {OnDemandCap: 0.01}, + {OnDemandUsed: 1}, + {PrepaidBalance: 5}, + {PlanName: "SuperGrok"}, + {PlanName: "SuperGrok Heavy"}, + {PlanCode: "supergrok_lite"}, + {PlanName: "X Premium+"}, + } { + if !billing.IsPaid() { + t.Fatalf("expected paid for %#v", billing) + } + } + if (Billing{Used: 100, CreditUsagePercent: 42.5, PlanName: "free"}).IsPaid() { + t.Fatal("usage percentage must not mark a Free account as paid") + } + if (Billing{CreditUsagePercent: 42.5}).IsPaid() { + t.Fatal("usage percentage without a subscription tier must remain unknown") + } + if (Billing{IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY"}).HasFreeProfileSignal() { + t.Fatal("generic weekly billing fields must not mark an account as free") + } + if !(Billing{PlanName: "Free"}).HasFreeProfileSignal() || !(Billing{PlanCode: "x_basic"}).HasFreeProfileSignal() { + t.Fatal("known restricted tiers should be recognized as free/basic") + } + now := time.Now().UTC() + if !(Billing{SyncedAt: now, IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY"}).HasInferredFreeProfileSignal() { + t.Fatal("a successful zero-value billing snapshot should be inferred as free") + } + for _, billing := range []Billing{ + {IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY"}, + {SyncedAt: now, MonthlyLimit: 1}, + {SyncedAt: now, CreditUsagePercent: 1}, + {SyncedAt: now, PlanName: "SuperGrok"}, + } { + if billing.HasInferredFreeProfileSignal() { + t.Fatalf("billing must remain non-inferred: %#v", billing) + } + } +} + +func TestBuildRouteModeValidation(t *testing.T) { + for _, value := range []BuildRouteMode{BuildRouteAuto, BuildRouteBuild, BuildRouteXAI} { + if !value.IsValid() { + t.Fatalf("valid route mode rejected: %q", value) + } + } + for _, value := range []BuildRouteMode{"", "primary", "fallback"} { + if value.IsValid() { + t.Fatalf("invalid route mode accepted: %q", value) + } + } +} + +func TestIsBuildSuper(t *testing.T) { + paid := Billing{MonthlyLimit: 100} + zeroFree := Billing{IsUnifiedBillingUser: true} + if !IsBuildSuper(Credential{Provider: ProviderBuild, BuildSuperEntitled: true}, &zeroFree) { + t.Fatal("entitlement must make zero-billing Build Super") + } + if !IsBuildSuper(Credential{Provider: ProviderBuild}, &paid) { + t.Fatal("paid billing must make Build Super") + } + if IsBuildSuper(Credential{Provider: ProviderBuild}, &zeroFree) { + t.Fatal("zero billing without entitlement is not Super") + } + if IsBuildSuper(Credential{Provider: ProviderWeb, BuildSuperEntitled: true}, &paid) { + t.Fatal("Web must ignore BuildSuperEntitled") + } +} + +func TestRoutingCandidateIsKnownFreeBuild(t *testing.T) { + freeBilling := Billing{PlanName: "Free"} + paidBilling := Billing{PlanName: "SuperGrok"} + freeRecovery := QuotaRecovery{Kind: QuotaRecoveryKindFree} + tests := []struct { + name string + candidate RoutingCandidate + want bool + }{ + {name: "billing profile", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild}, Billing: &freeBilling}, want: true}, + {name: "inferred zero billing profile", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild}, Billing: &Billing{SyncedAt: time.Now().UTC(), IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY"}}, want: true}, + {name: "ambiguous weekly profile", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild}, Billing: &Billing{IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY"}}}, + {name: "observed response model", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild, ObservedModel: "grok-4.5-build-free"}}, want: true}, + {name: "quota recovery", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild}, QuotaRecovery: &freeRecovery}, want: true}, + {name: "paid overrides stale free signal", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild, ObservedModel: "grok-4.5-build-free"}, Billing: &paidBilling}}, + {name: "entitlement overrides free profile", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild, BuildSuperEntitled: true}, Billing: &freeBilling}}, + {name: "entitlement overrides free recovery", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild, BuildSuperEntitled: true}, QuotaRecovery: &freeRecovery}}, + {name: "entitlement overrides observed free model", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild, BuildSuperEntitled: true, ObservedModel: "grok-4.5-build-free"}}}, + {name: "unknown build", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderBuild}}}, + {name: "web is never build free", candidate: RoutingCandidate{Credential: Credential{Provider: ProviderWeb, ObservedModel: "grok-4.5-build-free"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.candidate.IsKnownFreeBuild(); got != test.want { + t.Fatalf("IsKnownFreeBuild() = %t, want %t", got, test.want) + } + }) + } +} + +func TestBillingIsExhaustedForOnDemandCredits(t *testing.T) { + if !(Billing{OnDemandCap: 50, CreditUsagePercent: 100}).IsExhausted(0) { + t.Fatal("expected exhausted on-demand billing") + } + if (Billing{CreditUsagePercent: 100}).IsExhausted(0) { + t.Fatal("billing without a reported limit should not be treated as exhausted") + } + if !(Billing{CreditUsagePercent: 100, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY"}).IsExhausted(0) { + t.Fatal("expected exhausted weekly usage period") + } +} + +func TestBillingPeriodEndMatchesExhaustedLimit(t *testing.T) { + monthlyEnd := "2026-08-01T00:00:00Z" + weeklyEnd := "2026-07-19T00:00:00Z" + weekly := Billing{MonthlyLimit: 15_000, Used: 197, CreditUsagePercent: 100, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", UsagePeriodEnd: weeklyEnd, BillingPeriodEnd: monthlyEnd} + if value, ok := weekly.PeriodEnd(); !ok || value.Format(time.RFC3339) != weeklyEnd { + t.Fatalf("weekly period end = %v, %v", value, ok) + } + monthly := Billing{MonthlyLimit: 15_000, Used: 15_000, CreditUsagePercent: 5, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", UsagePeriodEnd: weeklyEnd, BillingPeriodEnd: monthlyEnd} + if value, ok := monthly.PeriodEnd(); !ok || value.Format(time.RFC3339) != monthlyEnd { + t.Fatalf("monthly period end = %v, %v", value, ok) + } +} diff --git a/backend/internal/domain/admin/admin.go b/backend/internal/domain/admin/admin.go new file mode 100644 index 0000000000000000000000000000000000000000..e1be5a7cdd92260d8476e57959f8255198c648fa --- /dev/null +++ b/backend/internal/domain/admin/admin.go @@ -0,0 +1,22 @@ +package admin + +import "time" + +// Admin 表示系统唯一管理员。 +type Admin struct { + ID uint64 + Username string + PasswordHash string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Session 表示可轮换和删除的管理员刷新会话。 +type Session struct { + ID uint64 + AdminID uint64 + RefreshTokenHash string + ExpiresAt time.Time + LastUsedAt *time.Time + CreatedAt time.Time +} diff --git a/backend/internal/domain/audit/audit.go b/backend/internal/domain/audit/audit.go new file mode 100644 index 0000000000000000000000000000000000000000..7d11ac6ec0256647bc4aa8df828e8aa632e86782 --- /dev/null +++ b/backend/internal/domain/audit/audit.go @@ -0,0 +1,129 @@ +package audit + +import "time" + +type Operation string + +const ( + OperationResponses Operation = "responses" + OperationCompaction Operation = "compaction" + OperationChat Operation = "chat" + OperationMessages Operation = "messages" + OperationImage Operation = "image" + OperationImageEdit Operation = "image_edit" + OperationVideo Operation = "video" +) + +type UsageSource string + +const ( + UsageSourceUpstream UsageSource = "upstream" + UsageSourceEstimated UsageSource = "estimated" + UsageSourceNone UsageSource = "none" +) + +type AttemptSource string + +const ( + AttemptSourceUpstreamHTTP AttemptSource = "upstream_http" + AttemptSourceTransport AttemptSource = "gateway_transport" + AttemptSourceCredential AttemptSource = "credential" +) + +type ErrorFrame struct { + Type string + Message string +} + +// Attempt 保存一次失败尝试经过裁剪和脱敏的管理员诊断快照。 +type Attempt struct { + ID uint64 + AuditID uint64 + Number int + Source AttemptSource + Stage string + AccountID *uint64 + AccountName string + Method string + RequestPath string + UpstreamURL string + StartedAt time.Time + DurationMS int64 + UpstreamStatusCode *int + UpstreamStatus string + ResponseHeaders map[string][]string + ResponseBody []byte + ResponseBodyTruncated bool + TransportError string + ErrorChain []ErrorFrame +} + +type EgressMode string + +const ( + EgressModeDirect EgressMode = "direct" + EgressModeProxy EgressMode = "proxy" +) + +// Record 表示推理请求审计;成功请求不保存正文,失败请求仅保留受限诊断快照。 +type Record struct { + ID uint64 + EventID string + RequestID string + ClientKeyID uint64 + ClientKeyName string + ModelRouteID uint64 + ModelPublicID string + ModelUpstreamModel string + Provider string + Operation Operation + UsageSource UsageSource + AccountID *uint64 + AccountName string + EgressNodeID *uint64 + EgressNodeName string + EgressScope string + EgressMode EgressMode + StatusCode int + Streaming bool + MediaInputImages int64 + MediaOutputImages int64 + MediaOutputSeconds int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + TotalTokens int64 + CostInUSDTicks int64 + EstimatedCostInUSDTicks int64 + PricingModel string + PricingVersion string + NumSourcesUsed int64 + NumServerSideToolsUsed int64 + ContextInputTokens int64 + ContextOutputTokens int64 + FirstTokenMS *int64 + DurationMS int64 + ErrorCode string + AttemptCount int + Attempts []Attempt + CreatedAt time.Time +} + +// Summary 表示指定审计范围内的聚合用量。 +type Summary struct { + Requests int64 + SuccessfulRequests int64 + FailedRequests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + TotalTokens int64 + DurationMS int64 + EstimatedCostInUSDTicks int64 + PricedRequests int64 + UnpricedRequests int64 + PricedTokens int64 + UnpricedTokens int64 +} diff --git a/backend/internal/domain/audit/pricing.go b/backend/internal/domain/audit/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..aecc18d0b456d988d2f21991a9e79ec47c233327 --- /dev/null +++ b/backend/internal/domain/audit/pricing.go @@ -0,0 +1,431 @@ +package audit + +import ( + "encoding/json" + "regexp" + "strings" +) + +const ( + OfficialPricingSource = "https://docs.x.ai/developers/pricing" + OfficialPricingAsOf = "2026-07-14" + officialImageEditInputTicks int64 = 100_000_000 +) + +type PricingResult struct { + Model string + CostInUSDTicks int64 +} + +// PricingBreakdown describes the exact rate components used to reconstruct a stored estimate. +type PricingBreakdown struct { + Model string + CostInUSDTicks int64 + Tier PricingTier + Components []PricingComponent +} + +type PricingTier string + +const ( + PricingTierStandard PricingTier = "standard" + PricingTierLongContext PricingTier = "long_context" + PricingTierMedia PricingTier = "media" +) + +type PricingComponentKind string + +const ( + PricingComponentUncachedInput PricingComponentKind = "uncached_input" + PricingComponentCachedInput PricingComponentKind = "cached_input" + PricingComponentOutput PricingComponentKind = "output" + PricingComponentInputImage PricingComponentKind = "input_image" + PricingComponentOutputImage PricingComponentKind = "output_image" + PricingComponentOutputSecond PricingComponentKind = "output_second" +) + +type PricingUnit string + +const ( + PricingUnitToken PricingUnit = "token" + PricingUnitImage PricingUnit = "image" + PricingUnitSecond PricingUnit = "second" +) + +type PricingComponent struct { + Kind PricingComponentKind + Unit PricingUnit + Quantity int64 + UnitPriceInUSDTicks int64 + CostInUSDTicks int64 +} + +type tokenPrice struct { + CanonicalModel string + InputTicks int64 + CachedInputTicks int64 + OutputTicks int64 + LongContextTokens int64 + LongInputTicks int64 + LongCachedTicks int64 + LongOutputTicks int64 +} + +var officialTokenPrices = buildOfficialTokenPrices() + +type tokenPriceRule struct { + Pattern *regexp.Regexp + CanonicalModel string +} + +var officialTokenPriceRules = []tokenPriceRule{ + {Pattern: regexp.MustCompile(`^grok-(?:build-0\.1|code-fast(?:-1)?|composer-2\.5-fast)(?:-[a-z0-9.]+)*$`), CanonicalModel: "grok-build-0.1"}, + {Pattern: regexp.MustCompile(`^grok-4\.5(?:-[a-z0-9.]+)*$`), CanonicalModel: "grok-4.5"}, + {Pattern: regexp.MustCompile(`^grok-4\.3(?:-[a-z0-9.]+)*$`), CanonicalModel: "grok-4.3"}, + {Pattern: regexp.MustCompile(`^grok-4\.20-multi-agent(?:-[a-z0-9.]+)*$`), CanonicalModel: "grok-4.20-multi-agent-0309"}, + {Pattern: regexp.MustCompile(`^grok-4\.20(?:-[a-z0-9.]+)*-non-reasoning(?:-[a-z0-9.]+)*$`), CanonicalModel: "grok-4.20-0309-non-reasoning"}, + {Pattern: regexp.MustCompile(`^grok-4\.20(?:-[a-z0-9.]+)*$`), CanonicalModel: "grok-4.20-0309-reasoning"}, +} + +// buildOfficialTokenPrices 使用 xAI 官方每 Token USD ticks 费率。 +// 1 USD = 10,000,000,000 ticks;官方页面展示价格均为每 1M Tokens。 +func buildOfficialTokenPrices() map[string]tokenPrice { + prices := make(map[string]tokenPrice) + register := func(canonical string, price tokenPrice, names ...string) { + price.CanonicalModel = canonical + for _, name := range append([]string{canonical}, names...) { + prices[name] = price + } + } + register("grok-build-0.1", tokenPrice{InputTicks: 10000, CachedInputTicks: 2000, OutputTicks: 20000, LongContextTokens: 200000, LongInputTicks: 20000, LongCachedTicks: 4000, LongOutputTicks: 40000}, + "grok-code-fast-1", "grok-code-fast", "grok-code-fast-1-0825", "grok-composer-2.5-fast") + register("grok-4.5", tokenPrice{InputTicks: 20000, CachedInputTicks: 3000, OutputTicks: 60000, LongContextTokens: 200000, LongInputTicks: 40000, LongCachedTicks: 6000, LongOutputTicks: 120000}, + "grok-4.5-latest", "grok-build-latest") + standard := tokenPrice{InputTicks: 12500, CachedInputTicks: 2000, OutputTicks: 25000, LongContextTokens: 200000, LongInputTicks: 25000, LongCachedTicks: 4000, LongOutputTicks: 50000} + register("grok-4.3", standard, "grok-4.3-latest", "grok-latest") + register("grok-4.20-multi-agent-0309", standard, + "grok-4.20-multi-agent", "grok-4.20-multi-agent-latest", "grok-4.20-multi-agent-beta-latest", "grok-4.20-multi-agent-beta-0309") + register("grok-4.20-0309-reasoning", standard, + "grok-4.20-reasoning-latest", "grok-4.20", "grok-4.20-reasoning", "grok-4.20-0309", "grok-4.20-beta", "grok-4.20-beta-0309", "grok-4.20-beta-latest", "grok-4.20-beta-reasoning", "grok-4.20-beta-latest-reasoning") + register("grok-4.20-0309-non-reasoning", standard, + "grok-4.20-non-reasoning", "grok-4.20-non-reasoning-latest", "grok-4.20-beta-non-reasoning", "grok-4.20-beta-latest-non-reasoning") + return prices +} + +// resolveOfficialTokenPrice 先处理内部来源前缀和官方精确别名,再使用锚定规则识别同一模型家族的版本后缀。 +func resolveOfficialTokenPrice(model string) (tokenPrice, bool) { + normalized := normalizePricingModel(model) + if price, ok := officialTokenPrices[normalized]; ok { + return price, true + } + for _, rule := range officialTokenPriceRules { + if !rule.Pattern.MatchString(normalized) { + continue + } + price, ok := officialTokenPrices[rule.CanonicalModel] + return price, ok + } + return tokenPrice{}, false +} + +// normalizePricingModel 只移除系统已知的来源前缀,避免任意路径片段被误识别为可计费模型。 +func normalizePricingModel(model string) string { + normalized := strings.ToLower(strings.TrimSpace(model)) + for _, prefix := range []string{"build/", "web/", "console/", "grok_build/", "grok_web/", "grok_console/"} { + if strings.HasPrefix(normalized, prefix) { + return strings.TrimSpace(normalized[len(prefix):]) + } + } + return normalized +} + +// EstimateOfficialCost 按官方模型价格计算单次请求成本;未知模型返回 false。 +func EstimateOfficialCost(model string, inputTokens, cachedInputTokens, outputTokens, contextInputTokens int64) (PricingResult, bool) { + price, ok := resolveOfficialTokenPrice(model) + if !ok { + return PricingResult{}, false + } + inputPrice := price.InputTicks + cachedPrice := price.CachedInputTicks + outputPrice := price.OutputTicks + contextTokens := contextInputTokens + if contextTokens <= 0 { + contextTokens = inputTokens + } + if price.LongContextTokens > 0 && contextTokens > price.LongContextTokens { + inputPrice = price.LongInputTicks + cachedPrice = price.LongCachedTicks + outputPrice = price.LongOutputTicks + } + cachedTokens := max(int64(0), min(cachedInputTokens, inputTokens)) + uncachedTokens := max(int64(0), inputTokens-cachedTokens) + outputTokens = max(int64(0), outputTokens) + return PricingResult{Model: price.CanonicalModel, CostInUSDTicks: uncachedTokens*inputPrice + cachedTokens*cachedPrice + outputTokens*outputPrice}, true +} + +// EstimateOfficialTextReservation 根据请求内容和输出上限计算保守的文本费用预留。 +func EstimateOfficialTextReservation(model string, body []byte) (PricingResult, bool) { + if _, ok := resolveOfficialTokenPrice(model); !ok { + return PricingResult{}, false + } + inputTokens := estimateRequestInputTokens(body) + outputTokens := estimateRequestOutputLimit(body) + return EstimateOfficialCost(model, inputTokens, 0, outputTokens, inputTokens) +} + +func estimateRequestOutputLimit(body []byte) int64 { + const defaultOutputTokens int64 = 16_384 + const maximumOutputTokens int64 = 131_072 + var payload map[string]json.RawMessage + if json.Unmarshal(body, &payload) != nil { + return defaultOutputTokens + } + for _, key := range []string{"max_output_tokens", "max_completion_tokens", "max_tokens"} { + var value int64 + if raw, ok := payload[key]; ok && json.Unmarshal(raw, &value) == nil && value > 0 { + return min(value, maximumOutputTokens) + } + } + return defaultOutputTokens +} + +func estimateRequestInputTokens(body []byte) int64 { + var payload any + if json.Unmarshal(body, &payload) != nil { + return max(256, int64((len(body)+2)/3)) + } + return max(256, estimateJSONTokens(payload)+128) +} + +func estimateJSONTokens(value any) int64 { + switch typed := value.(type) { + case map[string]any: + var total int64 + for key, child := range typed { + total += int64((len(key)+2)/3) + 1 + estimateJSONTokens(child) + } + return total + case []any: + var total int64 + for _, child := range typed { + total += 1 + estimateJSONTokens(child) + } + return total + case string: + trimmed := strings.TrimSpace(typed) + if strings.HasPrefix(trimmed, "data:image/") || strings.HasPrefix(trimmed, "data:video/") { + return 256 + } + return max(1, int64((len(typed)+2)/3)) + case json.Number, float64, bool: + return 1 + case nil: + return 0 + default: + encoded, _ := json.Marshal(typed) + return max(1, int64((len(encoded)+2)/3)) + } +} + +// EstimateOfficialImageCost 按客户端请求的 n 计算 Grok Imagine 图片费用。 +func EstimateOfficialImageCost(model, resolution string, count int) (PricingResult, bool) { + if count <= 0 { + return PricingResult{}, false + } + model = normalizePricingModel(model) + if model == "grok-imagine-image" { + return PricingResult{Model: "grok-imagine-image", CostInUSDTicks: int64(count) * 200_000_000}, true + } + if model != "grok-imagine-image-quality" { + return PricingResult{}, false + } + resolution = strings.ToLower(strings.TrimSpace(resolution)) + if resolution == "" { + resolution = "1k" + } + var ticksPerImage int64 + switch resolution { + case "1k": + ticksPerImage = 500_000_000 + case "2k": + ticksPerImage = 700_000_000 + default: + return PricingResult{}, false + } + return PricingResult{ + Model: "grok-imagine-image-quality-" + resolution, + CostInUSDTicks: int64(count) * ticksPerImage, + }, true +} + +// EstimateOfficialImageEditCost 按输出图片数量计费,并叠加每张输入图片的处理费用。 +func EstimateOfficialImageEditCost(model, resolution string, outputCount, inputCount int) (PricingResult, bool) { + if normalizePricingModel(model) != "grok-imagine-image-edit" || outputCount <= 0 || inputCount <= 0 { + return PricingResult{}, false + } + resolution = strings.ToLower(strings.TrimSpace(resolution)) + if resolution == "" { + resolution = "1k" + } + var outputTicks int64 + switch resolution { + case "1k": + outputTicks = 500_000_000 + case "2k": + outputTicks = 700_000_000 + default: + return PricingResult{}, false + } + return PricingResult{ + Model: "grok-imagine-image-edit-" + resolution, + CostInUSDTicks: int64(outputCount)*outputTicks + int64(inputCount)*officialImageEditInputTicks, + }, true +} + +// EstimateOfficialVideoCost 按请求视频时长和分辨率计算费用。 +// 仅精确支持 grok-imagine-video 与 grok-imagine-video-1.5(可带来源前缀);未知后缀拒绝。 +func EstimateOfficialVideoCost(model, resolution string, seconds int) (PricingResult, bool) { + if seconds <= 0 { + return PricingResult{}, false + } + baseModel, ok := officialVideoPricingModel(model) + if !ok { + return PricingResult{}, false + } + resolution = strings.ToLower(strings.TrimSpace(resolution)) + var ticksPerSecond int64 + switch resolution { + case "480p": + ticksPerSecond = 800_000_000 + case "720p": + ticksPerSecond = 1_400_000_000 + default: + return PricingResult{}, false + } + return PricingResult{ + Model: baseModel + "-" + resolution, + CostInUSDTicks: int64(seconds) * ticksPerSecond, + }, true +} + +// ReconstructOfficialCost builds an admin-facing formula without adding work to the request settlement path. +func ReconstructOfficialCost(model string, inputTokens, cachedInputTokens, outputTokens, contextInputTokens, inputImages, outputImages, outputSeconds int64) (PricingBreakdown, bool) { + normalized := normalizePricingModel(model) + switch normalized { + case "grok-imagine-image": + return reconstructImageCost(normalized, "", outputImages) + case "grok-imagine-image-quality-1k": + return reconstructImageCost("grok-imagine-image-quality", "1k", outputImages) + case "grok-imagine-image-quality-2k": + return reconstructImageCost("grok-imagine-image-quality", "2k", outputImages) + case "grok-imagine-image-edit-1k": + return reconstructImageEditCost("1k", inputImages, outputImages) + case "grok-imagine-image-edit-2k": + return reconstructImageEditCost("2k", inputImages, outputImages) + case "grok-imagine-video-480p": + return reconstructVideoCost("grok-imagine-video", "480p", outputSeconds) + case "grok-imagine-video-720p": + return reconstructVideoCost("grok-imagine-video", "720p", outputSeconds) + case "grok-imagine-video-1.5-480p": + return reconstructVideoCost("grok-imagine-video-1.5", "480p", outputSeconds) + case "grok-imagine-video-1.5-720p": + return reconstructVideoCost("grok-imagine-video-1.5", "720p", outputSeconds) + default: + return reconstructTextCost(normalized, inputTokens, cachedInputTokens, outputTokens, contextInputTokens) + } +} + +func reconstructTextCost(model string, inputTokens, cachedInputTokens, outputTokens, contextInputTokens int64) (PricingBreakdown, bool) { + price, ok := resolveOfficialTokenPrice(model) + if !ok { + return PricingBreakdown{}, false + } + inputPrice, cachedPrice, outputPrice := price.InputTicks, price.CachedInputTicks, price.OutputTicks + tier := PricingTierStandard + contextTokens := contextInputTokens + if contextTokens <= 0 { + contextTokens = inputTokens + } + if price.LongContextTokens > 0 && contextTokens > price.LongContextTokens { + inputPrice, cachedPrice, outputPrice = price.LongInputTicks, price.LongCachedTicks, price.LongOutputTicks + tier = PricingTierLongContext + } + cachedTokens := max(int64(0), min(cachedInputTokens, inputTokens)) + return newPricingBreakdown(price.CanonicalModel, tier, + newPricingComponent(PricingComponentUncachedInput, PricingUnitToken, max(int64(0), inputTokens-cachedTokens), inputPrice), + newPricingComponent(PricingComponentOutput, PricingUnitToken, outputTokens, outputPrice), + newPricingComponent(PricingComponentCachedInput, PricingUnitToken, cachedTokens, cachedPrice), + ), true +} + +func reconstructImageCost(model, resolution string, count int64) (PricingBreakdown, bool) { + if count <= 0 || int64(int(count)) != count { + return PricingBreakdown{}, false + } + result, ok := EstimateOfficialImageCost(model, resolution, int(count)) + if !ok { + return PricingBreakdown{}, false + } + return newPricingBreakdown(result.Model, PricingTierMedia, + newPricingComponent(PricingComponentOutputImage, PricingUnitImage, count, result.CostInUSDTicks/count), + ), true +} + +func reconstructImageEditCost(resolution string, inputCount, outputCount int64) (PricingBreakdown, bool) { + if inputCount <= 0 || outputCount <= 0 || int64(int(inputCount)) != inputCount || int64(int(outputCount)) != outputCount { + return PricingBreakdown{}, false + } + result, ok := EstimateOfficialImageEditCost("grok-imagine-image-edit", resolution, int(outputCount), int(inputCount)) + if !ok { + return PricingBreakdown{}, false + } + outputCost := result.CostInUSDTicks - inputCount*officialImageEditInputTicks + if outputCost < 0 || outputCost%outputCount != 0 { + return PricingBreakdown{}, false + } + return newPricingBreakdown(result.Model, PricingTierMedia, + newPricingComponent(PricingComponentOutputImage, PricingUnitImage, outputCount, outputCost/outputCount), + newPricingComponent(PricingComponentInputImage, PricingUnitImage, inputCount, officialImageEditInputTicks), + ), true +} + +func reconstructVideoCost(model, resolution string, seconds int64) (PricingBreakdown, bool) { + if seconds <= 0 || int64(int(seconds)) != seconds { + return PricingBreakdown{}, false + } + result, ok := EstimateOfficialVideoCost(model, resolution, int(seconds)) + if !ok { + return PricingBreakdown{}, false + } + return newPricingBreakdown(result.Model, PricingTierMedia, + newPricingComponent(PricingComponentOutputSecond, PricingUnitSecond, seconds, result.CostInUSDTicks/seconds), + ), true +} + +func newPricingComponent(kind PricingComponentKind, unit PricingUnit, quantity, unitPriceInUSDTicks int64) PricingComponent { + quantity = max(int64(0), quantity) + unitPriceInUSDTicks = max(int64(0), unitPriceInUSDTicks) + return PricingComponent{ + Kind: kind, Unit: unit, Quantity: quantity, UnitPriceInUSDTicks: unitPriceInUSDTicks, + CostInUSDTicks: quantity * unitPriceInUSDTicks, + } +} + +func newPricingBreakdown(model string, tier PricingTier, components ...PricingComponent) PricingBreakdown { + result := PricingBreakdown{Model: model, Tier: tier, Components: components} + for _, component := range components { + result.CostInUSDTicks += component.CostInUSDTicks + } + return result +} + +func officialVideoPricingModel(model string) (string, bool) { + switch normalizePricingModel(model) { + case "grok-imagine-video": + return "grok-imagine-video", true + case "grok-imagine-video-1.5": + return "grok-imagine-video-1.5", true + default: + return "", false + } +} diff --git a/backend/internal/domain/audit/pricing_test.go b/backend/internal/domain/audit/pricing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4359bafa39576afa1d5dbed3c2e1d693eaf259cd --- /dev/null +++ b/backend/internal/domain/audit/pricing_test.go @@ -0,0 +1,177 @@ +package audit + +import "testing" + +func TestEstimateOfficialCostHandlesCacheAliasesAndLongContext(t *testing.T) { + result, ok := EstimateOfficialCost("grok-code-fast-1", 1_000_000, 200_000, 500_000, 100_000) + if !ok || result.Model != "grok-build-0.1" || result.CostInUSDTicks != 18_400_000_000 { + t.Fatalf("standard result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialCost("grok-composer-2.5-fast", 1_000_000, 200_000, 500_000, 256_000) + if !ok || result.Model != "grok-build-0.1" || result.CostInUSDTicks != 36_800_000_000 { + t.Fatalf("composer result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialCost("grok-4.5", 1_000_000, 200_000, 500_000, 210_000) + if !ok || result.CostInUSDTicks != 93_200_000_000 { + t.Fatalf("long-context result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialCost("Web/grok-4.5-build-free", 100, 0, 50, 100) + if !ok || result.Model != "grok-4.5" || result.CostInUSDTicks != 5_000_000 { + t.Fatalf("dynamic prefixed alias = %#v, ok = %v", result, ok) + } +} + +func TestEstimateOfficialCostMatchesControlledModelFamilies(t *testing.T) { + tests := []struct { + model string + canonical string + }{ + {model: "Build/grok-build-0.1", canonical: "grok-build-0.1"}, + {model: "grok_build/grok-code-fast-1-0825", canonical: "grok-build-0.1"}, + {model: "Console/grok-4.3-high", canonical: "grok-4.3"}, + {model: "Web/grok-4.5-2026-07-14", canonical: "grok-4.5"}, + {model: "Console/grok-4.20-multi-agent-beta-0310", canonical: "grok-4.20-multi-agent-0309"}, + {model: "Console/grok-4.20-experimental-beta-0310-non-reasoning", canonical: "grok-4.20-0309-non-reasoning"}, + {model: "Console/grok-4.20-experimental-beta-0310-reasoning", canonical: "grok-4.20-0309-reasoning"}, + } + for _, test := range tests { + result, ok := EstimateOfficialCost(test.model, 1_000_000, 0, 0, 100_000) + if !ok || result.Model != test.canonical { + t.Fatalf("EstimateOfficialCost(%q) = %#v, %v", test.model, result, ok) + } + } + for _, model := range []string{"my-grok-4.5", "Other/grok-4.5", "grok-4.50", "grok-4.5/preview"} { + if result, ok := EstimateOfficialCost(model, 100, 0, 50, 100); ok { + t.Fatalf("unsafe model %q was priced as %#v", model, result) + } + } +} + +func TestOfficialPricingMatchesPublishedTokenRates(t *testing.T) { + tests := []struct { + model string + inputCost, cachedCost, outputCost int64 + }{ + {model: "grok-build-0.1", inputCost: 10_000_000_000, cachedCost: 2_000_000_000, outputCost: 20_000_000_000}, + {model: "grok-4.5", inputCost: 20_000_000_000, cachedCost: 3_000_000_000, outputCost: 60_000_000_000}, + {model: "grok-4.3", inputCost: 12_500_000_000, cachedCost: 2_000_000_000, outputCost: 25_000_000_000}, + {model: "grok-4.20-multi-agent-0309", inputCost: 12_500_000_000, cachedCost: 2_000_000_000, outputCost: 25_000_000_000}, + {model: "grok-4.20-0309-reasoning", inputCost: 12_500_000_000, cachedCost: 2_000_000_000, outputCost: 25_000_000_000}, + {model: "grok-4.20-0309-non-reasoning", inputCost: 12_500_000_000, cachedCost: 2_000_000_000, outputCost: 25_000_000_000}, + } + for _, test := range tests { + input, inputOK := EstimateOfficialCost(test.model, 1_000_000, 0, 0, 100_000) + cached, cachedOK := EstimateOfficialCost(test.model, 1_000_000, 1_000_000, 0, 100_000) + output, outputOK := EstimateOfficialCost(test.model, 0, 0, 1_000_000, 100_000) + if !inputOK || !cachedOK || !outputOK || input.CostInUSDTicks != test.inputCost || cached.CostInUSDTicks != test.cachedCost || output.CostInUSDTicks != test.outputCost { + t.Fatalf("published price %q = input %#v, cached %#v, output %#v", test.model, input, cached, output) + } + } + longBuild, ok := EstimateOfficialCost("Build/grok-build-0.1", 1_000_000, 0, 1_000_000, 200_001) + if !ok || longBuild.CostInUSDTicks != 60_000_000_000 { + t.Fatalf("long-context Build price = %#v, %v", longBuild, ok) + } +} + +func TestMediaPricingAcceptsProviderPrefixes(t *testing.T) { + if result, ok := EstimateOfficialImageCost("Web/grok-imagine-image", "1k", 1); !ok || result.CostInUSDTicks != 200_000_000 { + t.Fatalf("prefixed image price = %#v, %v", result, ok) + } + if result, ok := EstimateOfficialImageEditCost("grok_web/grok-imagine-image-edit", "1k", 1, 1); !ok || result.CostInUSDTicks != 600_000_000 { + t.Fatalf("prefixed image edit price = %#v, %v", result, ok) + } + if result, ok := EstimateOfficialVideoCost("Web/grok-imagine-video", "480p", 1); !ok || result.CostInUSDTicks != 800_000_000 { + t.Fatalf("prefixed video price = %#v, %v", result, ok) + } +} + +func TestEstimateOfficialTextReservationUsesOutputLimitAndIgnoresInlineMediaBytes(t *testing.T) { + small, ok := EstimateOfficialTextReservation("grok-4.5", []byte(`{"input":"hello","max_output_tokens":1000}`)) + if !ok || small.CostInUSDTicks <= 60_000_000 { + t.Fatalf("small reservation = %#v, ok = %v", small, ok) + } + largeInline, ok := EstimateOfficialTextReservation("grok-4.5", []byte(`{"input":[{"type":"input_image","image_url":"data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}],"max_output_tokens":1000}`)) + if !ok || largeInline.CostInUSDTicks > small.CostInUSDTicks+20_000_000 { + t.Fatalf("inline media reservation = %#v, small = %#v", largeInline, small) + } + if _, ok := EstimateOfficialTextReservation("unknown-model", []byte(`{"input":"hello"}`)); ok { + t.Fatal("unknown model was priced") + } +} + +func TestEstimateOfficialImageCost(t *testing.T) { + result, ok := EstimateOfficialImageCost("grok-imagine-image-quality", "1k", 2) + if !ok || result.Model != "grok-imagine-image-quality-1k" || result.CostInUSDTicks != 1_000_000_000 { + t.Fatalf("1k result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialImageCost("grok-imagine-image-quality", "2k", 3) + if !ok || result.Model != "grok-imagine-image-quality-2k" || result.CostInUSDTicks != 2_100_000_000 { + t.Fatalf("2k result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialImageCost("grok-imagine-image", "2k", 4) + if !ok || result.Model != "grok-imagine-image" || result.CostInUSDTicks != 800_000_000 { + t.Fatalf("Lite result = %#v, ok = %v", result, ok) + } +} + +func TestEstimateOfficialImageEditCost(t *testing.T) { + result, ok := EstimateOfficialImageEditCost("grok-imagine-image-edit", "1k", 2, 1) + if !ok || result.Model != "grok-imagine-image-edit-1k" || result.CostInUSDTicks != 1_100_000_000 { + t.Fatalf("1k edit result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialImageEditCost("grok-imagine-image-edit", "2K", 3, 4) + if !ok || result.Model != "grok-imagine-image-edit-2k" || result.CostInUSDTicks != 2_500_000_000 { + t.Fatalf("2k edit result = %#v, ok = %v", result, ok) + } + if result, ok = EstimateOfficialImageEditCost("grok-imagine-image-edit", "4k", 1, 1); ok || result.CostInUSDTicks != 0 { + t.Fatalf("unknown edit resolution = %#v, ok = %v", result, ok) + } +} + +func TestEstimateOfficialVideoCost(t *testing.T) { + result, ok := EstimateOfficialVideoCost("grok-imagine-video", "480p", 10) + if !ok || result.Model != "grok-imagine-video-480p" || result.CostInUSDTicks != 8_000_000_000 { + t.Fatalf("480p video result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialVideoCost("grok-imagine-video", "720P", 6) + if !ok || result.Model != "grok-imagine-video-720p" || result.CostInUSDTicks != 8_400_000_000 { + t.Fatalf("720p video result = %#v, ok = %v", result, ok) + } + if result, ok = EstimateOfficialVideoCost("grok-imagine-video", "1080p", 10); ok || result.CostInUSDTicks != 0 { + t.Fatalf("unpriced video resolution = %#v, ok = %v", result, ok) + } + + result, ok = EstimateOfficialVideoCost("grok-imagine-video-1.5", "720p", 6) + if !ok || result.Model != "grok-imagine-video-1.5-720p" || result.CostInUSDTicks != 8_400_000_000 { + t.Fatalf("1.5 720p video result = %#v, ok = %v", result, ok) + } + result, ok = EstimateOfficialVideoCost("Build/grok-imagine-video-1.5", "480p", 1) + if !ok || result.Model != "grok-imagine-video-1.5-480p" || result.CostInUSDTicks != 800_000_000 { + t.Fatalf("prefixed 1.5 video result = %#v, ok = %v", result, ok) + } + if result, ok = EstimateOfficialVideoCost("grok-imagine-video-1.5-fast", "720p", 6); ok || result.CostInUSDTicks != 0 { + t.Fatalf("unknown 1.5 suffix was priced = %#v, ok = %v", result, ok) + } + if result, ok = EstimateOfficialVideoCost("grok-imagine-video-2.0", "720p", 6); ok || result.CostInUSDTicks != 0 { + t.Fatalf("unknown video model was priced = %#v, ok = %v", result, ok) + } +} + +func TestReconstructOfficialCostReturnsExactStoredFormulaInputs(t *testing.T) { + textResult, ok := ReconstructOfficialCost("grok-build-0.1", 100, 20, 50, 100, 0, 0, 0) + if !ok || textResult.Tier != PricingTierStandard || textResult.CostInUSDTicks != 1_840_000 || len(textResult.Components) != 3 { + t.Fatalf("text reconstruction = %#v, %v", textResult, ok) + } + longResult, ok := ReconstructOfficialCost("grok-build-0.1", 100, 20, 50, 200_001, 0, 0, 0) + if !ok || longResult.Tier != PricingTierLongContext || longResult.CostInUSDTicks != 3_680_000 { + t.Fatalf("long-context reconstruction = %#v, %v", longResult, ok) + } + imageResult, ok := ReconstructOfficialCost("grok-imagine-image-edit-2k", 0, 0, 0, 0, 2, 3, 0) + if !ok || imageResult.CostInUSDTicks != 2_300_000_000 || len(imageResult.Components) != 2 || imageResult.Components[1].Kind != PricingComponentInputImage { + t.Fatalf("image reconstruction = %#v, %v", imageResult, ok) + } + videoResult, ok := ReconstructOfficialCost("grok-imagine-video-1.5-720p", 0, 0, 0, 0, 1, 0, 6) + if !ok || videoResult.CostInUSDTicks != 8_400_000_000 || videoResult.Components[0].Unit != PricingUnitSecond { + t.Fatalf("video reconstruction = %#v, %v", videoResult, ok) + } +} diff --git a/backend/internal/domain/clientkey/client_key.go b/backend/internal/domain/clientkey/client_key.go new file mode 100644 index 0000000000000000000000000000000000000000..cd1819f66d2d7ecc867b97b41fbb9cd5a7a77cae --- /dev/null +++ b/backend/internal/domain/clientkey/client_key.go @@ -0,0 +1,263 @@ +package clientkey + +import ( + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +const ( + DefaultRPMLimit = 120 + DefaultMaxConcurrent = 8 + MaxRPMLimit = 100000 + MaxConcurrent = 1024 + MaxBillingLimitTicks = 9_000_000_000_000_000 +) + +type ProviderScope uint8 + +const ( + ProviderScopeBuild ProviderScope = 1 << iota + ProviderScopeWeb + ProviderScopeConsole + ProviderScopeAll = ProviderScopeBuild | ProviderScopeWeb | ProviderScopeConsole +) + +type TierScope uint8 + +const ( + TierScopeFree TierScope = 1 << iota + TierScopeSuper + TierScopeUnknown + TierScopeAll = TierScopeFree | TierScopeSuper | TierScopeUnknown +) + +type AccountTier uint8 + +const ( + AccountTierFree AccountTier = iota + 1 + AccountTierSuper + AccountTierUnknown +) + +type AccountScope struct { + Providers ProviderScope + Tiers TierScope +} + +func ParseProviderScopeValues(values []string) (ProviderScope, bool) { + if len(values) == 0 { + return 0, false + } + var scope ProviderScope + for _, value := range values { + switch value { + case "all": + if len(values) != 1 { + return 0, false + } + return ProviderScopeAll, true + case string(account.ProviderBuild): + scope |= ProviderScopeBuild + case string(account.ProviderWeb): + scope |= ProviderScopeWeb + case string(account.ProviderConsole): + scope |= ProviderScopeConsole + default: + return 0, false + } + } + return NormalizeProviderScope(scope) +} + +func ParseTierScopeValues(values []string) (TierScope, bool) { + if len(values) == 0 { + return 0, false + } + var scope TierScope + for _, value := range values { + switch value { + case "all": + if len(values) != 1 { + return 0, false + } + return TierScopeAll, true + case "free": + scope |= TierScopeFree + case "super": + scope |= TierScopeSuper + default: + return 0, false + } + } + return NormalizeTierScope(scope) +} + +func (s ProviderScope) Values() []string { + value, valid := NormalizeProviderScope(s) + if !valid || value == ProviderScopeAll { + return []string{"all"} + } + values := make([]string, 0, 3) + if value&ProviderScopeBuild != 0 { + values = append(values, string(account.ProviderBuild)) + } + if value&ProviderScopeWeb != 0 { + values = append(values, string(account.ProviderWeb)) + } + if value&ProviderScopeConsole != 0 { + values = append(values, string(account.ProviderConsole)) + } + return values +} + +func (s TierScope) Values() []string { + value, valid := NormalizeTierScope(s) + if !valid || value == TierScopeAll { + return []string{"all"} + } + values := make([]string, 0, 2) + if value&TierScopeFree != 0 { + values = append(values, "free") + } + if value&TierScopeSuper != 0 { + values = append(values, "super") + } + return values +} + +func NormalizeProviderScope(value ProviderScope) (ProviderScope, bool) { + if value == 0 { + return ProviderScopeAll, true + } + if value&^ProviderScopeAll != 0 { + return value, false + } + return value, true +} + +func NormalizeTierScope(value TierScope) (TierScope, bool) { + if value == 0 { + return TierScopeAll, true + } + switch value { + case TierScopeFree, TierScopeSuper, TierScopeFree | TierScopeSuper, TierScopeAll: + return value, true + default: + return value, false + } +} + +func NormalizeAccountScope(value AccountScope) (AccountScope, bool) { + providers, providersValid := NormalizeProviderScope(value.Providers) + tiers, tiersValid := NormalizeTierScope(value.Tiers) + return AccountScope{Providers: providers, Tiers: tiers}, providersValid && tiersValid +} + +func (s ProviderScope) Allows(provider account.Provider) bool { + value, valid := NormalizeProviderScope(s) + if !valid { + return false + } + switch provider { + case account.ProviderBuild: + return value&ProviderScopeBuild != 0 + case account.ProviderWeb: + return value&ProviderScopeWeb != 0 + case account.ProviderConsole: + return value&ProviderScopeConsole != 0 + default: + return false + } +} + +func (s TierScope) Allows(tier AccountTier) bool { + value, valid := NormalizeTierScope(s) + if !valid { + return false + } + switch tier { + case AccountTierFree: + return value&TierScopeFree != 0 + case AccountTierSuper: + return value&TierScopeSuper != 0 + case AccountTierUnknown: + return value&TierScopeUnknown != 0 + default: + return false + } +} + +func (s AccountScope) AllowsProvider(provider account.Provider) bool { + value, valid := NormalizeAccountScope(s) + return valid && value.Providers.Allows(provider) +} + +// AllowsAccount applies provider restrictions to every channel while tier +// restrictions apply only to channels with a reliable tier classification. +func (s AccountScope) AllowsAccount(provider account.Provider, tier AccountTier) bool { + value, valid := NormalizeAccountScope(s) + if !valid || !value.Providers.Allows(provider) { + return false + } + if provider == account.ProviderConsole { + return true + } + return value.Tiers.Allows(tier) +} + +func (s AccountScope) IsRestricted() bool { + value, valid := NormalizeAccountScope(s) + return valid && (value.Providers != ProviderScopeAll || value.Tiers != TierScopeAll) +} + +func (k Key) AccountScope() AccountScope { + value, _ := NormalizeAccountScope(AccountScope{Providers: k.ProviderScope, Tiers: k.TierScope}) + return value +} + +// Key 表示下游客户端调用凭据及其限制。 +type Key struct { + ID uint64 + Name string + Prefix string + SecretHash string + EncryptedSecret string + Enabled bool + ExpiresAt *time.Time + RPMLimit int + MaxConcurrent int + BillingLimitUSDTicks int64 + BilledUsageUSDTicks int64 + ReservedUsageUSDTicks int64 + // AllowModelAliases enables discovery and use of dynamically generated reasoning-effort aliases. + // Registered compatibility aliases remain available to avoid breaking existing clients. + AllowModelAliases bool + AllowedModels []uint64 + // ProviderScope and TierScope narrow routing without adding request-time storage lookups. + ProviderScope ProviderScope + TierScope TierScope + LastUsedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// IsAvailable 判断客户端 Key 当前是否可用。 +func (k Key) IsAvailable(now time.Time) bool { + if !k.Enabled { + return false + } + return k.ExpiresAt == nil || now.Before(*k.ExpiresAt) +} + +func (k Key) AllowsModel(modelID uint64) bool { + if len(k.AllowedModels) == 0 { + return true + } + for _, allowed := range k.AllowedModels { + if allowed == modelID { + return true + } + } + return false +} diff --git a/backend/internal/domain/clientkey/client_key_test.go b/backend/internal/domain/clientkey/client_key_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d4e27246b696f1896bdfe4785948bd5cafc4e368 --- /dev/null +++ b/backend/internal/domain/clientkey/client_key_test.go @@ -0,0 +1,59 @@ +package clientkey + +import ( + "reflect" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestAccountScopeParsingAndNormalization(t *testing.T) { + if _, valid := ParseProviderScopeValues(nil); valid { + t.Fatal("an explicitly empty provider scope must be rejected") + } + if _, valid := ParseTierScopeValues(nil); valid { + t.Fatal("an explicitly empty tier scope must be rejected") + } + providers, valid := ParseProviderScopeValues([]string{"grok_build", "grok_web"}) + if !valid || providers != ProviderScopeBuild|ProviderScopeWeb { + t.Fatalf("provider scope = %d, valid = %v", providers, valid) + } + if _, valid := ParseProviderScopeValues([]string{"all", "grok_web"}); valid { + t.Fatal("all mixed with a provider must be rejected") + } + tiers, valid := ParseTierScopeValues([]string{"free", "super"}) + if !valid || tiers != TierScopeFree|TierScopeSuper { + t.Fatalf("tier scope = %d, valid = %v", tiers, valid) + } + if tiers == TierScopeAll { + t.Fatal("free + super must continue excluding unknown tiers") + } + if _, valid := ParseTierScopeValues([]string{"all", "free"}); valid { + t.Fatal("all mixed with a tier must be rejected") + } + if values := ProviderScopeAll.Values(); !reflect.DeepEqual(values, []string{"all"}) { + t.Fatalf("all provider values = %#v", values) + } + if values := tiers.Values(); !reflect.DeepEqual(values, []string{"free", "super"}) { + t.Fatalf("tier values = %#v", values) + } +} + +func TestAccountScopeAllowsProviderAndTierIntersection(t *testing.T) { + scope := AccountScope{Providers: ProviderScopeBuild | ProviderScopeConsole, Tiers: TierScopeFree} + if !scope.AllowsAccount(account.ProviderBuild, AccountTierFree) { + t.Fatal("Build Free should be allowed") + } + if scope.AllowsAccount(account.ProviderBuild, AccountTierSuper) { + t.Fatal("Build Super should be excluded") + } + if scope.AllowsAccount(account.ProviderWeb, AccountTierFree) { + t.Fatal("Web should be excluded by provider scope") + } + if !scope.AllowsAccount(account.ProviderConsole, AccountTierUnknown) { + t.Fatal("Console should ignore tier restrictions") + } + if (AccountScope{Providers: ProviderScopeBuild, Tiers: TierScopeFree | TierScopeSuper}).AllowsAccount(account.ProviderBuild, AccountTierUnknown) { + t.Fatal("Free + Super should exclude unknown Build accounts") + } +} diff --git a/backend/internal/domain/dashboard/dashboard.go b/backend/internal/domain/dashboard/dashboard.go new file mode 100644 index 0000000000000000000000000000000000000000..6d392caae5d74aba6e21582e46238275fe5f02b4 --- /dev/null +++ b/backend/internal/domain/dashboard/dashboard.go @@ -0,0 +1,80 @@ +package dashboard + +// Resources 表示 Dashboard 所需的资源总量与可用量。 +type Resources struct { + ActiveAccounts int64 + TotalAccounts int64 + BuildAccounts int64 + WebAccounts int64 + ConsoleAccounts int64 + EnabledModels int64 + TotalModels int64 + ActiveClientKeys int64 + TotalClientKeys int64 +} + +// Usage 表示指定时间窗口内的请求聚合。 +type Usage struct { + Requests int64 + SuccessfulRequests int64 + FailedRequests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 + FirstTokenSamples int64 + FirstTokenTotalMS int64 + ThroughputSamples int64 + ThroughputTokens int64 + GenerationTotalMS int64 +} + +// Bucket 表示一个固定时间桶内的请求和 token 数量。 +type Bucket struct { + Index int + Requests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 +} + +// ModelUsage 表示指定时间范围内按公开模型聚合的调用量。 +type ModelUsage struct { + Model string + Requests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 +} + +// ActivityBucket 表示活动热力图中的单日请求量。 +type ActivityBucket struct { + Index int + Requests int64 +} + +// ProviderUsage 表示指定时间范围内单个上游渠道的调用量。 +type ProviderUsage struct { + Provider string + Requests int64 + SuccessfulRequests int64 + Tokens int64 +} + +// Aggregate 表示持久化层返回的 Dashboard 聚合快照。 +type Aggregate struct { + Resources Resources + Usage Usage + Buckets []Bucket + ActivityBuckets []ActivityBucket + TopModels []ModelUsage + Providers []ProviderUsage +} diff --git a/backend/internal/domain/egress/egress.go b/backend/internal/domain/egress/egress.go new file mode 100644 index 0000000000000000000000000000000000000000..a7aaa58bdb11f0ee85e12b0bc257c0b4fe938256 --- /dev/null +++ b/backend/internal/domain/egress/egress.go @@ -0,0 +1,255 @@ +package egress + +import "time" + +type Mode string + +const ( + ModeDirect Mode = "direct" + ModeSingle Mode = "single" + ModePool Mode = "pool" +) + +type Scope string + +const ( + ScopeBuild Scope = "grok_build" + ScopeWeb Scope = "grok_web" + ScopeConsole Scope = "grok_console" + ScopeWebAsset Scope = "grok_web_asset" +) + +type Node struct { + ID uint64 + Name string + Scope Scope + Enabled bool + ProxyPool bool + SourceID uint64 + SourceKey string + AccountCapacity int + EncryptedProxyURL string + UserAgent string + EncryptedCloudflareCookie string + ClearanceRefreshedAt *time.Time + ClearanceFingerprint string + ClearanceBindingFingerprint string + Health float64 + FailureCount int + CooldownUntil *time.Time + LastError string + ProbeStatus ProbeStatus + LastProbedAt *time.Time + ProbeLatencyMS int + ExitIP string + ProbeError string + ProbeProvider ProbeProvider + IPv4Probe ProbeFamilyResult + IPv6Probe ProbeFamilyResult + AssignedAccountCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +type PublicNode struct { + ID uint64 + Name string + Scope Scope + Enabled bool + ProxyConfigured bool + ProxyPool bool + SourceID uint64 + AccountCapacity int + UserAgent string + CookieConfigured bool + AccountBoundProxy bool + Health float64 + FailureCount int + CooldownUntil *time.Time + LastError string + ProbeStatus ProbeStatus + LastProbedAt *time.Time + ProbeLatencyMS int + ExitIP string + ProbeError string + ProbeProvider ProbeProvider + IPv4Probe ProbeFamilyResult + IPv6Probe ProbeFamilyResult + AssignedAccountCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +type ProbeStatus string + +const ( + ProbeStatusUnknown ProbeStatus = "unknown" + ProbeStatusHealthy ProbeStatus = "healthy" + ProbeStatusUnhealthy ProbeStatus = "unhealthy" +) + +func (value ProbeStatus) IsValid() bool { + switch value { + case ProbeStatusUnknown, ProbeStatusHealthy, ProbeStatusUnhealthy: + return true + default: + return false + } +} + +// ProbeResult contains only operational metadata. It never stores or exposes +// proxy credentials. +type ProbeResult struct { + Status ProbeStatus + TestedAt time.Time + LatencyMS int + ExitIP string + Error string + Provider ProbeProvider + IPv4 ProbeFamilyResult + IPv6 ProbeFamilyResult +} + +// ProbeFamilyResult stores one address family's independent connectivity +// result. A zero TestedAt represents a family that has not been tested yet. +type ProbeFamilyResult struct { + Status ProbeStatus + TestedAt time.Time + LatencyMS int + ExitIP string + Error string +} + +// SubscriptionSource stores a write-only remote proxy subscription. The URL +// remains encrypted at rest and must never be returned by management APIs. +type SubscriptionSource struct { + ID uint64 + Name string + Scope Scope + Enabled bool + EncryptedURL string + RefreshIntervalSeconds int + DefaultAccountCapacity int + LastSyncedAt *time.Time + NextSyncAt *time.Time + LastSyncImported int + LastSyncError string + CreatedAt time.Time + UpdatedAt time.Time +} + +type PublicSubscriptionSource struct { + ID uint64 + Name string + Scope Scope + Enabled bool + URLConfigured bool + RefreshIntervalSeconds int + DefaultAccountCapacity int + LastSyncedAt *time.Time + NextSyncAt *time.Time + LastSyncImported int + LastSyncError string + CreatedAt time.Time + UpdatedAt time.Time +} + +// FallbackMode controls what happens when no primary egress node can be +// acquired for a request scope. The default is deliberately none so upgrades +// preserve the existing fail-closed behavior. +type FallbackMode string + +const ( + FallbackModeNone FallbackMode = "none" + FallbackModeDirect FallbackMode = "direct" + FallbackModeFixed FallbackMode = "fixed" +) + +func (value FallbackMode) IsValid() bool { + switch value { + case FallbackModeNone, FallbackModeDirect, FallbackModeFixed: + return true + default: + return false + } +} + +// Normalized maps the zero value left by pre-fallback database rows to the +// conservative disabled mode. +func (value FallbackMode) Normalized() FallbackMode { + if value == "" { + return FallbackModeNone + } + return value +} + +type FallbackConfig struct { + Mode FallbackMode + NodeID uint64 +} + +type ProbeProvider string + +const ( + ProbeProviderIPInfo ProbeProvider = "ipinfo" + ProbeProviderCloudflare ProbeProvider = "cloudflare" +) + +func (value ProbeProvider) IsValid() bool { + return value == ProbeProviderIPInfo || value == ProbeProviderCloudflare +} + +func (value ProbeProvider) Normalized() ProbeProvider { + if !value.IsValid() { + return ProbeProviderCloudflare + } + return value +} + +// OperationsConfig controls background probe, account assignment, and egress +// fallback work. It defaults to a conservative disabled state for mutations +// and fallback routing. +type OperationsConfig struct { + ProbeProvider ProbeProvider + ProbeIntervalSeconds int + AutoAssignEnabled bool + AutoBalanceEnabled bool + AssignmentIntervalSeconds int + Fallbacks map[Scope]FallbackConfig + UpdatedAt time.Time +} + +func DefaultOperationsConfig() OperationsConfig { + return OperationsConfig{ + ProbeProvider: ProbeProviderCloudflare, + ProbeIntervalSeconds: 900, + AssignmentIntervalSeconds: 300, + Fallbacks: map[Scope]FallbackConfig{ + ScopeBuild: {Mode: FallbackModeNone}, + ScopeWeb: {Mode: FallbackModeNone}, + ScopeConsole: {Mode: FallbackModeNone}, + ScopeWebAsset: {Mode: FallbackModeNone}, + }, + } +} + +// FallbackFor always returns a canonical, safe fallback value. It accepts +// sparse maps so older callers and historical records remain compatible. +func (value OperationsConfig) FallbackFor(scope Scope) FallbackConfig { + fallback := value.Fallbacks[scope] + fallback.Mode = fallback.Mode.Normalized() + if fallback.Mode != FallbackModeFixed { + fallback.NodeID = 0 + } + return fallback +} + +// SupportsScope reports whether a node can serve requests for the supplied +// scope. Console may intentionally reuse a Web browser proxy, and Web assets +// inherit a Web node when no asset-specific node is required. +func SupportsScope(nodeScope, requestScope Scope) bool { + if nodeScope == requestScope { + return true + } + return (requestScope == ScopeWebAsset || requestScope == ScopeConsole) && nodeScope == ScopeWeb +} diff --git a/backend/internal/domain/inference/response.go b/backend/internal/domain/inference/response.go new file mode 100644 index 0000000000000000000000000000000000000000..eb0967b87f4049cbbcbf637340a0763bc30189ac --- /dev/null +++ b/backend/internal/domain/inference/response.go @@ -0,0 +1,33 @@ +package inference + +import ( + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +// ResponseOwnership 记录上游 Response 资源所属账号,不保存请求或响应正文。 +type ResponseOwnership struct { + ResponseID string + AccountID uint64 + ClientKeyID uint64 + Provider account.Provider + PromptCacheKey string + ReasoningReplayKey string + ExpiresAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// WebResponseState 保存 Grok Web 本地 Responses 资源及其上游会话游标。 +type WebResponseState struct { + ResponseID string + AccountID uint64 + ConversationID string + UpstreamParentResponseID string + ResponseJSON string + Status string + ExpiresAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/domain/media/asset.go b/backend/internal/domain/media/asset.go new file mode 100644 index 0000000000000000000000000000000000000000..c515535733a5cd5c75f10920f5ff8b5583b5fc37 --- /dev/null +++ b/backend/internal/domain/media/asset.go @@ -0,0 +1,14 @@ +package media + +import "time" + +// Asset 表示已归档到本地媒体存储的不可变资源。 +type Asset struct { + ID string + Kind string + StorageKey string + MIMEType string + SizeBytes int64 + SHA256 string + CreatedAt time.Time +} diff --git a/backend/internal/domain/media/job.go b/backend/internal/domain/media/job.go new file mode 100644 index 0000000000000000000000000000000000000000..0d1b8591dbb9cb020c98646fa56db16c212cd8e7 --- /dev/null +++ b/backend/internal/domain/media/job.go @@ -0,0 +1,57 @@ +package media + +import "time" + +type Status string + +const ( + StatusQueued Status = "queued" + StatusInProgress Status = "in_progress" + StatusCompleted Status = "completed" + StatusFailed Status = "failed" +) + +// MaxInputJSONBytes is the persisted media_jobs.input_json ceiling (32 MiB). +// Keep the relational CHECK constraint and gateway encode guard aligned with this value. +const MaxInputJSONBytes = 32 << 20 + +// MaxInputImages is the maximum number of reference images accepted for a video job. +const MaxInputImages = 8 + +// Job 表示可跨进程重启恢复的异步视频任务。 +type Job struct { + ID string + RequestID string + ClientKeyID uint64 + ClientKeyName string + AccountID uint64 + AccountName string + EgressNodeID *uint64 + EgressNodeName string + EgressScope string + EgressMode string + Provider string + Model string + ModelRouteID uint64 + UpstreamModel string + Prompt string + Seconds int + Size string + Quality string + Status Status + Progress int + InputJSON string + InputImageCount int + UpstreamURL string + // ResultAssetID 指向本地媒体资产;XAI ZDR 上传完成后优先从此读取。 + ResultAssetID string + ContentType string + ErrorCode string + ErrorMessage string + LeaseUntil *time.Time + ClaimToken string + CreatedAt time.Time + UpdatedAt time.Time + CompletedAt *time.Time + UsageRecordedAt *time.Time +} diff --git a/backend/internal/domain/model/model.go b/backend/internal/domain/model/model.go new file mode 100644 index 0000000000000000000000000000000000000000..38cc8d6c8b40cd8ecb39873c8b5f5889dd18e70c --- /dev/null +++ b/backend/internal/domain/model/model.go @@ -0,0 +1,157 @@ +package model + +import ( + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +const ( + MaxPublicIDLength = 255 + MaxUpstreamModelLength = 255 +) + +type Capability string + +type Origin string + +const ( + CapabilityResponses Capability = "responses" + CapabilityChat Capability = "chat" + CapabilityImage Capability = "image" + CapabilityImageEdit Capability = "image_edit" + CapabilityVideo Capability = "video" +) + +const ( + OriginCatalog Origin = "catalog" + OriginDiscovered Origin = "discovered" + OriginManual Origin = "manual" +) + +// Route 表示带 Provider 前缀的内部路由 ID 到真实上游模型名的稳定映射。 +type Route struct { + ID uint64 + PublicID string + Provider account.Provider + UpstreamModel string + Capability Capability + Origin Origin + Enabled bool + BoundAccountIDs []uint64 + SupportedAccounts int + SyncedAccounts int + TotalAccounts int + LastSyncedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// NormalizePublicID 将内部路由 ID 规范化为稳定的 Provider 命名空间。 +// Provider 前缀只用于区分内部路由目标,不应直接暴露给下游客户端。 +func NormalizePublicID(provider account.Provider, value string) (string, bool) { + if !provider.IsValid() { + return "", false + } + localID := strings.TrimSpace(value) + if localID == "" { + return "", false + } + for _, candidate := range account.Providers() { + prefix := candidate.ModelNamespace() + "/" + if len(localID) < len(prefix) || !strings.EqualFold(localID[:len(prefix)], prefix) { + continue + } + if candidate != provider { + return "", false + } + localID = strings.TrimSpace(localID[len(prefix):]) + break + } + if localID == "" { + return "", false + } + publicID := provider.ModelNamespace() + "/" + localID + if len([]rune(publicID)) > MaxPublicIDLength { + return "", false + } + return publicID, true +} + +// IsCanonicalPublicID 判断内部路由 ID 是否已经采用精确的稳定命名空间。 +func IsCanonicalPublicID(provider account.Provider, value string) bool { + normalized, ok := NormalizePublicID(provider, value) + return ok && normalized == value +} + +// ExternalPublicID 返回下游客户端使用的不带 Provider 前缀的模型名称。 +func ExternalPublicID(provider account.Provider, value string) string { + value = strings.TrimSpace(value) + prefix := provider.ModelNamespace() + "/" + if provider.IsValid() && len(value) >= len(prefix) && strings.EqualFold(value[:len(prefix)], prefix) { + return strings.TrimSpace(value[len(prefix):]) + } + return value +} + +// PublicIDCandidates 将下游模型名称展开为按 Provider 优先级排列的内部路由 ID。 +// 已显式携带 Provider 前缀的名称只会匹配指定来源。 +func PublicIDCandidates(value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + for _, providerValue := range account.Providers() { + prefix := providerValue.ModelNamespace() + "/" + if len(value) >= len(prefix) && strings.EqualFold(value[:len(prefix)], prefix) { + normalized, ok := NormalizePublicID(providerValue, value) + if !ok { + return nil + } + return []string{normalized} + } + } + result := make([]string, 0, len(account.Providers())) + for _, providerValue := range account.Providers() { + if normalized, ok := NormalizePublicID(providerValue, value); ok { + result = append(result, normalized) + } + } + return result +} + +// NormalizeUpstreamModel 接受带或不带来源前缀的上游模型名称,并返回 Provider 实际接收的名称。 +func NormalizeUpstreamModel(provider account.Provider, value string) (string, bool) { + if !provider.IsValid() { + return "", false + } + upstream := strings.TrimSpace(value) + if upstream == "" { + return "", false + } + for _, candidate := range account.Providers() { + prefix := candidate.ModelNamespace() + "/" + if len(upstream) < len(prefix) || !strings.EqualFold(upstream[:len(prefix)], prefix) { + continue + } + if candidate != provider { + return "", false + } + upstream = strings.TrimSpace(upstream[len(prefix):]) + break + } + if upstream == "" || len([]rune(upstream)) > MaxUpstreamModelLength { + return "", false + } + return upstream, true +} + +// DisplayUpstreamModel 返回管理界面使用的带来源前缀上游模型名称。 +func DisplayUpstreamModel(provider account.Provider, value string) string { + upstream, ok := NormalizeUpstreamModel(provider, value) + if !ok { + return strings.TrimSpace(value) + } + return provider.ModelNamespace() + "/" + upstream +} diff --git a/backend/internal/domain/model/model_test.go b/backend/internal/domain/model/model_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0f90e294066c7d6d538819fd7eb73dd707b1649b --- /dev/null +++ b/backend/internal/domain/model/model_test.go @@ -0,0 +1,77 @@ +package model + +import ( + "strings" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestNormalizePublicIDUsesStableProviderNamespace(t *testing.T) { + tests := []struct { + provider account.Provider + input string + want string + }{ + {provider: account.ProviderBuild, input: "grok-4.3", want: "Build/grok-4.3"}, + {provider: account.ProviderWeb, input: "web/custom", want: "Web/custom"}, + {provider: account.ProviderConsole, input: " Console/grok-4.3 ", want: "Console/grok-4.3"}, + } + for _, test := range tests { + got, ok := NormalizePublicID(test.provider, test.input) + if !ok || got != test.want { + t.Fatalf("NormalizePublicID(%q, %q) = %q, %v; want %q", test.provider, test.input, got, ok, test.want) + } + } + if _, ok := NormalizePublicID(account.ProviderBuild, "Web/grok-4.3"); ok { + t.Fatal("cross-provider namespace was accepted") + } + if _, ok := NormalizePublicID(account.ProviderBuild, strings.Repeat("x", MaxPublicIDLength)); ok { + t.Fatal("overlong canonical public ID was accepted") + } +} + +func TestIsCanonicalPublicIDRequiresExactNamespace(t *testing.T) { + if !IsCanonicalPublicID(account.ProviderBuild, "Build/grok-4.3") { + t.Fatal("canonical Build model was rejected") + } + for _, value := range []string{"grok-4.3", "build/grok-4.3", " Build/grok-4.3 "} { + if IsCanonicalPublicID(account.ProviderBuild, value) { + t.Fatalf("non-canonical model %q was accepted", value) + } + } +} + +func TestExternalPublicIDAndCandidatesSeparateClientAndRouteNames(t *testing.T) { + if got := ExternalPublicID(account.ProviderBuild, "Build/grok-4.5"); got != "grok-4.5" { + t.Fatalf("external public ID = %q", got) + } + want := []string{"Build/grok-4.5", "Web/grok-4.5", "Console/grok-4.5"} + got := PublicIDCandidates("grok-4.5") + if len(got) != len(want) { + t.Fatalf("candidates = %#v", got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("candidate %d = %q; want %q", index, got[index], want[index]) + } + } + if got := PublicIDCandidates("Console/grok-4.5"); len(got) != 1 || got[0] != "Console/grok-4.5" { + t.Fatalf("qualified candidates = %#v", got) + } +} + +func TestNormalizeAndDisplayUpstreamModel(t *testing.T) { + for _, input := range []string{"grok-4.5", "Build/grok-4.5", " build/grok-4.5 "} { + got, ok := NormalizeUpstreamModel(account.ProviderBuild, input) + if !ok || got != "grok-4.5" { + t.Fatalf("NormalizeUpstreamModel(%q) = %q, %v", input, got, ok) + } + } + if _, ok := NormalizeUpstreamModel(account.ProviderBuild, "Console/grok-4.5"); ok { + t.Fatal("cross-provider upstream prefix was accepted") + } + if got := DisplayUpstreamModel(account.ProviderConsole, "grok-4.3"); got != "Console/grok-4.3" { + t.Fatalf("display upstream = %q", got) + } +} diff --git a/backend/internal/domain/model/reasoning.go b/backend/internal/domain/model/reasoning.go new file mode 100644 index 0000000000000000000000000000000000000000..d3025ef729475233204df0302f64665f8d0e3a67 --- /dev/null +++ b/backend/internal/domain/model/reasoning.go @@ -0,0 +1,204 @@ +package model + +import ( + "strings" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +// ReasoningEffort is a client-facing reasoning depth level accepted by Grok models. +// Only levels a model actually supports should be advertised or accepted as aliases. +type ReasoningEffort = string + +const ( + ReasoningEffortNone ReasoningEffort = "none" + ReasoningEffortLow ReasoningEffort = "low" + ReasoningEffortMedium ReasoningEffort = "medium" + ReasoningEffortHigh ReasoningEffort = "high" + ReasoningEffortXHigh ReasoningEffort = "xhigh" + ReasoningEffortMax ReasoningEffort = "max" +) + +// reasoningEffortSuffixes is ordered longest-first so "xhigh" wins over "high". +var reasoningEffortSuffixes = []string{ + ReasoningEffortXHigh, + ReasoningEffortMedium, + ReasoningEffortHigh, + ReasoningEffortLow, + ReasoningEffortNone, + ReasoningEffortMax, +} + +// grokReasoningCapabilities maps external public model IDs to the reasoning levels +// each model actually accepts. Values follow xAI docs and observed Build/Console behavior: +// - grok-4.5: low/medium/high (reasoning cannot be disabled; no xhigh/max) +// - grok-4.3: none/low/medium/high +// - grok-4.20-multi-agent: low/medium/high/xhigh (effort controls agent count) +// Unknown models default to none-only and never expand into effort aliases. +var grokReasoningCapabilities = map[string][]string{ + "grok-4.5": {ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}, + "grok-4.3": {ReasoningEffortNone, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}, + "grok-build-0.1": {ReasoningEffortNone}, + "grok-4.20-0309-reasoning": {ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}, + "grok-4.20-0309-non-reasoning": {ReasoningEffortNone}, + "grok-4.20-multi-agent-0309": {ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh}, + "grok-3-mini": {ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}, + "grok-3-mini-fast": {ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}, + "grok-composer-2.5-fast": {ReasoningEffortNone}, +} + +// SupportedReasoningEfforts returns the reasoning levels a public model ID actually supports. +// Provider prefixes are stripped; unknown models only advertise "none". +// Effort-suffixed aliases (e.g. grok-4.5-low) inherit the base model's levels. +func SupportedReasoningEfforts(publicModel string) []string { + slug := externalModelSlug(publicModel) + if base, _, ok := parseReasoningModelAliasSlug(slug); ok { + slug = base + } + return reasoningEffortsForSlug(slug) +} + +// SupportsReasoningEffort reports whether publicModel accepts the given effort level. +func SupportsReasoningEffort(publicModel, effort string) bool { + effort = strings.ToLower(strings.TrimSpace(effort)) + for _, level := range SupportedReasoningEfforts(publicModel) { + if level == effort { + return true + } + } + return false +} + +// DefaultReasoningEffort picks a stable default from supported levels (prefer medium). +// For effort-suffixed aliases the pinned effort is the default. +func DefaultReasoningEffort(publicModel string) string { + if _, effort, ok := ParseReasoningModelAlias(publicModel); ok { + return effort + } + levels := SupportedReasoningEfforts(publicModel) + for _, level := range levels { + if level == ReasoningEffortMedium { + return level + } + } + if len(levels) > 0 { + return levels[0] + } + return ReasoningEffortNone +} + +func reasoningEffortsForSlug(slug string) []string { + if levels, ok := grokReasoningCapabilities[slug]; ok { + return append([]string(nil), levels...) + } + return []string{ReasoningEffortNone} +} + +// ReasoningAliasPublicIDs returns effort-suffixed aliases for a base public model ID. +// Models with fewer than two controllable levels produce no aliases (base name is enough). +// Only levels the model truly supports are included — never a blanket none/low/medium/high/xhigh/max template. +func ReasoningAliasPublicIDs(publicModel string) []string { + base := strings.TrimSpace(publicModel) + if base == "" { + return nil + } + levels := SupportedReasoningEfforts(base) + if len(levels) < 2 { + return nil + } + // Prefer the external (unprefixed) form so clients see "grok-4.5-low", not "Build/grok-4.5-low". + external := externalModelSlug(base) + if external == "" { + return nil + } + aliases := make([]string, 0, len(levels)) + for _, level := range levels { + aliases = append(aliases, external+"-"+level) + } + return aliases +} + +// ParseReasoningModelAlias splits names like "grok-4.5-low" into base model + effort +// only when the base model actually supports that effort. +// Base retains any provider prefix present on the input (e.g. Build/grok-4.5). +func ParseReasoningModelAlias(publicModel string) (baseModel, effort string, ok bool) { + name := strings.TrimSpace(publicModel) + if name == "" { + return "", "", false + } + providerPrefix := "" + local := name + for _, providerValue := range account.Providers() { + prefix := providerValue.ModelNamespace() + "/" + if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) { + providerPrefix = name[:len(prefix)] + local = strings.TrimSpace(name[len(prefix):]) + break + } + } + baseLocal, effort, ok := parseReasoningModelAliasSlug(local) + if !ok { + return "", "", false + } + return providerPrefix + baseLocal, effort, true +} + +// parseReasoningModelAliasSlug parses an unprefixed model slug only. +// It looks up capabilities directly to avoid recursion with SupportedReasoningEfforts. +func parseReasoningModelAliasSlug(slug string) (base, effort string, ok bool) { + name := strings.TrimSpace(slug) + if name == "" { + return "", "", false + } + for _, level := range reasoningEffortSuffixes { + suffix := "-" + level + if len(name) <= len(suffix) { + continue + } + if !strings.EqualFold(name[len(name)-len(suffix):], suffix) { + continue + } + base = strings.TrimSpace(name[:len(name)-len(suffix)]) + if base == "" { + continue + } + // Aliases only exist when the base model has multiple controllable levels; + // single-level models (e.g. grok-build-0.1 → none only) keep the base name. + levels := reasoningEffortsForSlug(base) + if len(levels) < 2 || !levelsContain(levels, level) { + continue + } + return base, level, true + } + return "", "", false +} + +func levelsContain(levels []string, effort string) bool { + effort = strings.ToLower(strings.TrimSpace(effort)) + for _, level := range levels { + if level == effort { + return true + } + } + return false +} + +// IsReasoningModelAlias reports whether value is a valid effort-suffixed model alias. +func IsReasoningModelAlias(publicModel string) bool { + _, _, ok := ParseReasoningModelAlias(publicModel) + return ok +} + +func externalModelSlug(publicModel string) string { + value := strings.TrimSpace(publicModel) + if value == "" { + return "" + } + for _, providerValue := range account.Providers() { + prefix := providerValue.ModelNamespace() + "/" + if len(value) >= len(prefix) && strings.EqualFold(value[:len(prefix)], prefix) { + return strings.TrimSpace(value[len(prefix):]) + } + } + return value +} diff --git a/backend/internal/domain/model/reasoning_test.go b/backend/internal/domain/model/reasoning_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e0e3bc0c26093d7c11051b6c3d757236f3613ce2 --- /dev/null +++ b/backend/internal/domain/model/reasoning_test.go @@ -0,0 +1,76 @@ +package model + +import "testing" + +func TestSupportedReasoningEffortsPerModel(t *testing.T) { + tests := []struct { + model string + want []string + }{ + {model: "grok-4.5", want: []string{"low", "medium", "high"}}, + {model: "Build/grok-4.5", want: []string{"low", "medium", "high"}}, + {model: "grok-4.3", want: []string{"none", "low", "medium", "high"}}, + {model: "grok-4.20-0309-reasoning", want: []string{"low", "medium", "high"}}, + {model: "grok-4.20-multi-agent-0309", want: []string{"low", "medium", "high", "xhigh"}}, + {model: "grok-build-0.1", want: []string{"none"}}, + {model: "unknown-model", want: []string{"none"}}, + } + for _, test := range tests { + got := SupportedReasoningEfforts(test.model) + if len(got) != len(test.want) { + t.Fatalf("%s levels = %#v, want %#v", test.model, got, test.want) + } + for i := range got { + if got[i] != test.want[i] { + t.Fatalf("%s levels = %#v, want %#v", test.model, got, test.want) + } + } + } + if SupportsReasoningEffort("grok-4.5", "none") || SupportsReasoningEffort("grok-4.5", "xhigh") || SupportsReasoningEffort("grok-4.5", "max") { + t.Fatal("grok-4.5 must not advertise none/xhigh/max") + } + if !SupportsReasoningEffort("grok-4.3", "none") || SupportsReasoningEffort("grok-4.3", "xhigh") { + t.Fatal("grok-4.3 effort support mismatch") + } +} + +func TestParseReasoningModelAlias(t *testing.T) { + tests := []struct { + name string + input string + wantBase string + wantLevel string + wantOK bool + }{ + {name: "grok-4.5 low", input: "grok-4.5-low", wantBase: "grok-4.5", wantLevel: "low", wantOK: true}, + {name: "grok-4.5 high", input: "grok-4.5-high", wantBase: "grok-4.5", wantLevel: "high", wantOK: true}, + {name: "grok-4.5 rejects none", input: "grok-4.5-none", wantOK: false}, + {name: "grok-4.5 rejects xhigh", input: "grok-4.5-xhigh", wantOK: false}, + {name: "grok-4.3 none", input: "grok-4.3-none", wantBase: "grok-4.3", wantLevel: "none", wantOK: true}, + {name: "xhigh before high", input: "grok-4.20-multi-agent-0309-xhigh", wantBase: "grok-4.20-multi-agent-0309", wantLevel: "xhigh", wantOK: true}, + {name: "prefixed base", input: "Build/grok-4.5-medium", wantBase: "Build/grok-4.5", wantLevel: "medium", wantOK: true}, + {name: "base model not alias", input: "grok-4.5", wantOK: false}, + {name: "single-level model no alias", input: "grok-build-0.1-none", wantOK: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + base, level, ok := ParseReasoningModelAlias(test.input) + if ok != test.wantOK || base != test.wantBase || level != test.wantLevel { + t.Fatalf("ParseReasoningModelAlias(%q) = %q, %q, %v; want %q, %q, %v", + test.input, base, level, ok, test.wantBase, test.wantLevel, test.wantOK) + } + }) + } +} + +func TestReasoningAliasPublicIDs(t *testing.T) { + if got := ReasoningAliasPublicIDs("grok-4.5"); len(got) != 3 || got[0] != "grok-4.5-low" || got[2] != "grok-4.5-high" { + t.Fatalf("grok-4.5 aliases = %#v", got) + } + if got := ReasoningAliasPublicIDs("Build/grok-4.3"); len(got) != 4 || got[0] != "grok-4.3-none" { + t.Fatalf("grok-4.3 aliases = %#v", got) + } + if got := ReasoningAliasPublicIDs("grok-build-0.1"); len(got) != 0 { + t.Fatalf("single-level model should not expand aliases: %#v", got) + } +} diff --git a/backend/internal/domain/settings/settings.go b/backend/internal/domain/settings/settings.go new file mode 100644 index 0000000000000000000000000000000000000000..c6b781dae6c66bfa5e2036c33d20d466b5557d3e --- /dev/null +++ b/backend/internal/domain/settings/settings.go @@ -0,0 +1,135 @@ +package settings + +import "time" + +const ( + DefaultBuildResponseHeaderTimeout = 5 * time.Minute + MinBuildResponseHeaderTimeout = 30 * time.Second + MaxBuildResponseHeaderTimeout = 30 * time.Minute +) + +// Config 表示可跨重启持久化并支持热加载的网关运行参数。 +type Config struct { + Server ServerConfig + ProviderBuild ProviderBuildConfig + ProviderWeb ProviderWebConfig + ProviderConsole ProviderConsoleConfig + Batch BatchConfig + Media MediaConfig + Frontend FrontendConfig + Routing RoutingConfig + Audit AuditConfig + ClientKeyDefaults ClientKeyDefaultsConfig + Accounts AccountsConfig +} + +// ServerConfig 定义可热更新的推理入口容量参数。 +type ServerConfig struct { + MaxConcurrentRequests int +} + +// FrontendConfig 定义公开 API 地址的运行时覆盖值;留空时使用配置文件值。 +type FrontendConfig struct { + PublicAPIBaseURL string +} + +type ProviderConsoleConfig struct { + BaseURL string + ChatTimeout time.Duration +} + +type MediaConfig struct { + MaxImageBytes int64 + MaxTotalBytes int64 + CleanupThresholdPercent int + CleanupInterval time.Duration +} + +type ProviderWebConfig struct { + BaseURL string + StatsigMode string + StatsigManualValue string + StatsigSignerURL string + ClearanceMode string + FlareSolverrURL string + ClearanceTimeout time.Duration + ClearanceRefresh time.Duration + QuotaTimeout time.Duration + ChatTimeout time.Duration + ImageTimeout time.Duration + VideoTimeout time.Duration + MediaConcurrency int + AllowNSFW bool + RecoveryBackoffBase time.Duration + RecoveryBackoffMax time.Duration +} + +// BatchConfig 定义账号导入、转换、同步和凭据刷新的并发上限。 +type BatchConfig struct { + ImportConcurrency int + ConversionConcurrency int + SyncConcurrency int + RefreshConcurrency int + RandomDelay *time.Duration +} + +// ProviderBuildConfig 定义 Grok Build CLI 上游协议标识。 +type ProviderBuildConfig struct { + BaseURL string + FallbackBaseURL string + ClientVersion string + ClientIdentifier string + TokenAuth string + UserAgent string + ResponseHeaderTimeout time.Duration +} + +// RoutingConfig 定义会话粘性、冷却和故障切换边界。 +type RoutingConfig struct { + StickyTTL time.Duration + CooldownBase time.Duration + CooldownMax time.Duration + CapacityWait time.Duration + MaxAttempts int + PreferFreeBuild bool + SegmentedSelector *SegmentedSelectorConfig +} + +// SegmentedSelectorConfig persists the bounded selector policy. +// A nil value means the stored settings predate this policy and startup defaults must be preserved. +type SegmentedSelectorConfig struct { + // ActiveEnabled retains the original persisted field name so existing disabled policies remain disabled. + ActiveEnabled bool + MinCandidates int + WindowSize int +} + +// AuditConfig 定义请求审计异步写入参数。 +type AuditConfig struct { + BufferSize int + BatchSize int + FlushInterval time.Duration + CommitDelay time.Duration +} + +// ClientKeyDefaultsConfig 定义新建客户端密钥的默认限制。 +type ClientKeyDefaultsConfig struct { + RPMLimit int + MaxConcurrent int +} + +// AccountsConfig 定义账号池后台维护策略;默认全部关闭。 +type AccountsConfig struct { + // MarkBuildForbiddenReauth marks high-confidence Grok Build permission denials as requiring reauthorization. + MarkBuildForbiddenReauth bool + // BuildForbiddenReauthCodes contains exact upstream error codes that opt into account invalidation. + BuildForbiddenReauthCodes []string + // AutoCleanReauthEnabled 为 true 时,周期性删除已标记 reauthRequired 且超过 minAge 的账号。 + AutoCleanReauthEnabled bool + // AutoCleanReauthInterval 自动清理扫描间隔。 + AutoCleanReauthInterval time.Duration + // AutoCleanReauthMinAge 仅删除 reauth_marked_at 早于该时长的 reauthRequired 账号。 + AutoCleanReauthMinAge time.Duration + // AutoCleanIncludeDisabled 为 true 时,reauth 清理时包含 enabled=false 的账号。 + AutoCleanIncludeDisabled bool +} diff --git a/backend/internal/infra/config/config.go b/backend/internal/infra/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..b9099bf56c7f9287d21921cff2a3338c5e4756ed --- /dev/null +++ b/backend/internal/infra/config/config.go @@ -0,0 +1,725 @@ +package config + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "github.com/chenyme/grok2api/backend/internal/pkg/signerurl" + "gopkg.in/yaml.v3" +) + +const ( + StatsigModeManual = "manual" + StatsigModeURL = "url" + ClearanceModeManual = "manual" + ClearanceModeFlareSolverr = "flaresolverr" + DefaultStatsigSignerURL = "https://grok.wodf.de/sign" + DefaultFlareSolverrURL = "http://flaresolverr:8191" + RecommendedBuildClientVersion = "0.2.111" + RecommendedBuildUserAgent = "grok-shell/" + RecommendedBuildClientVersion + " (linux; x86_64)" + + maxServerBodyBytes = 256 << 20 + maxRequestTimeout = 24 * time.Hour + maxReadTimeout = time.Hour + maxRoutingTTL = 30 * 24 * time.Hour + maxRoutingCooldown = 24 * time.Hour + maxRoutingAttempts = 200 + minAuditFlushInterval = 10 * time.Millisecond + maxAuditFlushInterval = time.Minute + minAuditCommitDelay = time.Millisecond + maxAuditCommitDelay = 50 * time.Millisecond + maxAuditBufferSize = 262144 + maxAuditBatchSize = 4096 + maxDeploymentReplicas = 1024 +) + +const unlimitedRoutingAttempts = -1 + +var buildForbiddenCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + +// Config 表示后端运行配置。 +type Config struct { + Server ServerConfig `yaml:"server"` + Frontend FrontendConfig `yaml:"frontend"` + Database DatabaseConfig `yaml:"database"` + RuntimeStore RuntimeStoreConfig `yaml:"runtimeStore"` + Deployment DeploymentConfig `yaml:"deployment"` + Auth AuthConfig `yaml:"auth"` + Secrets Secrets `yaml:"secrets"` + BootstrapAdmin BootstrapAdminConfig `yaml:"bootstrapAdmin"` + Provider ProviderConfig `yaml:"provider"` + Batch BatchConfig `yaml:"-"` + Media MediaConfig `yaml:"media"` + Routing RoutingConfig `yaml:"routing"` + Audit AuditConfig `yaml:"audit"` + ClientKeyDefaults ClientKeyDefaultsConfig `yaml:"clientKeyDefaults"` + Accounts AccountsConfig `yaml:"-"` +} + +type ServerConfig struct { + Listen string `yaml:"listen"` + MaxBodyBytes int64 `yaml:"maxBodyBytes"` + MaxConcurrentRequests int `yaml:"maxConcurrentRequests"` + ReadTimeout Duration `yaml:"readTimeout"` + RequestTimeout Duration `yaml:"requestTimeout"` + SwaggerEnabled bool `yaml:"swaggerEnabled"` +} + +type FrontendConfig struct { + PublicAPIBaseURL string `yaml:"publicApiBaseURL"` + PublicAPIBaseURLOverride string `yaml:"-"` + StaticPath string `yaml:"staticPath"` +} + +const DefaultPublicAPIBaseURL = "http://127.0.0.1:8000" + +// EffectivePublicAPIBaseURL 按运行设置、配置文件、内置默认值的顺序解析公开地址。 +func (c FrontendConfig) EffectivePublicAPIBaseURL() string { + for _, value := range []string{c.PublicAPIBaseURLOverride, c.PublicAPIBaseURL} { + if value = strings.TrimRight(strings.TrimSpace(value), "/"); value != "" { + return value + } + } + return DefaultPublicAPIBaseURL +} + +type DatabaseConfig struct { + Driver string `yaml:"driver"` + SQLite SQLiteDatabaseConfig `yaml:"sqlite"` + Postgres PostgresDatabaseConfig `yaml:"postgres"` +} + +type SQLiteDatabaseConfig struct { + Path string `yaml:"path"` +} + +type PostgresDatabaseConfig struct { + DSN string `yaml:"dsn"` + MaxOpenConns int `yaml:"maxOpenConns"` + MaxIdleConns int `yaml:"maxIdleConns"` +} + +type RuntimeStoreConfig struct { + Driver string `yaml:"driver"` + Redis RedisRuntimeConfig `yaml:"redis"` +} + +type DeploymentConfig struct { + Replicas int `yaml:"replicas"` + InstanceID string `yaml:"instanceID"` + ClusterID string `yaml:"clusterID"` + SharedMedia bool `yaml:"sharedMedia"` +} + +type RedisRuntimeConfig struct { + Address string `yaml:"address"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Database int `yaml:"database"` + KeyPrefix string `yaml:"keyPrefix"` + TLS bool `yaml:"tls"` +} + +type AuthConfig struct { + AccessTokenTTL Duration `yaml:"accessTokenTTL"` + RefreshTokenTTL Duration `yaml:"refreshTokenTTL"` + SecureCookies bool `yaml:"secureCookies"` +} + +type ProviderConfig struct { + Build BuildProviderConfig `yaml:"build"` + Web WebProviderConfig `yaml:"web"` + Console ConsoleProviderConfig `yaml:"console"` +} + +type BuildProviderConfig struct { + BaseURL string `yaml:"baseURL"` + FallbackBaseURL string `yaml:"fallbackBaseURL"` + ClientVersion string `yaml:"clientVersion"` + ClientIdentifier string `yaml:"clientIdentifier"` + TokenAuth string `yaml:"tokenAuth"` + UserAgent string `yaml:"userAgent"` + ResponseHeaderTimeout Duration `yaml:"-"` +} + +// DefaultBuildFallbackBaseURL 是主 Build API 对可回退推理操作 403 时探测的 XAI API 根地址。 +const DefaultBuildFallbackBaseURL = "https://api.x.ai/v1" + +type WebProviderConfig struct { + BaseURL string `yaml:"baseURL"` + StatsigMode string `yaml:"-"` + StatsigManualValue string `yaml:"-"` + StatsigSignerURL string `yaml:"-"` + ClearanceMode string `yaml:"-"` + FlareSolverrURL string `yaml:"-"` + ClearanceTimeout Duration `yaml:"-"` + ClearanceRefresh Duration `yaml:"-"` + QuotaTimeout Duration `yaml:"quotaTimeout"` + ChatTimeout Duration `yaml:"chatTimeout"` + ImageTimeout Duration `yaml:"imageTimeout"` + VideoTimeout Duration `yaml:"videoTimeout"` + MediaConcurrency int `yaml:"mediaConcurrency"` + AllowNSFW bool `yaml:"allowNSFW"` + RecoveryBackoffBase Duration `yaml:"recoveryBackoffBase"` + RecoveryBackoffMax Duration `yaml:"recoveryBackoffMax"` +} + +type ConsoleProviderConfig struct { + BaseURL string `yaml:"baseURL"` + LegacyUserAgent string `yaml:"userAgent"` // Deprecated: 仅用于兼容旧配置文件,不参与请求。 + ChatTimeout Duration `yaml:"chatTimeout"` +} + +// BatchConfig 定义可热加载的账号批量任务并发上限。 +type BatchConfig struct { + ImportConcurrency int + ConversionConcurrency int + SyncConcurrency int + RefreshConcurrency int + RandomDelay Duration +} + +type MediaConfig struct { + Driver string `yaml:"driver"` + MaxImageBytes int64 `yaml:"-"` + MaxTotalBytes int64 `yaml:"-"` + CleanupThresholdPercent int `yaml:"-"` + CleanupInterval Duration `yaml:"-"` + Local LocalMediaConfig `yaml:"local"` +} + +type LocalMediaConfig struct { + Path string `yaml:"path"` +} + +type RoutingConfig struct { + StickyTTL Duration `yaml:"stickyTTL"` + CooldownBase Duration `yaml:"cooldownBase"` + CooldownMax Duration `yaml:"cooldownMax"` + CapacityWait Duration `yaml:"capacityWait"` + MaxAttempts int `yaml:"maxAttempts"` + PreferFreeBuild bool `yaml:"preferFreeBuild"` + SegmentedSelectorEnabled bool `yaml:"segmentedSelectorEnabled"` + SegmentedMinCandidates int `yaml:"segmentedSelectorMinCandidates"` + SegmentedWindowSize int `yaml:"segmentedSelectorWindowSize"` + ReasoningReplayEnabled bool `yaml:"reasoningReplayEnabled"` + ReasoningReplayTTL Duration `yaml:"reasoningReplayTTL"` + ReasoningReplayMaxEntries int `yaml:"reasoningReplayMaxEntries"` +} + +type AuditConfig struct { + BufferSize int `yaml:"bufferSize"` + BatchSize int `yaml:"batchSize"` + FlushInterval Duration `yaml:"flushInterval"` + CommitDelay Duration `yaml:"commitDelay"` + LedgerMode string `yaml:"ledgerMode"` + LedgerFailureThreshold int `yaml:"ledgerFailureThreshold"` + LedgerUnhealthyGrace Duration `yaml:"ledgerUnhealthyGrace"` + LedgerQueueHighWatermarkPct int `yaml:"ledgerQueueHighWatermarkPercent"` +} + +type ClientKeyDefaultsConfig struct { + RPMLimit int `yaml:"rpmLimit"` + MaxConcurrent int `yaml:"maxConcurrent"` +} + +// AccountsConfig 定义可热加载的账号池维护策略;默认全部关闭。 +type AccountsConfig struct { + MarkBuildForbiddenReauth bool + BuildForbiddenReauthCodes []string + AutoCleanReauthEnabled bool + AutoCleanReauthInterval Duration + AutoCleanReauthMinAge Duration + AutoCleanIncludeDisabled bool +} + +type Secrets struct { + JWTSecret string `yaml:"jwtSecret"` + CredentialEncryptionKey string `yaml:"credentialEncryptionKey"` +} + +type BootstrapAdminConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// Duration 支持在 YAML 中使用 10m、1h 等可读时间格式。 +type Duration time.Duration + +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + parsed, err := time.ParseDuration(node.Value) + if err != nil { + return err + } + *d = Duration(parsed) + return nil +} + +func (d Duration) MarshalYAML() (any, error) { return d.String(), nil } + +func (d Duration) Value() time.Duration { return time.Duration(d) } + +func (d Duration) String() string { + value := d.Value().String() + if strings.HasSuffix(value, "m0s") { + value = strings.TrimSuffix(value, "0s") + } + if strings.HasSuffix(value, "h0m") { + value = strings.TrimSuffix(value, "0m") + } + return value +} + +// Load 从 YAML 加载启动配置,并为非敏感运行参数补充代码默认值。 +func Load(path string) (Config, error) { + cfg := defaultConfig() + loadedFrom := "" + if strings.TrimSpace(path) != "" { + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return Config{}, fmt.Errorf("读取配置文件: %w", err) + } + if err == nil { + loadedFrom = path + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) { + return Config{}, fmt.Errorf("解析配置文件: %w", err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err != nil { + return Config{}, fmt.Errorf("解析配置文件: %w", err) + } + return Config{}, errors.New("配置文件只能包含一个 YAML 文档") + } + } + } + if loadedFrom != "" { + if err := resolveRelativePaths(&cfg, loadedFrom); err != nil { + return Config{}, err + } + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func resolveRelativePaths(cfg *Config, configPath string) error { + absoluteConfigPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("解析配置文件路径: %w", err) + } + baseDir := filepath.Dir(absoluteConfigPath) + if cfg.Database.Driver == "sqlite" { + path := strings.TrimSpace(cfg.Database.SQLite.Path) + if path != "" && !filepath.IsAbs(path) { + cfg.Database.SQLite.Path = filepath.Clean(filepath.Join(baseDir, path)) + } + } + mediaPath := strings.TrimSpace(cfg.Media.Local.Path) + if mediaPath != "" && !filepath.IsAbs(mediaPath) { + cfg.Media.Local.Path = filepath.Clean(filepath.Join(baseDir, mediaPath)) + } + staticPath := strings.TrimSpace(cfg.Frontend.StaticPath) + if staticPath != "" && !filepath.IsAbs(staticPath) { + cfg.Frontend.StaticPath = filepath.Clean(filepath.Join(baseDir, staticPath)) + } + return nil +} + +// Validate 校验启动所需的安全配置和运行边界。 +func (c Config) Validate() error { + if strings.TrimSpace(c.Server.Listen) == "" { + return errors.New("server.listen 不能为空") + } + if c.Server.MaxBodyBytes <= 0 || c.Server.MaxBodyBytes > maxServerBodyBytes { + return fmt.Errorf("server.maxBodyBytes 必须在 1 到 %d 字节之间", maxServerBodyBytes) + } + if c.Server.ReadTimeout.Value() <= 0 || c.Server.ReadTimeout.Value() > maxReadTimeout { + return errors.New("server.readTimeout 必须大于零且不超过 1 小时") + } + if c.Server.RequestTimeout.Value() <= 0 || c.Server.RequestTimeout.Value() > maxRequestTimeout { + return errors.New("server.requestTimeout 必须大于零且不超过 24 小时") + } + if c.Server.MaxConcurrentRequests < 1 || c.Server.MaxConcurrentRequests > 100000 { + return errors.New("server.maxConcurrentRequests 必须在 1 到 100000 之间") + } + for _, item := range []struct { + name string + value string + }{ + {name: "frontend.publicApiBaseURL", value: c.Frontend.PublicAPIBaseURL}, + {name: "frontend.publicApiBaseURL 运行设置", value: c.Frontend.PublicAPIBaseURLOverride}, + } { + if publicBase := strings.TrimSpace(item.value); publicBase != "" { + publicAPIURL, err := url.ParseRequestURI(publicBase) + if err != nil || (publicAPIURL.Scheme != "http" && publicAPIURL.Scheme != "https") || publicAPIURL.Host == "" || publicAPIURL.User != nil || publicAPIURL.RawQuery != "" || publicAPIURL.Fragment != "" { + return fmt.Errorf("%s 必须是不含凭据、查询参数和片段的 HTTP(S) URL", item.name) + } + } + } + switch c.Database.Driver { + case "sqlite": + if strings.TrimSpace(c.Database.SQLite.Path) == "" { + return errors.New("database.sqlite.path 不能为空") + } + case "postgres": + if strings.TrimSpace(c.Database.Postgres.DSN) == "" { + return errors.New("database.postgres.dsn 不能为空") + } + if c.Database.Postgres.MaxOpenConns < 1 || c.Database.Postgres.MaxOpenConns > 1000 || c.Database.Postgres.MaxIdleConns < 0 || c.Database.Postgres.MaxIdleConns > c.Database.Postgres.MaxOpenConns { + return errors.New("database.postgres 连接池配置无效") + } + default: + return errors.New("database.driver 必须是 sqlite 或 postgres") + } + switch c.RuntimeStore.Driver { + case "memory": + case "redis": + if strings.TrimSpace(c.RuntimeStore.Redis.Address) == "" { + return errors.New("runtimeStore.redis.address 不能为空") + } + if c.RuntimeStore.Redis.Database < 0 || c.RuntimeStore.Redis.Database > 1024 { + return errors.New("runtimeStore.redis.database 必须在 0 到 1024 之间") + } + if prefix := strings.TrimSpace(c.RuntimeStore.Redis.KeyPrefix); prefix == "" || len(prefix) > 128 { + return errors.New("runtimeStore.redis.keyPrefix 必须在 1 到 128 个字符之间") + } + default: + return errors.New("runtimeStore.driver 必须是 memory 或 redis") + } + if c.Deployment.Replicas < 1 || c.Deployment.Replicas > maxDeploymentReplicas { + return fmt.Errorf("deployment.replicas 必须在 1 到 %d 之间", maxDeploymentReplicas) + } + if c.Deployment.Replicas > 1 { + if c.Database.Driver != "postgres" { + return errors.New("多实例部署必须使用 PostgreSQL") + } + if c.RuntimeStore.Driver != "redis" { + return errors.New("多实例部署必须使用 Redis 运行态存储") + } + if strings.TrimSpace(c.Deployment.InstanceID) == "" { + return errors.New("多实例部署必须配置 deployment.instanceID") + } + if strings.TrimSpace(c.Deployment.ClusterID) == "" { + return errors.New("多实例部署必须配置 deployment.clusterID") + } + if !c.Deployment.SharedMedia { + return errors.New("多实例部署必须确认 deployment.sharedMedia=true 并挂载共享媒体目录") + } + } + if c.Media.Driver != "local" { + return errors.New("media.driver 当前仅支持 local") + } + if strings.TrimSpace(c.Media.Local.Path) == "" { + return errors.New("media.local.path 不能为空") + } + if c.Media.MaxImageBytes < 1<<20 || c.Media.MaxImageBytes > 32<<20 { + return errors.New("media.maxImageBytes 必须在 1 MiB 到 32 MiB 之间") + } + if c.Media.MaxTotalBytes < c.Media.MaxImageBytes || c.Media.MaxTotalBytes > 1<<40 { + return errors.New("media.maxTotalBytes 必须不小于单图上限且不超过 1 TiB") + } + if c.Media.CleanupThresholdPercent < 50 || c.Media.CleanupThresholdPercent > 95 { + return errors.New("media.cleanupThresholdPercent 必须在 50 到 95 之间") + } + if c.Media.CleanupInterval.Value() < time.Minute || c.Media.CleanupInterval.Value() > 24*time.Hour { + return errors.New("media.cleanupInterval 必须在 1 分钟到 24 小时之间") + } + if len(c.Secrets.JWTSecret) < 32 { + return errors.New("secrets.jwtSecret 至少需要 32 个字符") + } + if isExampleSecret(c.Secrets.JWTSecret) { + return errors.New("secrets.jwtSecret 不能使用示例占位值") + } + if !validCredentialEncryptionKey(c.Secrets.CredentialEncryptionKey) { + return errors.New("secrets.credentialEncryptionKey 必须是 Base64 编码的 32 字节密钥") + } + if isExampleSecret(c.BootstrapAdmin.Password) { + return errors.New("bootstrapAdmin.password 不能使用示例占位值") + } + if c.Auth.AccessTokenTTL.Value() <= 0 || c.Auth.RefreshTokenTTL.Value() <= 0 { + return errors.New("JWT 有效期必须大于零") + } + if err := validateAPIBaseURL("provider.build.baseURL", c.Provider.Build.BaseURL, false); err != nil { + return err + } + fallbackBase := strings.TrimSpace(c.Provider.Build.FallbackBaseURL) + if fallbackBase == "" { + fallbackBase = DefaultBuildFallbackBaseURL + } + if err := validateAPIBaseURL("provider.build.fallbackBaseURL", fallbackBase, true); err != nil { + return err + } + if strings.TrimSpace(c.Provider.Build.ClientVersion) == "" || strings.TrimSpace(c.Provider.Build.ClientIdentifier) == "" || strings.TrimSpace(c.Provider.Build.TokenAuth) == "" || strings.TrimSpace(c.Provider.Build.UserAgent) == "" { + return errors.New("provider.build 客户端标识不能为空") + } + if timeout := c.Provider.Build.ResponseHeaderTimeout.Value(); timeout < settingsdomain.MinBuildResponseHeaderTimeout || timeout > settingsdomain.MaxBuildResponseHeaderTimeout { + return errors.New("Grok Build 响应头超时必须在 30 秒到 30 分钟之间") + } + webURL, err := url.ParseRequestURI(strings.TrimSpace(c.Provider.Web.BaseURL)) + if err != nil || webURL.Scheme != "https" || webURL.Host == "" || webURL.User != nil { + return errors.New("provider.web.baseURL 必须是无凭据的 HTTPS URL") + } + switch c.Provider.Web.StatsigMode { + case StatsigModeManual: + if !validStatsigID(c.Provider.Web.StatsigManualValue) { + return errors.New("provider.web 手动 x-statsig-id 格式无效") + } + case StatsigModeURL: + if err := signerurl.Validate(c.Provider.Web.StatsigSignerURL); err != nil { + return fmt.Errorf("provider.web Statsig 签名 URL 无效: %w", err) + } + default: + return errors.New("provider.web Statsig 模式必须是 manual 或 url") + } + switch c.Provider.Web.ClearanceMode { + case ClearanceModeManual: + case ClearanceModeFlareSolverr: + if err := validateFlareSolverrURL(c.Provider.Web.FlareSolverrURL); err != nil { + return fmt.Errorf("provider.web FlareSolverr URL 无效: %w", err) + } + default: + return errors.New("provider.web Clearance 模式必须是 manual 或 flaresolverr") + } + if c.Provider.Web.ClearanceTimeout.Value() < 10*time.Second || c.Provider.Web.ClearanceTimeout.Value() > 5*time.Minute { + return errors.New("provider.web Clearance 超时必须在 10 秒到 5 分钟之间") + } + if c.Provider.Web.ClearanceRefresh.Value() < time.Minute || c.Provider.Web.ClearanceRefresh.Value() > 24*time.Hour { + return errors.New("provider.web Clearance 刷新间隔必须在 1 分钟到 24 小时之间") + } + if c.Provider.Web.QuotaTimeout.Value() < time.Second || c.Provider.Web.QuotaTimeout.Value() > 2*time.Minute || + c.Provider.Web.ChatTimeout.Value() < 5*time.Second || c.Provider.Web.ChatTimeout.Value() > 30*time.Minute || + c.Provider.Web.ImageTimeout.Value() < 5*time.Second || c.Provider.Web.ImageTimeout.Value() > 30*time.Minute || + c.Provider.Web.VideoTimeout.Value() < time.Minute || c.Provider.Web.VideoTimeout.Value() > 2*time.Hour { + return errors.New("provider.web 上游超时配置无效") + } + if c.Provider.Web.MediaConcurrency < 1 || c.Provider.Web.MediaConcurrency > 64 { + return errors.New("provider.web 媒体并发必须在 1 到 64 之间") + } + consoleURL, err := url.ParseRequestURI(strings.TrimSpace(c.Provider.Console.BaseURL)) + if err != nil || consoleURL.Scheme != "https" || consoleURL.Host == "" || consoleURL.User != nil { + return errors.New("provider.console.baseURL 必须是无凭据的 HTTPS URL") + } + if c.Provider.Console.ChatTimeout.Value() < 5*time.Second || c.Provider.Console.ChatTimeout.Value() > 30*time.Minute { + return errors.New("provider.console.chatTimeout 必须在 5 秒到 30 分钟之间") + } + if c.Batch.ImportConcurrency < 1 || c.Batch.ImportConcurrency > 50 || + c.Batch.ConversionConcurrency < 1 || c.Batch.ConversionConcurrency > 50 || + c.Batch.SyncConcurrency < 1 || c.Batch.SyncConcurrency > 50 || + c.Batch.RefreshConcurrency < 1 || c.Batch.RefreshConcurrency > 50 { + return errors.New("批量任务并发必须在 1 到 50 之间") + } + if c.Batch.RandomDelay.Value() < 0 || c.Batch.RandomDelay.Value() > 5*time.Second { + return errors.New("批量任务随机延迟必须在 0 到 5 秒之间") + } + if c.Provider.Web.RecoveryBackoffBase.Value() < 5*time.Second || c.Provider.Web.RecoveryBackoffMax.Value() < c.Provider.Web.RecoveryBackoffBase.Value() || c.Provider.Web.RecoveryBackoffMax.Value() > 6*time.Hour { + return errors.New("provider.web 恢复退避配置无效") + } + if c.Routing.StickyTTL.Value() <= 0 || c.Routing.StickyTTL.Value() > maxRoutingTTL || c.Routing.CooldownBase.Value() <= 0 || c.Routing.CooldownMax.Value() < c.Routing.CooldownBase.Value() || c.Routing.CooldownMax.Value() > maxRoutingCooldown || c.Routing.CapacityWait.Value() <= 0 || c.Routing.CapacityWait.Value() > 5*time.Second || c.Routing.MaxAttempts < unlimitedRoutingAttempts || c.Routing.MaxAttempts == 0 || c.Routing.MaxAttempts > maxRoutingAttempts { + return errors.New("routing 配置无效") + } + if c.Routing.SegmentedMinCandidates < 100 || c.Routing.SegmentedMinCandidates > 1000000 || + c.Routing.SegmentedWindowSize < 8 || c.Routing.SegmentedWindowSize > 256 || + c.Routing.SegmentedWindowSize > c.Routing.SegmentedMinCandidates { + return errors.New("routing segmented selector 配置无效") + } + if c.Routing.ReasoningReplayTTL.Value() <= 0 || c.Routing.ReasoningReplayTTL.Value() > 24*time.Hour { + return errors.New("routing.reasoningReplayTTL 必须在 1 纳秒到 24 小时之间") + } + if c.Routing.ReasoningReplayMaxEntries < 100 || c.Routing.ReasoningReplayMaxEntries > 1000000 { + return errors.New("routing.reasoningReplayMaxEntries 必须在 100 到 1000000 之间") + } + if c.Audit.BufferSize < 1 || c.Audit.BufferSize > maxAuditBufferSize || c.Audit.BatchSize < 1 || c.Audit.BatchSize > maxAuditBatchSize || c.Audit.BatchSize > c.Audit.BufferSize || c.Audit.FlushInterval.Value() < minAuditFlushInterval || c.Audit.FlushInterval.Value() > maxAuditFlushInterval { + return errors.New("audit 队列和批量写入配置无效") + } + if c.Audit.CommitDelay.Value() < minAuditCommitDelay || c.Audit.CommitDelay.Value() > maxAuditCommitDelay { + return errors.New("audit.commitDelay 必须在 1ms 到 50ms 之间") + } + if c.Audit.LedgerMode != "observe" && c.Audit.LedgerMode != "enforce" { + return errors.New("audit.ledgerMode 必须是 observe 或 enforce") + } + if c.Audit.LedgerFailureThreshold < 1 || c.Audit.LedgerFailureThreshold > 100 { + return errors.New("audit.ledgerFailureThreshold 必须在 1 到 100 之间") + } + if c.Audit.LedgerUnhealthyGrace.Value() < time.Second || c.Audit.LedgerUnhealthyGrace.Value() > 10*time.Minute { + return errors.New("audit.ledgerUnhealthyGrace 必须在 1 秒到 10 分钟之间") + } + if c.Audit.LedgerQueueHighWatermarkPct < 50 || c.Audit.LedgerQueueHighWatermarkPct > 100 { + return errors.New("audit.ledgerQueueHighWatermarkPercent 必须在 50 到 100 之间") + } + if c.ClientKeyDefaults.RPMLimit < 1 || c.ClientKeyDefaults.RPMLimit > clientkeydomain.MaxRPMLimit || c.ClientKeyDefaults.MaxConcurrent < 1 || c.ClientKeyDefaults.MaxConcurrent > clientkeydomain.MaxConcurrent { + return errors.New("clientKeyDefaults 超出允许范围") + } + if c.Accounts.AutoCleanReauthInterval.Value() < time.Minute || c.Accounts.AutoCleanReauthInterval.Value() > time.Hour { + return errors.New("accounts.autoCleanReauthInterval 必须在 1 分钟到 1 小时之间") + } + if c.Accounts.AutoCleanReauthMinAge.Value() < time.Minute || c.Accounts.AutoCleanReauthMinAge.Value() > 30*24*time.Hour { + return errors.New("accounts.autoCleanReauthMinAge 必须在 1 分钟到 30 天之间") + } + if len(c.Accounts.BuildForbiddenReauthCodes) > 32 { + return errors.New("accounts.buildForbiddenReauthCodes 最多支持 32 个错误码") + } + for _, code := range c.Accounts.BuildForbiddenReauthCodes { + if !buildForbiddenCodePattern.MatchString(strings.TrimSpace(code)) { + return errors.New("accounts.buildForbiddenReauthCodes 包含无效错误码") + } + } + if len(c.Accounts.BuildForbiddenReauthCodes) == 0 { + return errors.New("accounts.buildForbiddenReauthCodes 至少需要一个错误码") + } + return nil +} + +// validateAPIBaseURL 仅允许无凭据、query、fragment 的 HTTP(S) API 根地址。 +// requireHTTPS 为 true 时强制 HTTPS(用于生产默认 XAI 备用地址)。 +func validateAPIBaseURL(name, raw string, requireHTTPS bool) error { + parsed, err := url.ParseRequestURI(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("%s 必须是不含凭据、查询参数和片段的 HTTP(S) URL", name) + } + switch parsed.Scheme { + case "https": + return nil + case "http": + if requireHTTPS { + return fmt.Errorf("%s 必须是 HTTPS URL", name) + } + return nil + default: + return fmt.Errorf("%s 必须是不含凭据、查询参数和片段的 HTTP(S) URL", name) + } +} + +// NormalizeBuildFallbackBaseURL 在旧配置缺字段时填入默认 XAI 备用地址。 +func NormalizeBuildFallbackBaseURL(value string) string { + if strings.TrimSpace(value) == "" { + return DefaultBuildFallbackBaseURL + } + return strings.TrimSpace(value) +} + +func defaultConfig() Config { + return Config{ + Server: ServerConfig{ + Listen: "127.0.0.1:8000", + MaxBodyBytes: 32 << 20, + MaxConcurrentRequests: 1024, + ReadTimeout: Duration(15 * time.Minute), + RequestTimeout: Duration(2 * time.Hour), + }, + Frontend: FrontendConfig{PublicAPIBaseURL: DefaultPublicAPIBaseURL, StaticPath: "./frontend/dist"}, + Database: DatabaseConfig{ + Driver: "sqlite", + SQLite: SQLiteDatabaseConfig{Path: "./data/backend.db"}, + Postgres: PostgresDatabaseConfig{MaxOpenConns: 50, MaxIdleConns: 10}, + }, + RuntimeStore: RuntimeStoreConfig{ + Driver: "memory", + Redis: RedisRuntimeConfig{Address: "127.0.0.1:6379", KeyPrefix: "grok2api:"}, + }, + Deployment: DeploymentConfig{Replicas: 1, ClusterID: "grok2api"}, + Auth: AuthConfig{ + AccessTokenTTL: Duration(15 * time.Minute), + RefreshTokenTTL: Duration(30 * 24 * time.Hour), + }, + Provider: ProviderConfig{ + Build: BuildProviderConfig{ + BaseURL: "https://cli-chat-proxy.grok.com/v1", FallbackBaseURL: DefaultBuildFallbackBaseURL, + ClientVersion: RecommendedBuildClientVersion, ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", + UserAgent: RecommendedBuildUserAgent, ResponseHeaderTimeout: Duration(settingsdomain.DefaultBuildResponseHeaderTimeout), + }, + Web: WebProviderConfig{ + BaseURL: "https://grok.com", StatsigMode: StatsigModeURL, StatsigSignerURL: DefaultStatsigSignerURL, + ClearanceMode: ClearanceModeManual, FlareSolverrURL: DefaultFlareSolverrURL, + ClearanceTimeout: Duration(time.Minute), ClearanceRefresh: Duration(10 * time.Minute), + QuotaTimeout: Duration(25 * time.Second), + ChatTimeout: Duration(2 * time.Minute), ImageTimeout: Duration(3 * time.Minute), + VideoTimeout: Duration(15 * time.Minute), + MediaConcurrency: 4, RecoveryBackoffBase: Duration(30 * time.Second), + RecoveryBackoffMax: Duration(30 * time.Minute), + }, + Console: ConsoleProviderConfig{BaseURL: "https://console.x.ai", ChatTimeout: Duration(5 * time.Minute)}, + }, + Batch: BatchConfig{ + ImportConcurrency: 25, ConversionConcurrency: 25, SyncConcurrency: 25, + RefreshConcurrency: 25, RandomDelay: Duration(500 * time.Millisecond), + }, + Media: MediaConfig{ + Driver: "local", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, + CleanupThresholdPercent: 80, CleanupInterval: Duration(10 * time.Minute), + Local: LocalMediaConfig{Path: "./data/media"}, + }, + Routing: RoutingConfig{ + StickyTTL: Duration(time.Hour), + CooldownBase: Duration(30 * time.Second), + CooldownMax: Duration(30 * time.Minute), + CapacityWait: Duration(500 * time.Millisecond), + MaxAttempts: 3, + PreferFreeBuild: false, + SegmentedSelectorEnabled: false, + SegmentedMinCandidates: 3000, + SegmentedWindowSize: 64, + ReasoningReplayEnabled: true, + ReasoningReplayTTL: Duration(time.Hour), + ReasoningReplayMaxEntries: 10240, + }, + Audit: AuditConfig{ + BufferSize: 16384, BatchSize: 256, FlushInterval: Duration(250 * time.Millisecond), CommitDelay: Duration(5 * time.Millisecond), + LedgerMode: "enforce", LedgerFailureThreshold: 1, + LedgerUnhealthyGrace: Duration(10 * time.Second), LedgerQueueHighWatermarkPct: 90, + }, + ClientKeyDefaults: ClientKeyDefaultsConfig{RPMLimit: clientkeydomain.DefaultRPMLimit, MaxConcurrent: clientkeydomain.DefaultMaxConcurrent}, + Accounts: AccountsConfig{ + MarkBuildForbiddenReauth: false, + BuildForbiddenReauthCodes: []string{"permission-denied"}, + AutoCleanReauthEnabled: false, + AutoCleanReauthInterval: Duration(10 * time.Minute), + AutoCleanReauthMinAge: Duration(time.Hour), + AutoCleanIncludeDisabled: false, + }, + } +} + +func validateFlareSolverrURL(value string) error { + if err := signerurl.Validate(value); err != nil { + return errors.New(strings.ReplaceAll(err.Error(), "签名 URL", "URL")) + } + return nil +} + +func validStatsigID(value string) bool { + value = strings.TrimSpace(value) + decoded, err := base64.RawStdEncoding.DecodeString(value) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(value) + } + return err == nil && len(decoded) == 70 +} + +func validCredentialEncryptionKey(value string) bool { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value)) + return err == nil && len(decoded) == 32 +} + +func isExampleSecret(value string) bool { + switch strings.TrimSpace(value) { + case "replace-with-at-least-32-characters", "replace-with-base64-key", "replace-with-a-strong-password": + return true + default: + return false + } +} diff --git a/backend/internal/infra/config/config_test.go b/backend/internal/infra/config/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..99fe16fb34f470170b987e11d72906e0e4693256 --- /dev/null +++ b/backend/internal/infra/config/config_test.go @@ -0,0 +1,420 @@ +package config + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + "time" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" +) + +func TestLoadDurationAndSecretsFromYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + data := []byte(`server: + requestTimeout: 2m +secrets: + jwtSecret: "12345678901234567890123456789012" + credentialEncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +bootstrapAdmin: + username: "admin" + password: "password123" +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Server.RequestTimeout.Value() != 2*time.Minute { + t.Fatalf("requestTimeout = %s", cfg.Server.RequestTimeout.Value()) + } + if cfg.Server.ReadTimeout.Value() != 15*time.Minute { + t.Fatalf("readTimeout = %s", cfg.Server.ReadTimeout.Value()) + } + if cfg.Secrets.JWTSecret != "12345678901234567890123456789012" { + t.Fatalf("jwtSecret = %q", cfg.Secrets.JWTSecret) + } + if cfg.Secrets.CredentialEncryptionKey != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" { + t.Fatalf("credentialEncryptionKey = %q", cfg.Secrets.CredentialEncryptionKey) + } + if cfg.BootstrapAdmin.Username != "admin" || cfg.BootstrapAdmin.Password != "password123" { + t.Fatalf("bootstrapAdmin = %#v", cfg.BootstrapAdmin) + } + if cfg.Batch.ImportConcurrency != 25 || cfg.Batch.ConversionConcurrency != 25 || cfg.Batch.SyncConcurrency != 25 || cfg.Batch.RefreshConcurrency != 25 || cfg.Batch.RandomDelay.Value() != 500*time.Millisecond { + t.Fatalf("batch defaults = %#v", cfg.Batch) + } + if cfg.Routing.PreferFreeBuild { + t.Fatal("preferFreeBuild should retain its false default when omitted from YAML") + } + if cfg.Routing.SegmentedSelectorEnabled || cfg.Routing.SegmentedMinCandidates != 3000 || cfg.Routing.SegmentedWindowSize != 64 { + t.Fatalf("segmented selector defaults = %#v", cfg.Routing) + } + if cfg.Accounts.AutoCleanReauthEnabled || cfg.Accounts.AutoCleanIncludeDisabled { + t.Fatal("accounts auto-clean flags should default to false") + } + if cfg.Accounts.MarkBuildForbiddenReauth || len(cfg.Accounts.BuildForbiddenReauthCodes) != 1 || cfg.Accounts.BuildForbiddenReauthCodes[0] != "permission-denied" { + t.Fatalf("Build forbidden-account defaults = %#v", cfg.Accounts) + } + if cfg.Accounts.AutoCleanReauthInterval.Value() != 10*time.Minute || cfg.Accounts.AutoCleanReauthMinAge.Value() != time.Hour { + t.Fatalf("accounts auto-clean defaults = %#v", cfg.Accounts) + } + if !cfg.Routing.ReasoningReplayEnabled || cfg.Routing.ReasoningReplayTTL.Value() != time.Hour || cfg.Routing.ReasoningReplayMaxEntries != 10240 { + t.Fatalf("reasoning replay defaults = %#v", cfg.Routing) + } + if cfg.Audit.CommitDelay.Value() != 5*time.Millisecond { + t.Fatalf("audit commit delay = %s", cfg.Audit.CommitDelay.Value()) + } + if cfg.Audit.LedgerMode != "enforce" || cfg.Audit.LedgerFailureThreshold != 1 { + t.Fatalf("audit ledger defaults = %#v", cfg.Audit) + } + if cfg.Provider.Build.ResponseHeaderTimeout.Value() != 5*time.Minute { + t.Fatalf("Build response header timeout = %s", cfg.Provider.Build.ResponseHeaderTimeout.Value()) + } + expectedDatabasePath := filepath.Join(dir, "data", "backend.db") + if cfg.Database.SQLite.Path != expectedDatabasePath { + t.Fatalf("database path = %q, want %q", cfg.Database.SQLite.Path, expectedDatabasePath) + } + expectedMediaPath := filepath.Join(dir, "data", "media") + if cfg.Media.Local.Path != expectedMediaPath { + t.Fatalf("media path = %q, want %q", cfg.Media.Local.Path, expectedMediaPath) + } + expectedFrontendPath := filepath.Join(dir, "frontend", "dist") + if cfg.Frontend.StaticPath != expectedFrontendPath { + t.Fatalf("frontend static path = %q, want %q", cfg.Frontend.StaticPath, expectedFrontendPath) + } +} + +func TestBuildResponseHeaderTimeoutIsRuntimeOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("provider:\n build:\n responseHeaderTimeout: 10m\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("runtime-only Build response header timeout was accepted from YAML") + } +} + +func TestDefaultGrokBuildClientVersionMatchesLocalBaseline(t *testing.T) { + build := defaultConfig().Provider.Build + if RecommendedBuildClientVersion != "0.2.111" { + t.Fatalf("recommended clientVersion = %q", RecommendedBuildClientVersion) + } + if build.ClientVersion != RecommendedBuildClientVersion { + t.Fatalf("clientVersion = %q", build.ClientVersion) + } + if RecommendedBuildUserAgent != "grok-shell/0.2.111 (linux; x86_64)" { + t.Fatalf("recommended userAgent = %q", RecommendedBuildUserAgent) + } + if build.UserAgent != RecommendedBuildUserAgent { + t.Fatalf("userAgent = %q", build.UserAgent) + } +} + +func TestDefaultConsoleProviderConfig(t *testing.T) { + console := defaultConfig().Provider.Console + if console.BaseURL != "https://console.x.ai" || console.LegacyUserAgent != "" || console.ChatTimeout.Value() != 5*time.Minute { + t.Fatalf("console defaults = %#v", console) + } +} + +func TestLoadAcceptsLegacyConsoleUserAgent(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + data := []byte(`provider: + console: + userAgent: "legacy-console-agent" +secrets: + jwtSecret: "12345678901234567890123456789012" + credentialEncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Provider.Console.LegacyUserAgent != "legacy-console-agent" { + t.Fatalf("legacy userAgent = %q", cfg.Provider.Console.LegacyUserAgent) + } +} + +func TestLoadAcceptsRuntimeDefaultsAndRejectsUnknownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + data := []byte(`secrets: + jwtSecret: "12345678901234567890123456789012" + credentialEncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +routing: + maxAttempts: 9 + preferFreeBuild: true +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil || cfg.Routing.MaxAttempts != 9 || !cfg.Routing.PreferFreeBuild { + t.Fatalf("runtime defaults = %#v, err = %v", cfg.Routing, err) + } + data = append(data, []byte("unknownField: true\n")...) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("unknown field was accepted") + } +} + +func TestValidateMaxAttemptsRange(t *testing.T) { + tests := []struct { + name string + value int + wantErr bool + }{ + {name: "unlimited", value: -1}, + {name: "minimum", value: 1}, + {name: "above former cap", value: 11}, + {name: "maximum", value: 200}, + {name: "zero", value: 0, wantErr: true}, + {name: "below unlimited", value: -2, wantErr: true}, + {name: "above maximum", value: 201, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := defaultConfig() + cfg.Secrets.JWTSecret = "12345678901234567890123456789012" + cfg.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + cfg.Routing.MaxAttempts = test.value + err := cfg.Validate() + if (err != nil) != test.wantErr { + t.Fatalf("Validate() error = %v, wantErr = %v", err, test.wantErr) + } + }) + } +} + +func TestValidateRejectsInvalidSegmentedSelectorConfig(t *testing.T) { + tests := []func(*RoutingConfig){ + func(value *RoutingConfig) { value.SegmentedMinCandidates = 99 }, + func(value *RoutingConfig) { value.SegmentedWindowSize = 257 }, + func(value *RoutingConfig) { value.SegmentedWindowSize = value.SegmentedMinCandidates + 1 }, + } + for index, mutate := range tests { + cfg := defaultConfig() + mutate(&cfg.Routing) + if err := cfg.Validate(); err == nil { + t.Fatalf("case %d accepted invalid segmented selector config", index) + } + } +} + +func TestLoadRejectsMediaRuntimeSettingsInYAML(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + data := []byte(`secrets: + jwtSecret: "12345678901234567890123456789012" + credentialEncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +media: + driver: local + maxTotalBytes: 1073741824 + local: + path: "./data/media" +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("hot-reloadable media setting was accepted from YAML") + } +} + +func TestDurationStringUsesCompactYAMLForm(t *testing.T) { + tests := map[time.Duration]string{ + 250 * time.Millisecond: "250ms", + 30 * time.Second: "30s", + 30 * time.Minute: "30m", + time.Hour: "1h", + 90 * time.Minute: "1h30m", + } + for value, expected := range tests { + if actual := (Duration(value)).String(); actual != expected { + t.Fatalf("Duration(%s).String() = %q, want %q", value, actual, expected) + } + } +} + +func TestValidateRejectsUnsafeRuntimeLimits(t *testing.T) { + tests := map[string]func(*Config){ + "request body": func(cfg *Config) { cfg.Server.MaxBodyBytes = maxServerBodyBytes + 1 }, + "audit buffer": func(cfg *Config) { cfg.Audit.BufferSize = maxAuditBufferSize + 1 }, + "audit commit delay": func(cfg *Config) { + cfg.Audit.CommitDelay = Duration(maxAuditCommitDelay + time.Millisecond) + }, + "client rpm": func(cfg *Config) { cfg.ClientKeyDefaults.RPMLimit = clientkeydomain.MaxRPMLimit + 1 }, + "image size": func(cfg *Config) { cfg.Media.MaxImageBytes = 33 << 20 }, + "media total": func(cfg *Config) { cfg.Media.MaxTotalBytes = 1 }, + "batch limit": func(cfg *Config) { cfg.Batch.SyncConcurrency = 51 }, + "batch jitter": func(cfg *Config) { cfg.Batch.RandomDelay = Duration(6 * time.Second) }, + "console url": func(cfg *Config) { cfg.Provider.Console.BaseURL = "http://console.x.ai" }, + "console timeout": func(cfg *Config) { + cfg.Provider.Console.ChatTimeout = Duration(time.Second) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + cfg := defaultConfig() + cfg.Secrets.JWTSecret = "12345678901234567890123456789012" + cfg.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + mutate(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatal("unsafe configuration was accepted") + } + }) + } +} + +func TestValidateRejectsExampleSecrets(t *testing.T) { + base := defaultConfig() + base.Secrets.JWTSecret = "12345678901234567890123456789012" + base.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + tests := map[string]func(*Config){ + "example jwt": func(cfg *Config) { cfg.Secrets.JWTSecret = "replace-with-at-least-32-characters" }, + "invalid encryption key": func(cfg *Config) { cfg.Secrets.CredentialEncryptionKey = "not-a-32-byte-base64-key" }, + "example admin password": func(cfg *Config) { cfg.BootstrapAdmin.Password = "replace-with-a-strong-password" }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + cfg := base + mutate(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatal("unsafe configuration was accepted") + } + }) + } +} + +func TestValidateStatsigModes(t *testing.T) { + base := defaultConfig() + base.Secrets.JWTSecret = "12345678901234567890123456789012" + base.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + manual := base + manual.Provider.Web.StatsigMode = StatsigModeManual + manual.Provider.Web.StatsigManualValue = base64.RawStdEncoding.EncodeToString(make([]byte, 70)) + if err := manual.Validate(); err != nil { + t.Fatalf("valid manual Statsig rejected: %v", err) + } + manual.Provider.Web.StatsigManualValue = "invalid" + if err := manual.Validate(); err == nil { + t.Fatal("invalid manual Statsig was accepted") + } + + remote := base + remote.Provider.Web.StatsigMode = StatsigModeURL + remote.Provider.Web.StatsigSignerURL = "http://grok-signer-go:8788/sign" + if err := remote.Validate(); err != nil { + t.Fatalf("Docker internal Statsig signer rejected: %v", err) + } + remote.Provider.Web.StatsigSignerURL = "http://signer.example.com:8788/sign" + if err := remote.Validate(); err == nil { + t.Fatal("public plaintext Statsig signer URL was accepted") + } +} + +func TestValidateFlareSolverrClearance(t *testing.T) { + base := defaultConfig() + base.Secrets.JWTSecret = "12345678901234567890123456789012" + base.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + base.Provider.Web.ClearanceMode = ClearanceModeFlareSolverr + base.Provider.Web.FlareSolverrURL = "http://flaresolverr:8191" + if err := base.Validate(); err != nil { + t.Fatalf("valid FlareSolverr config rejected: %v", err) + } + base.Provider.Web.FlareSolverrURL = "ftp://solver.example" + if err := base.Validate(); err == nil { + t.Fatal("invalid FlareSolverr scheme was accepted") + } + base.Provider.Web.FlareSolverrURL = "http://solver.example:8191" + if err := base.Validate(); err == nil { + t.Fatal("public plaintext FlareSolverr URL was accepted") + } + base.Provider.Web.FlareSolverrURL = "https://solver.example/v1" + if err := base.Validate(); err != nil { + t.Fatalf("public HTTPS FlareSolverr URL rejected: %v", err) + } +} + +func TestValidateInfrastructureDrivers(t *testing.T) { + base := defaultConfig() + base.Secrets.JWTSecret = "12345678901234567890123456789012" + base.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + postgresRedis := base + postgresRedis.Database.Driver = "postgres" + postgresRedis.Database.Postgres.DSN = "postgres://user:password@127.0.0.1:5432/grok2api" + postgresRedis.RuntimeStore.Driver = "redis" + if err := postgresRedis.Validate(); err != nil { + t.Fatalf("valid postgres + redis configuration rejected: %v", err) + } + postgresRedis.Deployment = DeploymentConfig{Replicas: 2, InstanceID: "replica-a", ClusterID: "cluster-a", SharedMedia: true} + if err := postgresRedis.Validate(); err != nil { + t.Fatalf("valid multi-replica configuration rejected: %v", err) + } + + invalidMultiReplica := base + invalidMultiReplica.Deployment.Replicas = 2 + if err := invalidMultiReplica.Validate(); err == nil { + t.Fatal("multi-replica SQLite and memory configuration was accepted") + } + + invalidDatabase := base + invalidDatabase.Database.Driver = "mysql" + if err := invalidDatabase.Validate(); err == nil { + t.Fatal("unsupported database driver was accepted") + } + + invalidRuntime := base + invalidRuntime.RuntimeStore.Driver = "fallback" + if err := invalidRuntime.Validate(); err == nil { + t.Fatal("unsupported runtime store driver was accepted") + } +} + +func TestValidateFrontendPublicAPIBaseURL(t *testing.T) { + cfg := defaultConfig() + cfg.Secrets.JWTSecret = "12345678901234567890123456789012" + cfg.Secrets.CredentialEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + for _, value := range []string{"127.0.0.1:8000", "ftp://example.com", "https://user@example.com", "https://example.com?token=value"} { + cfg.Frontend.PublicAPIBaseURL = value + if err := cfg.Validate(); err == nil { + t.Fatalf("frontend.publicApiBaseURL %q was accepted", value) + } + } + cfg.Frontend.PublicAPIBaseURL = "https://api.example.com/grok2api" + cfg.Auth.SecureCookies = false + if err := cfg.Validate(); err != nil { + t.Fatalf("valid frontend.publicApiBaseURL rejected: %v", err) + } +} + +func TestEffectivePublicAPIBaseURLPriority(t *testing.T) { + cases := []struct { + name string + frontend FrontendConfig + want string + }{ + {name: "runtime override", frontend: FrontendConfig{PublicAPIBaseURL: "https://yaml.example/base", PublicAPIBaseURLOverride: "https://runtime.example/api/"}, want: "https://runtime.example/api"}, + {name: "yaml fallback", frontend: FrontendConfig{PublicAPIBaseURL: "https://yaml.example/base/"}, want: "https://yaml.example/base"}, + {name: "local fallback", frontend: FrontendConfig{}, want: DefaultPublicAPIBaseURL}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.frontend.EffectivePublicAPIBaseURL(); got != tc.want { + t.Fatalf("EffectivePublicAPIBaseURL() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/backend/internal/infra/egress/buildclient.go b/backend/internal/infra/egress/buildclient.go new file mode 100644 index 0000000000000000000000000000000000000000..22a8010e75f3f1b9668e561e2bab76619a300a29 --- /dev/null +++ b/backend/internal/infra/egress/buildclient.go @@ -0,0 +1,86 @@ +package egress + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + _ "github.com/bdandy/go-socks4" + xproxy "golang.org/x/net/proxy" +) + +// newBuildClient keeps Grok Build on the standard Go HTTP/TLS stack used by +// the official CLI-facing transport. Browser TLS impersonation is reserved for +// Grok Web, where the browser fingerprint and User-Agent belong together. +func newBuildClient(proxyURL string, responseHeaderTimeout time.Duration) (*http.Client, error) { + direct := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + transport := &http.Transport{ + Proxy: nil, + DialContext: direct.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 256, + MaxIdleConnsPerHost: 128, + MaxConnsPerHost: 256, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: responseHeaderTimeout, + ExpectContinueTimeout: time.Second, + } + if strings.TrimSpace(proxyURL) != "" { + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("解析 Grok Build 出口代理: %w", err) + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + transport.Proxy = http.ProxyURL(parsed) + case "socks4", "socks4a", "socks5", "socks5h": + dialer, err := xproxy.FromURL(parsed, direct) + if err != nil { + return nil, fmt.Errorf("创建 Grok Build SOCKS 代理: %w", err) + } + transport.DialContext = dialContext(dialer) + default: + return nil, fmt.Errorf("Grok Build 不支持代理协议 %q", parsed.Scheme) + } + } + return &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, nil +} + +func dialContext(dialer xproxy.Dialer) func(context.Context, string, string) (net.Conn, error) { + if contextual, ok := dialer.(xproxy.ContextDialer); ok { + return contextual.DialContext + } + type result struct { + connection net.Conn + err error + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + completed := make(chan result, 1) + go func() { + connection, err := dialer.Dial(network, address) + completed <- result{connection: connection, err: err} + }() + select { + case value := <-completed: + return value.connection, value.err + case <-ctx.Done(): + go func() { + value := <-completed + if value.connection != nil { + _ = value.connection.Close() + } + }() + return nil, ctx.Err() + } + } +} diff --git a/backend/internal/infra/egress/buildclient_test.go b/backend/internal/infra/egress/buildclient_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5bf7260b5f15185e8f5d9baaead189e693186b26 --- /dev/null +++ b/backend/internal/infra/egress/buildclient_test.go @@ -0,0 +1,256 @@ +package egress + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + neterrorpkg "github.com/chenyme/grok2api/backend/internal/pkg/neterror" +) + +func TestBuildClientUsesConfiguredResponseHeaderTimeout(t *testing.T) { + client, err := newBuildClient("", 7*time.Minute) + if err != nil { + t.Fatal(err) + } + transport := client.Transport.(*http.Transport) + if transport.ResponseHeaderTimeout != 7*time.Minute { + t.Fatalf("response header timeout = %s", transport.ResponseHeaderTimeout) + } +} + +func TestBuildClientResponseHeaderTimeoutDoesNotLimitResponseBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + time.Sleep(200 * time.Millisecond) + _, _ = io.WriteString(writer, "ok") + })) + defer server.Close() + client, err := newBuildClient("", 100*time.Millisecond) + if err != nil { + t.Fatal(err) + } + response, err := client.Get(server.URL) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil || string(body) != "ok" { + t.Fatalf("body=%q err=%v", body, err) + } +} + +func TestBuildClientClassifiesDelayedResponseHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + time.Sleep(200 * time.Millisecond) + writer.WriteHeader(http.StatusOK) + })) + defer server.Close() + client, err := newBuildClient("", 50*time.Millisecond) + if err != nil { + t.Fatal(err) + } + _, err = client.Get(server.URL) + if !neterrorpkg.IsResponseHeaderTimeout(err) { + t.Fatalf("error = %v", err) + } +} + +func TestNewBuildClientUsesStandardTransportForEveryProxyFamily(t *testing.T) { + tests := []struct { + name string + proxyURL string + httpProxy bool + }{ + {name: "direct"}, + {name: "http", proxyURL: "http://user:password@127.0.0.1:8080", httpProxy: true}, + {name: "https", proxyURL: "https://proxy.example:8443", httpProxy: true}, + {name: "socks4", proxyURL: "socks4://127.0.0.1:1080"}, + {name: "socks4a", proxyURL: "socks4a://proxy.example:1080"}, + {name: "socks5", proxyURL: "socks5://127.0.0.1:1080"}, + {name: "socks5h warp", proxyURL: "socks5h://warp:1080"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, err := newBuildClient(test.proxyURL, 5*time.Minute) + if err != nil { + t.Fatal(err) + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport = %T, want *http.Transport", client.Transport) + } + if transport.ForceAttemptHTTP2 != true || transport.DialContext == nil { + t.Fatalf("standard transport not fully configured: %#v", transport) + } + if (transport.Proxy != nil) != test.httpProxy { + t.Fatalf("http proxy callback configured=%v, want %v", transport.Proxy != nil, test.httpProxy) + } + }) + } +} + +func TestNewBuildClientRejectsUnsupportedProxyScheme(t *testing.T) { + if _, err := newBuildClient("ftp://proxy.example:21", 5*time.Minute); err == nil { + t.Fatal("unsupported proxy scheme was accepted") + } +} + +func TestBuildClientRoutesThroughSOCKS5HWithRemoteDNS(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("User-Agent") != "grok-shell/0.2.102 (linux; x86_64)" { + t.Errorf("User-Agent = %q", request.Header.Get("User-Agent")) + } + writer.Header().Set("Connection", "close") + _, _ = io.WriteString(writer, "ok") + })) + defer target.Close() + upstreamAddress := strings.TrimPrefix(target.URL, "http://") + _, upstreamPort, err := net.SplitHostPort(upstreamAddress) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + requestedHost := make(chan string, 1) + proxyDone := make(chan error, 1) + go func() { proxyDone <- serveSOCKS5TunnelOnce(listener, upstreamAddress, requestedHost) }() + + client, err := newBuildClient("socks5h://"+listener.Addr().String(), 5*time.Minute) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, "http://cli-chat-proxy.grok.test:"+upstreamPort+"/v1/models", nil) + if err != nil { + t.Fatal(err) + } + request.Close = true + request.Header.Set("User-Agent", "grok-shell/0.2.102 (linux; x86_64)") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil || string(body) != "ok" { + t.Fatalf("body=%q err=%v", body, err) + } + select { + case host := <-requestedHost: + if host != "cli-chat-proxy.grok.test" { + t.Fatalf("SOCKS target host = %q", host) + } + case <-time.After(2 * time.Second): + t.Fatal("SOCKS proxy did not receive a target hostname") + } + select { + case err := <-proxyDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("SOCKS proxy did not finish") + } +} + +func serveSOCKS5TunnelOnce(listener net.Listener, upstreamAddress string, requestedHost chan<- string) error { + connection, err := listener.Accept() + if err != nil { + return err + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + var greeting [2]byte + if _, err := io.ReadFull(connection, greeting[:]); err != nil { + return err + } + if greeting[0] != 5 { + return fmt.Errorf("SOCKS version = %d", greeting[0]) + } + methods := make([]byte, int(greeting[1])) + if _, err := io.ReadFull(connection, methods); err != nil { + return err + } + if _, err := connection.Write([]byte{5, 0}); err != nil { + return err + } + var header [4]byte + if _, err := io.ReadFull(connection, header[:]); err != nil { + return err + } + if header[0] != 5 || header[1] != 1 { + return fmt.Errorf("SOCKS request header = %v", header) + } + host, err := readSOCKS5Host(connection, header[3]) + if err != nil { + return err + } + var port [2]byte + if _, err := io.ReadFull(connection, port[:]); err != nil { + return err + } + _ = binary.BigEndian.Uint16(port[:]) + requestedHost <- host + + upstream, err := net.DialTimeout("tcp", upstreamAddress, 2*time.Second) + if err != nil { + return err + } + defer upstream.Close() + _ = upstream.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}); err != nil { + return err + } + copyDone := make(chan error, 1) + go func() { + _, copyErr := io.Copy(upstream, connection) + copyDone <- copyErr + }() + _, downstreamErr := io.Copy(connection, upstream) + upstreamErr := <-copyDone + if downstreamErr != nil && !isClosedNetworkError(downstreamErr) { + return downstreamErr + } + if upstreamErr != nil && !isClosedNetworkError(upstreamErr) { + return upstreamErr + } + return nil +} + +func readSOCKS5Host(reader io.Reader, addressType byte) (string, error) { + switch addressType { + case 1: + value := make([]byte, net.IPv4len) + _, err := io.ReadFull(reader, value) + return net.IP(value).String(), err + case 3: + var size [1]byte + if _, err := io.ReadFull(reader, size[:]); err != nil { + return "", err + } + value := make([]byte, int(size[0])) + _, err := io.ReadFull(reader, value) + return string(value), err + case 4: + value := make([]byte, net.IPv6len) + _, err := io.ReadFull(reader, value) + return net.IP(value).String(), err + default: + return "", fmt.Errorf("SOCKS address type = %d", addressType) + } +} + +func isClosedNetworkError(err error) bool { + return err == nil || strings.Contains(strings.ToLower(err.Error()), "closed network connection") +} diff --git a/backend/internal/infra/egress/flaresolverr.go b/backend/internal/infra/egress/flaresolverr.go new file mode 100644 index 0000000000000000000000000000000000000000..a76ec42bddb684a7cde66f5e66376fd8a05d6d5f --- /dev/null +++ b/backend/internal/infra/egress/flaresolverr.go @@ -0,0 +1,171 @@ +package egress + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + application "github.com/chenyme/grok2api/backend/internal/application/egress" +) + +const maxFlareSolverrResponseBytes = 2 << 20 + +var ( + proxyCredentialPattern = regexp.MustCompile(`(?i)\b(https?|socks4a?|socks5h?)://[^\s/@:]+:[^\s/@]+@`) + bearerCredentialPattern = regexp.MustCompile(`(?i)\bbearer\s+[a-z0-9._~+/=-]+`) + namedCredentialPattern = regexp.MustCompile(`(?i)\b(token|password|passwd|authorization|cookie)\s*[:=]\s*[^\s,;]+`) +) + +type ClearanceConfig struct { + Mode string + FlareSolverrURL string + TargetURL string + Timeout time.Duration + RefreshInterval time.Duration +} + +type clearanceSolution struct { + Cookies string + UserAgent string +} + +type clearanceSolver interface { + Solve(context.Context, ClearanceConfig, string) (clearanceSolution, error) +} + +type flaresolverrSolver struct{} + +func (flaresolverrSolver) Solve(ctx context.Context, cfg ClearanceConfig, proxyURL string) (clearanceSolution, error) { + endpoint, err := flaresolverrEndpoint(cfg.FlareSolverrURL) + if err != nil { + return clearanceSolution{}, err + } + target := strings.TrimSpace(cfg.TargetURL) + if target == "" { + target = "https://grok.com" + } + payload := map[string]any{ + "cmd": "request.get", + "url": target, + "maxTimeout": cfg.Timeout.Milliseconds(), + } + if proxyURL != "" { + payload["proxy"] = map[string]string{"url": proxyURL} + } + body, err := json.Marshal(payload) + if err != nil { + return clearanceSolution{}, fmt.Errorf("编码 FlareSolverr 请求: %w", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return clearanceSolution{}, fmt.Errorf("创建 FlareSolverr 请求: %w", err) + } + request.Header.Set("Content-Type", "application/json") + client := &http.Client{ + Timeout: cfg.Timeout + 15*time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return errors.New("FlareSolverr 响应不允许重定向") + }, + } + response, err := client.Do(request) + if err != nil { + return clearanceSolution{}, fmt.Errorf("调用 FlareSolverr: %w", err) + } + defer response.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxFlareSolverrResponseBytes+1)) + if err != nil { + return clearanceSolution{}, fmt.Errorf("读取 FlareSolverr 响应: %w", err) + } + if len(responseBody) > maxFlareSolverrResponseBytes { + return clearanceSolution{}, errors.New("FlareSolverr 响应过大") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return clearanceSolution{}, fmt.Errorf("FlareSolverr 返回 HTTP %d", response.StatusCode) + } + var result struct { + Status string `json:"status"` + Message string `json:"message"` + Solution struct { + UserAgent string `json:"userAgent"` + Cookies []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"cookies"` + } `json:"solution"` + } + if err := json.Unmarshal(responseBody, &result); err != nil { + return clearanceSolution{}, fmt.Errorf("解析 FlareSolverr 响应: %w", err) + } + if result.Status != "ok" { + message := sanitizeFlareSolverrMessage(result.Message) + if message == "" { + message = "unknown error" + } + return clearanceSolution{}, fmt.Errorf("FlareSolverr 求解失败: %s", message) + } + parts := make([]string, 0, len(result.Solution.Cookies)) + for _, cookie := range result.Solution.Cookies { + if strings.TrimSpace(cookie.Name) != "" && strings.TrimSpace(cookie.Value) != "" { + parts = append(parts, cookie.Name+"="+cookie.Value) + } + } + cookies := application.SanitizeCloudflareCookies(strings.Join(parts, "; ")) + userAgent := strings.TrimSpace(result.Solution.UserAgent) + if userAgent == "" || len(userAgent) > 512 || strings.IndexFunc(userAgent, func(character rune) bool { return character < 0x20 || character == 0x7f }) >= 0 { + return clearanceSolution{}, errors.New("FlareSolverr 返回的 User-Agent 无效") + } + return clearanceSolution{Cookies: cookies, UserAgent: userAgent}, nil +} + +func sanitizeFlareSolverrMessage(value string) string { + value = strings.Map(func(character rune) rune { + if character < 0x20 || character == 0x7f { + return -1 + } + return character + }, strings.TrimSpace(value)) + value = proxyCredentialPattern.ReplaceAllStringFunc(value, func(candidate string) string { + separator := strings.Index(candidate, "://") + if separator < 0 { + return "[redacted proxy]" + } + return candidate[:separator+3] + "***:***@" + }) + value = bearerCredentialPattern.ReplaceAllString(value, "Bearer [redacted]") + value = namedCredentialPattern.ReplaceAllString(value, "$1=[redacted]") + characters := []rune(value) + if len(characters) > 300 { + value = string(characters[:300]) + } + return value +} + +func flaresolverrEndpoint(value string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || parsed.Host == "" || parsed.User != nil { + return "", errors.New("FlareSolverr URL 无效") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("FlareSolverr URL 必须使用 HTTP 或 HTTPS") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("FlareSolverr URL 不能包含查询参数或片段") + } + path := strings.TrimSuffix(parsed.EscapedPath(), "/") + if path == "" { + path = "/v1" + } else if path != "/v1" { + path += "/v1" + } + parsed.RawPath = "" + parsed.Path = path + return parsed.String(), nil +} diff --git a/backend/internal/infra/egress/flaresolverr_test.go b/backend/internal/infra/egress/flaresolverr_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e067e9437c97a5a1e1ae771d27b61d4c73913511 --- /dev/null +++ b/backend/internal/infra/egress/flaresolverr_test.go @@ -0,0 +1,86 @@ +package egress + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestFlareSolverrSolveUsesNodeProxyAndFiltersCookies(t *testing.T) { + var requestPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1" || request.Method != http.MethodPost { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + if err := json.NewDecoder(request.Body).Decode(&requestPayload); err != nil { + t.Fatal(err) + } + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{"status":"ok","solution":{"userAgent":"Mozilla/5.0 Chrome/146.0.0.0 Safari/537.36","cookies":[{"name":"cf_clearance","value":"clear"},{"name":"sso","value":"secret"},{"name":"__cf_bm","value":"bm"}]}}`)) + })) + defer server.Close() + + solution, err := (flaresolverrSolver{}).Solve(context.Background(), ClearanceConfig{ + FlareSolverrURL: server.URL, TargetURL: "https://grok.com", Timeout: time.Second, + }, "socks5h://proxy:1080") + if err != nil { + t.Fatal(err) + } + if solution.Cookies != "cf_clearance=clear; __cf_bm=bm" || solution.UserAgent == "" { + t.Fatalf("solution = %#v", solution) + } + if requestPayload["cmd"] != "request.get" || requestPayload["url"] != "https://grok.com" { + t.Fatalf("payload = %#v", requestPayload) + } + proxy, ok := requestPayload["proxy"].(map[string]any) + if !ok || proxy["url"] != "socks5h://proxy:1080" { + t.Fatalf("proxy payload = %#v", requestPayload["proxy"]) + } +} + +func TestFlareSolverrSolveAcceptsNoChallengeWithoutCloudflareCookies(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{"status":"ok","solution":{"userAgent":"Mozilla/5.0 Chrome/146.0.0.0 Safari/537.36","cookies":[{"name":"sso","value":"ignored"}]}}`)) + })) + defer server.Close() + + solution, err := (flaresolverrSolver{}).Solve(context.Background(), ClearanceConfig{ + FlareSolverrURL: server.URL, TargetURL: "https://grok.com", Timeout: time.Second, + }, "") + if err != nil { + t.Fatal(err) + } + if solution.Cookies != "" || solution.UserAgent == "" { + t.Fatalf("solution = %#v", solution) + } +} + +func TestFlareSolverrEndpointAcceptsBaseAndV1Path(t *testing.T) { + for input, expected := range map[string]string{ + "http://flaresolverr:8191": "http://flaresolverr:8191/v1", + "http://flaresolverr:8191/": "http://flaresolverr:8191/v1", + "https://solver.example/api": "https://solver.example/api/v1", + } { + actual, err := flaresolverrEndpoint(input) + if err != nil || actual != expected { + t.Fatalf("endpoint(%q) = %q, %v", input, actual, err) + } + } +} + +func TestSanitizeFlareSolverrMessageRedactsCredentials(t *testing.T) { + message := sanitizeFlareSolverrMessage("proxy socks5h://user:secret@resin:2260 failed; token=abc123 Authorization: Bearer.SECRET cookie=sso-value") + for _, secret := range []string{"user", "secret", "abc123", "Bearer.SECRET", "sso-value"} { + if strings.Contains(message, secret) { + t.Fatalf("sanitized message leaked %q: %q", secret, message) + } + } + if !strings.Contains(message, "socks5h://***:***@") { + t.Fatalf("proxy scheme was not retained safely: %q", message) + } +} diff --git a/backend/internal/infra/egress/manager.go b/backend/internal/infra/egress/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..a0eac22704515c7ee85f6ea95dfab9d912053fc0 --- /dev/null +++ b/backend/internal/infra/egress/manager.go @@ -0,0 +1,2047 @@ +package egress + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptrace" + "net/netip" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + application "github.com/chenyme/grok2api/backend/internal/application/egress" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "github.com/chenyme/grok2api/backend/internal/infra/security" + neterrorpkg "github.com/chenyme/grok2api/backend/internal/pkg/neterror" + "github.com/chenyme/grok2api/backend/internal/repository" + "golang.org/x/sync/singleflight" +) + +const DefaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +const nodeSnapshotTTL = time.Second +const operationsConfigSnapshotTTL = time.Second +const proxyPoolRetryLimit = 2 +const clientCacheIdleTTL = 30 * time.Minute +const clientCacheCleanupInterval = time.Minute +const clientCacheTouchInterval = time.Minute +const maxCachedClients = 4096 +const clearanceLockGrace = 30 * time.Second +const clearanceCacheCleanupInterval = time.Minute +const clearanceCacheMinIdleTTL = 30 * time.Minute +const maxCachedClearances = 16384 +const clearanceCacheEvictionBatch = 256 + +// clientClosedRequestStatus is the conventional proxy status for a client-aborted request. +const clientClosedRequestStatus = 499 +const egressIPv4ProbeEndpoint = "https://ipinfo.io/json" +const egressIPv6ProbeEndpoint = "https://v6.ipinfo.io/json" +const cloudflareIPv4ProbeEndpoint = "https://1.1.1.1/cdn-cgi/trace" +const cloudflareIPv6ProbeEndpoint = "https://[2606:4700:4700::1111]/cdn-cgi/trace" +const egressProbeTimeout = 15 * time.Second +const clientCreationRetryLimit = 3 +const maxClientVersionEntries = 4096 + +var errNodeSnapshotInvalidated = errors.New("egress node snapshot invalidated") +var errClientCacheInvalidated = errors.New("egress client cache invalidated") + +type Lease struct { + NodeID uint64 + NodeName string + Scope domain.Scope + ProxyURL string + UserAgent string + CFCookies string + client requestClient + browser *browserClient + sticky bool + proxyPool bool + freshTunnel bool + clearanceKey string + clearanceManager *Manager + release func() +} + +type requestClient interface { + Do(*http.Request) (*http.Response, error) + CloseIdleConnections() +} + +func (l *Lease) Do(request *http.Request) (*http.Response, error) { + return l.doRequest(request, true) +} + +// DoDeferredForbidden executes an HTTP request while leaving 403 clearance +// invalidation to the caller after it has classified the response body. +func (l *Lease) DoDeferredForbidden(request *http.Request) (*http.Response, error) { + return l.doRequest(request, false) +} + +func (l *Lease) doRequest(request *http.Request, invalidateForbidden bool) (*http.Response, error) { + if l == nil || l.client == nil { + return nil, errors.New("出口客户端未初始化") + } + // Rotating proxy endpoints choose an exit when a new CONNECT tunnel is + // established. Reusing a Build keep-alive/HTTP2 connection would pin many + // otherwise independent requests to one exit and defeat proxy-pool + // rotation. Proxy-pool mode is explicit, so trade the extra handshake for a + // fresh tunnel without changing fixed-proxy, direct, Web, or Console paths. + if l.Scope == domain.ScopeBuild && l.freshTunnel { + request.Close = true + } + response, err := l.do(request) + recordPhysicalCall(request.Context(), response, err) + if invalidateForbidden && err == nil && response != nil && response.StatusCode == http.StatusForbidden { + l.InvalidateClearance() + } + return response, err +} + +// InvalidateClearance invalidates the exact browser-session binding used by +// this lease after a 403 has been classified as egress-related. +func (l *Lease) InvalidateClearance() { + if l != nil && l.clearanceManager != nil && l.clearanceKey != "" { + l.clearanceManager.invalidateClearanceKey(l.clearanceKey, l.client) + } +} + +func (l *Lease) Release() { + if l != nil && l.release != nil { + l.release() + l.release = nil + } +} + +type Manager struct { + repository repository.EgressRepository + cipher *security.Cipher + logger *slog.Logger + nodeMu sync.RWMutex + clientMu sync.RWMutex + clearanceMu sync.Mutex + operationsMu sync.RWMutex + clients map[clientCacheKey]cachedClient + inflight sync.Map + nodes map[domain.Scope]cachedNodeSnapshot + healthyNodes map[uint64]time.Time + nodeVersions map[domain.Scope]uint64 + nodeLoads singleflight.Group + clientLoads singleflight.Group + clientVersions map[uint64]uint64 + clientGeneration uint64 + buildHeaderTimeout atomic.Int64 + operationsConfig cachedOperationsConfig + operationsConfigLoad singleflight.Group + operationsConfigVer uint64 + lastClientCleanup time.Time + clearanceLoads singleflight.Group + clearanceConfig ClearanceConfig + clearanceVersion uint64 + clearances map[string]clearanceState + lastClearanceCleanup time.Time + solver clearanceSolver + clearanceLock repository.DistributedLock + newBuildClient func(string, time.Duration) (requestClient, error) + newBrowserClient func(string, string) (*browserClient, error) +} + +type clearanceState struct { + cookies string + userAgent string + refreshedAt time.Time + invalid bool + used bool + version uint64 + fingerprint string + bindingFingerprint string + lastUsedAt time.Time +} + +type egressStateRepository interface { + UpdateEgressNodeClearance(context.Context, uint64, string, string, string, string, time.Time) error + UpdateEgressNodeHealth(context.Context, uint64, float64, int, *time.Time, string) error + UpdateEgressNodeLastError(context.Context, uint64, string) error +} + +// operationsConfigRepository is optional so lightweight routing repositories +// retain their narrow contract. The relational implementation supplies it, +// allowing fallback policy to be read only when primary selection fails. +type operationsConfigRepository interface { + GetEgressOperationsConfig(context.Context) (domain.OperationsConfig, error) +} + +type cachedClient struct { + client requestClient + browser *browserClient + lastUsed time.Time +} + +type clientCacheKey struct { + nodeID uint64 + scope domain.Scope + fingerprint string +} + +type cachedNodeSnapshot struct { + values []domain.Node + expiresAt time.Time +} + +type cachedOperationsConfig struct { + value domain.OperationsConfig + expiresAt time.Time +} + +func NewManager(repository repository.EgressRepository, cipher *security.Cipher) *Manager { + manager := &Manager{ + repository: repository, cipher: cipher, + clients: make(map[clientCacheKey]cachedClient), + nodes: make(map[domain.Scope]cachedNodeSnapshot), healthyNodes: make(map[uint64]time.Time), + nodeVersions: make(map[domain.Scope]uint64), clientVersions: make(map[uint64]uint64), clearances: make(map[string]clearanceState), + newBuildClient: newBuildRequestClient, newBrowserClient: newBrowserClient, + solver: flaresolverrSolver{}, + clearanceConfig: ClearanceConfig{Mode: "manual", TargetURL: "https://grok.com", Timeout: time.Minute, RefreshInterval: 10 * time.Minute}, + } + manager.buildHeaderTimeout.Store(int64(settingsdomain.DefaultBuildResponseHeaderTimeout)) + return manager +} + +func (m *Manager) SetLogger(logger *slog.Logger) { + if logger == nil { + logger = slog.Default() + } + m.logger = logger +} + +func (m *Manager) log() *slog.Logger { + if m == nil || m.logger == nil { + return slog.Default() + } + return m.logger +} + +// UpdateBuildResponseHeaderTimeout rebuilds only cached Build clients. Active +// requests keep their current transport and are not interrupted. +func (m *Manager) UpdateBuildResponseHeaderTimeout(value time.Duration) { + if value <= 0 { + value = settingsdomain.DefaultBuildResponseHeaderTimeout + } + if previous := time.Duration(m.buildHeaderTimeout.Swap(int64(value))); previous == value { + return + } + m.clientMu.Lock() + var stale []requestClient + for key, cached := range m.clients { + if key.scope == domain.ScopeBuild { + stale = append(stale, m.evictClientLocked(key, cached)) + } + } + m.clientMu.Unlock() + closeRequestClients(stale) +} + +// SetClearanceLock enables cross-instance coordination for shared, fixed egress +// nodes. Account-bound Resin clearances remain process-local because they must +// never be persisted into the node-wide cookie fields. +func (m *Manager) SetClearanceLock(value repository.DistributedLock) { + m.clearanceMu.Lock() + m.clearanceLock = value + m.clearanceMu.Unlock() +} + +func (m *Manager) UpdateClearanceConfig(value ClearanceConfig) { + value.Mode = strings.TrimSpace(value.Mode) + value.FlareSolverrURL = strings.TrimSpace(value.FlareSolverrURL) + value.TargetURL = strings.TrimRight(strings.TrimSpace(value.TargetURL), "/") + m.clearanceMu.Lock() + previous := m.clearanceConfig + m.clearanceConfig = value + configurationChanged := previous.Mode != value.Mode || previous.FlareSolverrURL != value.FlareSolverrURL || previous.TargetURL != value.TargetURL + if configurationChanged { + m.clearanceVersion++ + m.clientMu.Lock() + m.invalidateAllClientVersionsLocked() + m.clientMu.Unlock() + } + m.clearanceMu.Unlock() +} + +func (m *Manager) Acquire(ctx context.Context, scope domain.Scope, affinity string) (*Lease, error) { + lease, _, err := m.acquire(ctx, scope, affinity, true, "", egressNodeFromContext(ctx)) + return lease, err +} + +// AcquireCredential binds the outbound proxy identity to one persisted +// Provider credential. Resin templates use this identity as their Account. +func (m *Manager) AcquireCredential(ctx context.Context, scope domain.Scope, credential accountdomain.Credential) (*Lease, error) { + identity := strings.TrimSpace(credential.EgressIdentity) + if identity == "" { + identity = string(credential.Provider) + "_" + strconv.FormatUint(credential.ID, 10) + } + // Web and Console accounts can be two database projections of the same SSO + // login. Resin must see one stable account identity across both channels; + // otherwise the proxy rotates the IP while the clearance remains bound to + // the other lease. The digest is non-reversible and is only used as a proxy + // template account label. + if strings.TrimSpace(credential.EgressIdentity) == "" && credential.AuthType == accountdomain.AuthTypeSSO && strings.TrimSpace(credential.EncryptedAccessToken) != "" { + token, decryptErr := m.cipher.Decrypt(credential.EncryptedAccessToken) + if decryptErr != nil { + return nil, decryptErr + } + identity = "sso_" + security.HashToken(token)[:32] + } + ctx = WithAccountIdentity(ctx, identity) + ctx = WithEgressNode(ctx, credential.EgressNodeID) + lease, _, err := m.acquire(ctx, scope, strconv.FormatUint(credential.ID, 10), true, credential.EncryptedCloudflareCookie, credential.EgressNodeID) + return lease, err +} + +func (m *Manager) AcquireIfConfigured(ctx context.Context, scope domain.Scope, affinity string) (*Lease, bool, error) { + return m.acquire(ctx, scope, affinity, false, "", egressNodeFromContext(ctx)) +} + +type preparedEgressProbe struct { + nodeID uint64 + nodeName string + nodeScope domain.Scope + proxyURL string +} + +// ProbeEgressNode verifies IPv4 and IPv6 independently through fixed provider +// endpoints. Both requests share one immutable node snapshot so a concurrent +// administrator edit cannot mix results from different proxy configurations. +func (m *Manager) ProbeEgressNode(ctx context.Context, node domain.Node) (domain.ProbeResult, error) { + type outcome struct { + family string + result domain.ProbeFamilyResult + err error + } + startedAt := time.Now().UTC() + var provider domain.ProbeProvider + config, supported, configErr := m.loadOperationsConfig(ctx, time.Now().UTC()) + if configErr != nil { + message := "读取代理探测服务配置失败" + result := failedEgressProbeResult(provider, message, startedAt) + m.logProbeSetupFailure(ctx, node, provider, "load_probe_config", message, configErr, result.LatencyMS) + return result, configErr + } + if supported { + provider = config.ProbeProvider.Normalized() + } else { + provider = domain.ProbeProviderCloudflare + } + target, stage, message, prepareErr := m.prepareEgressProbe(node) + if prepareErr != nil { + result := failedEgressProbeResult(provider, message, startedAt) + m.logProbeSetupFailure(ctx, node, provider, stage, message, prepareErr, result.LatencyMS) + return result, prepareErr + } + ipv4Endpoint, ipv6Endpoint := probeEndpoints(provider) + outcomes := make(chan outcome, 2) + for _, probe := range []struct{ family, endpoint string }{{"ipv4", ipv4Endpoint}, {"ipv6", ipv6Endpoint}} { + go func() { + result, err := m.probeEgressEndpoint(ctx, target, provider, probe.family, probe.endpoint) + outcomes <- outcome{family: probe.family, result: result, err: err} + }() + } + var ipv4Err, ipv6Err error + result := domain.ProbeResult{Status: domain.ProbeStatusUnhealthy, Provider: provider} + for range 2 { + current := <-outcomes + if current.family == "ipv4" { + result.IPv4, ipv4Err = current.result, current.err + } else { + result.IPv6, ipv6Err = current.result, current.err + } + } + result.TestedAt = time.Now().UTC() + result.LatencyMS = max(result.IPv4.LatencyMS, result.IPv6.LatencyMS) + if result.IPv4.Status == domain.ProbeStatusHealthy { + result.Status, result.ExitIP = domain.ProbeStatusHealthy, result.IPv4.ExitIP + } else if result.IPv6.Status == domain.ProbeStatusHealthy { + result.Status, result.ExitIP = domain.ProbeStatusHealthy, result.IPv6.ExitIP + } + if result.Status == domain.ProbeStatusHealthy { + return result, nil + } + errorsByFamily := make([]string, 0, 2) + if result.IPv4.Error != "" { + errorsByFamily = append(errorsByFamily, "IPv4: "+result.IPv4.Error) + } + if result.IPv6.Error != "" { + errorsByFamily = append(errorsByFamily, "IPv6: "+result.IPv6.Error) + } + result.Error = strings.Join(errorsByFamily, "; ") + if result.Error == "" { + result.Error = "IPv4 和 IPv6 代理探测均失败" + } + return result, errors.Join(ipv4Err, ipv6Err, errors.New(result.Error)) +} + +func (m *Manager) prepareEgressProbe(node domain.Node) (preparedEgressProbe, string, string, error) { + target := preparedEgressProbe{nodeID: node.ID, nodeName: node.Name, nodeScope: node.Scope} + if node.ID == 0 { + message := "代理节点 ID 无效" + return target, "validate_node", message, errors.New(message) + } + proxyURL, err := m.cipher.Decrypt(node.EncryptedProxyURL) + if err != nil { + return target, "decrypt_proxy", "读取代理配置失败", err + } + proxyURL, err = application.NormalizeProxyURL(proxyURL) + if err != nil { + return target, "normalize_proxy", "代理地址无效", err + } + if proxyURL == "" { + message := "未配置代理地址" + return target, "normalize_proxy", message, errors.New(message) + } + if strings.Contains(proxyURL, application.ProxyAccountPlaceholder) { + proxyURL, err = renderAccountProxyURL(proxyURL, "egress_probe") + if err != nil { + return target, "render_proxy_identity", "账号代理模板无效", err + } + } + target.proxyURL = proxyURL + return target, "", "", nil +} + +func failedEgressProbeResult(provider domain.ProbeProvider, message string, startedAt time.Time) domain.ProbeResult { + completedAt := time.Now().UTC() + latencyMS := max(1, int(completedAt.Sub(startedAt).Milliseconds())) + family := domain.ProbeFamilyResult{ + Status: domain.ProbeStatusUnhealthy, TestedAt: completedAt, LatencyMS: latencyMS, Error: message, + } + return domain.ProbeResult{ + Status: domain.ProbeStatusUnhealthy, TestedAt: completedAt, LatencyMS: latencyMS, Error: message, + Provider: provider, IPv4: family, IPv6: family, + } +} + +func (m *Manager) logProbeSetupFailure(ctx context.Context, node domain.Node, provider domain.ProbeProvider, stage, message string, err error, durationMS int) { + attributes := []any{ + "node_id", node.ID, "node_name", node.Name, "scope", node.Scope, + "probe_provider", provider, "address_family", "all", "endpoint", "", + "stage", stage, "status_code", 0, "duration_ms", durationMS, + "connect_ms", 0, "tls_ms", 0, "first_byte_ms", 0, + "probe_error", message, + } + if err != nil { + attributes = append(attributes, "error", sanitizeFlareSolverrMessage(err.Error())) + } + m.log().WarnContext(ctx, "egress_probe_failed", attributes...) +} + +func probeEndpoints(provider domain.ProbeProvider) (string, string) { + if provider.Normalized() == domain.ProbeProviderCloudflare { + return cloudflareIPv4ProbeEndpoint, cloudflareIPv6ProbeEndpoint + } + return egressIPv4ProbeEndpoint, egressIPv6ProbeEndpoint +} + +func (m *Manager) probeEgressEndpoint(ctx context.Context, target preparedEgressProbe, provider domain.ProbeProvider, family, targetURL string) (result domain.ProbeFamilyResult, probeErr error) { + startedAt := time.Now().UTC() + result = domain.ProbeFamilyResult{Status: domain.ProbeStatusUnhealthy, TestedAt: startedAt} + stage := "create_client" + statusCode := 0 + var traceMu sync.Mutex + var connectStartedAt, tlsStartedAt time.Time + connectDone, connectFailed := false, false + tlsDone, tlsFailed := false, false + gotFirstByte := false + connectMS, tlsMS, firstByteMS := 0, 0, 0 + defer func() { + completedAt := time.Now().UTC() + durationMS := max(1, int(completedAt.Sub(startedAt).Milliseconds())) + result.TestedAt = completedAt + if result.LatencyMS == 0 { + result.LatencyMS = durationMS + } + traceMu.Lock() + if !connectStartedAt.IsZero() && connectMS == 0 { + connectMS = max(1, int(time.Since(connectStartedAt).Milliseconds())) + } + if !tlsStartedAt.IsZero() && tlsMS == 0 { + tlsMS = max(1, int(time.Since(tlsStartedAt).Milliseconds())) + } + if stage == "first_byte" && firstByteMS == 0 { + firstByteMS = durationMS + } + connectDurationMS, tlsDurationMS, firstByteDurationMS := connectMS, tlsMS, firstByteMS + traceMu.Unlock() + attributes := []any{ + "node_id", target.nodeID, + "node_name", target.nodeName, + "scope", target.nodeScope, + "probe_provider", provider, + "address_family", family, + "endpoint", targetURL, + "stage", stage, + "status_code", statusCode, + "duration_ms", durationMS, + "connect_ms", connectDurationMS, + "tls_ms", tlsDurationMS, + "first_byte_ms", firstByteDurationMS, + } + if probeErr != nil || result.Status != domain.ProbeStatusHealthy { + attributes = append(attributes, "probe_error", result.Error) + if probeErr != nil { + attributes = append(attributes, "error", sanitizeFlareSolverrMessage(probeErr.Error())) + } + m.log().WarnContext(ctx, "egress_probe_failed", attributes...) + return + } + attributes = append(attributes, "latency_ms", result.LatencyMS, "exit_ip", result.ExitIP) + m.log().InfoContext(ctx, "egress_probe_succeeded", attributes...) + }() + clientFactory := m.newBuildClient + if clientFactory == nil { + clientFactory = newBuildRequestClient + } + client, err := clientFactory(target.proxyURL, egressProbeTimeout) + if err != nil { + result.Error = "创建代理连接失败" + return result, err + } + defer client.CloseIdleConnections() + probeCtx, cancel := context.WithTimeout(ctx, egressProbeTimeout) + defer cancel() + probeCtx = httptrace.WithClientTrace(probeCtx, &httptrace.ClientTrace{ + ConnectStart: func(_, _ string) { + traceMu.Lock() + if connectStartedAt.IsZero() { + connectStartedAt = time.Now() + } + traceMu.Unlock() + }, + ConnectDone: func(_, _ string, traceErr error) { + traceMu.Lock() + connectDone = true + connectFailed = traceErr != nil + if !connectStartedAt.IsZero() && connectMS == 0 { + connectMS = max(1, int(time.Since(connectStartedAt).Milliseconds())) + } + traceMu.Unlock() + }, + TLSHandshakeStart: func() { + traceMu.Lock() + if tlsStartedAt.IsZero() { + tlsStartedAt = time.Now() + } + traceMu.Unlock() + }, + TLSHandshakeDone: func(_ tls.ConnectionState, traceErr error) { + traceMu.Lock() + tlsDone = true + tlsFailed = traceErr != nil + if !tlsStartedAt.IsZero() && tlsMS == 0 { + tlsMS = max(1, int(time.Since(tlsStartedAt).Milliseconds())) + } + traceMu.Unlock() + }, + GotFirstResponseByte: func() { + traceMu.Lock() + gotFirstByte = true + if firstByteMS == 0 { + firstByteMS = max(1, int(time.Since(startedAt).Milliseconds())) + } + traceMu.Unlock() + }, + }) + stage = "build_request" + request, err := http.NewRequestWithContext(probeCtx, http.MethodGet, targetURL, nil) + if err != nil { + result.Error = "构造探测请求失败" + return result, err + } + request.Header.Set("User-Agent", DefaultUserAgent) + stage = "execute_request" + response, err := client.Do(request) + if err != nil { + traceMu.Lock() + switch { + case !connectStartedAt.IsZero() && (!connectDone || connectFailed): + stage = "connect" + case !tlsStartedAt.IsZero() && (!tlsDone || tlsFailed): + stage = "tls" + case (connectDone || tlsDone) && !gotFirstByte: + stage = "first_byte" + default: + stage = "execute_request" + } + traceMu.Unlock() + result.Error = "代理连接失败" + return result, err + } + statusCode = response.StatusCode + defer response.Body.Close() + stage = "read_response" + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + if readErr != nil { + result.Error = "读取探测响应失败" + return result, readErr + } + stage = "validate_status" + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + result.Error = fmt.Sprintf("探测服务返回 HTTP %d", response.StatusCode) + return result, errors.New(result.Error) + } + stage = "decode_response" + exitIP, err := decodeProbeIP(body) + if err != nil { + result.Error = "探测服务响应格式无效" + return result, err + } + stage = "validate_exit_ip" + address, err := netip.ParseAddr(exitIP) + if err != nil || (family == "ipv4" && !address.Is4()) || (family == "ipv6" && !address.Is6()) { + result.Error = fmt.Sprintf("探测服务未返回有效 %s 出口 IP", strings.ToUpper(family)) + if err == nil { + err = errors.New(result.Error) + } + return result, err + } + result.Status = domain.ProbeStatusHealthy + result.LatencyMS = max(1, int(time.Since(startedAt).Milliseconds())) + result.ExitIP = address.String() + result.Error = "" + stage = "complete" + return result, nil +} + +func decodeProbeIP(body []byte) (string, error) { + var payload struct { + IP string `json:"ip"` + } + if json.Unmarshal(body, &payload) == nil && strings.TrimSpace(payload.IP) != "" { + return strings.TrimSpace(payload.IP), nil + } + for line := range strings.SplitSeq(string(body), "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if found && key == "ip" && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), nil + } + } + return "", errors.New("probe response does not contain an IP address") +} + +func (m *Manager) acquire(ctx context.Context, scope domain.Scope, affinity string, allowDirect bool, encryptedCredentialCookies string, boundNodeID uint64) (*Lease, bool, error) { + now := time.Now().UTC() + managedClearance := isGrokWebScope(scope) && m.clearanceMode() == "flaresolverr" + configured := false + var available []domain.Node + if boundNodeID != 0 { + selected, err := m.repository.GetEgressNode(ctx, boundNodeID) + if err != nil { + primaryErr := fmt.Errorf("读取绑定出口节点: %w", err) + if !errors.Is(err, repository.ErrNotFound) { + return nil, true, primaryErr + } + return m.acquireUnavailableFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, primaryErr) + } + if !domain.SupportsScope(selected.Scope, scope) { + return m.acquireUnavailableFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, fmt.Errorf("绑定出口节点 %d 与 %s 作用域不兼容", boundNodeID, scope)) + } + if !selected.Enabled { + return m.acquireUnavailableFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, fmt.Errorf("绑定出口节点 %d 已禁用", boundNodeID)) + } + if strings.TrimSpace(selected.EncryptedProxyURL) == "" { + return m.acquireUnavailableFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, fmt.Errorf("绑定出口节点 %d 未配置代理地址", boundNodeID)) + } + proxyPool := m.isProxyPoolNode(selected) + if !proxyPool && selected.CooldownUntil != nil && now.Before(*selected.CooldownUntil) { + return m.acquireUnavailableFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, fmt.Errorf("绑定出口节点 %d 正在冷却", boundNodeID)) + } + return m.leaseForNode(ctx, scope, affinity, encryptedCredentialCookies, managedClearance, selected) + } + fallbackConfig, fallbackSupported, fallbackConfigErr := m.loadOperationsConfig(ctx, now) + fallback := domain.FallbackConfig{Mode: domain.FallbackModeNone} + reservedFallbackNodes := make(map[uint64]struct{}, 4) + if fallbackConfigErr == nil && fallbackSupported { + fallback = fallbackConfig.FallbackFor(scope) + for _, fallbackScope := range []domain.Scope{domain.ScopeBuild, domain.ScopeWeb, domain.ScopeConsole, domain.ScopeWebAsset} { + configuredFallback := fallbackConfig.FallbackFor(fallbackScope) + if configuredFallback.Mode == domain.FallbackModeFixed && configuredFallback.NodeID != 0 { + reservedFallbackNodes[configuredFallback.NodeID] = struct{}{} + } + } + } + for _, candidateScope := range fallbackScopes(scope) { + nodes, err := m.listNodes(ctx, candidateScope, now) + if err != nil { + return nil, false, err + } + candidateAvailable := make([]domain.Node, 0, len(nodes)) + for _, node := range nodes { + if !node.Enabled { + continue + } + // A fixed fallback is a reserved last resort, not another member of + // the primary pool. It is validated again immediately before use. + if _, reserved := reservedFallbackNodes[node.ID]; reserved { + continue + } + configured = true + proxyPool := m.isProxyPoolNode(node) + if node.CooldownUntil == nil || !now.Before(*node.CooldownUntil) || proxyPool { + if proxyPool { + node.Health, node.FailureCount, node.CooldownUntil, node.LastError = 1, 0, nil, "" + } + candidateAvailable = append(candidateAvailable, node) + } + } + if len(candidateAvailable) > 0 { + available = candidateAvailable + break + } + } + if len(available) == 0 { + primaryErr := error(nil) + if configured { + primaryErr = fmt.Errorf("当前没有可用的 %s 出口节点", scope) + } + lease, fallbackConfigured, applied, err := m.applyFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, primaryErr, fallback, fallbackSupported, fallbackConfigErr) + if err != nil { + return nil, fallbackConfigured, err + } + if applied { + return lease, fallbackConfigured, nil + } + if primaryErr != nil { + return nil, false, primaryErr + } + if !allowDirect { + recordSelection(ctx, Selection{NodeName: "direct", Scope: scope}) + return nil, false, nil + } + available = []domain.Node{{ID: 0, Name: "direct", Scope: scope, Enabled: true, Health: 1}} + } + sort.SliceStable(available, func(i, j int) bool { return available[i].ID < available[j].ID }) + selected := m.selectNode(available, affinity) + return m.leaseForNode(ctx, scope, affinity, encryptedCredentialCookies, managedClearance, selected) +} + +// acquireUnavailableFallback keeps the primary selection error when fallback +// is disabled. A fixed fallback is never silently replaced with direct traffic +// when it is invalid or unavailable. +func (m *Manager) acquireUnavailableFallback(ctx context.Context, scope domain.Scope, affinity string, allowDirect bool, encryptedCredentialCookies string, managedClearance bool, primaryErr error) (*Lease, bool, error) { + lease, configured, applied, err := m.acquireFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, primaryErr) + if err != nil { + return nil, configured, err + } + if applied { + return lease, configured, nil + } + return nil, true, primaryErr +} + +// acquireFallback is called before an upstream request is written. Transport +// failures are intentionally not retried through this path because replaying +// a non-idempotent upstream request on a different IP can duplicate work. +func (m *Manager) acquireFallback(ctx context.Context, scope domain.Scope, affinity string, allowDirect bool, encryptedCredentialCookies string, managedClearance bool, primaryErr error) (*Lease, bool, bool, error) { + fallback, supported, err := m.fallbackFor(ctx, scope, time.Now().UTC()) + return m.applyFallback(ctx, scope, affinity, allowDirect, encryptedCredentialCookies, managedClearance, primaryErr, fallback, supported, err) +} + +func (m *Manager) applyFallback(ctx context.Context, scope domain.Scope, affinity string, allowDirect bool, encryptedCredentialCookies string, managedClearance bool, primaryErr error, fallback domain.FallbackConfig, supported bool, configErr error) (*Lease, bool, bool, error) { + if configErr != nil { + return nil, false, false, fallbackError(primaryErr, fmt.Errorf("读取出口回退配置: %w", configErr)) + } + if !supported { + return nil, false, false, nil + } + switch fallback.Mode { + case domain.FallbackModeNone: + return nil, false, false, nil + case domain.FallbackModeDirect: + if !allowDirect { + recordSelection(ctx, Selection{NodeName: "direct", Scope: scope}) + return nil, false, true, nil + } + lease, _, err := m.leaseForNode(ctx, scope, affinity, encryptedCredentialCookies, managedClearance, domain.Node{ID: 0, Name: "direct", Scope: scope, Enabled: true, Health: 1}) + if err != nil { + return nil, false, false, fallbackError(primaryErr, fmt.Errorf("获取本地直连回退: %w", err)) + } + return lease, false, true, nil + case domain.FallbackModeFixed: + selected, err := m.fixedFallbackNode(ctx, scope, fallback.NodeID) + if err != nil { + return nil, false, false, fallbackError(primaryErr, err) + } + lease, _, err := m.leaseForNode(ctx, scope, affinity, encryptedCredentialCookies, managedClearance, selected) + if err != nil { + return nil, false, false, fallbackError(primaryErr, fmt.Errorf("获取固定回退节点 %d: %w", selected.ID, err)) + } + return lease, true, true, nil + default: + return nil, false, false, fallbackError(primaryErr, fmt.Errorf("出口回退模式 %q 无效", fallback.Mode)) + } +} + +func (m *Manager) fallbackFor(ctx context.Context, scope domain.Scope, now time.Time) (domain.FallbackConfig, bool, error) { + config, supported, err := m.loadOperationsConfig(ctx, now) + if err != nil || !supported { + return domain.FallbackConfig{Mode: domain.FallbackModeNone}, supported, err + } + return config.FallbackFor(scope), true, nil +} + +func (m *Manager) loadOperationsConfig(ctx context.Context, now time.Time) (domain.OperationsConfig, bool, error) { + configRepository, ok := m.repository.(operationsConfigRepository) + if !ok { + return domain.OperationsConfig{}, false, nil + } + m.operationsMu.RLock() + cached := m.operationsConfig + m.operationsMu.RUnlock() + if !cached.expiresAt.IsZero() && now.Before(cached.expiresAt) { + return cached.value, true, nil + } + loaded, err, _ := m.operationsConfigLoad.Do("operations", func() (any, error) { + checkTime := time.Now().UTC() + m.operationsMu.RLock() + cached := m.operationsConfig + m.operationsMu.RUnlock() + if !cached.expiresAt.IsZero() && checkTime.Before(cached.expiresAt) { + return cached.value, nil + } + m.operationsMu.RLock() + version := m.operationsConfigVer + m.operationsMu.RUnlock() + value, err := configRepository.GetEgressOperationsConfig(ctx) + if err != nil { + return domain.OperationsConfig{}, err + } + m.operationsMu.Lock() + if version == m.operationsConfigVer { + m.operationsConfig = cachedOperationsConfig{value: value, expiresAt: checkTime.Add(operationsConfigSnapshotTTL)} + } + m.operationsMu.Unlock() + return value, nil + }) + if err != nil { + return domain.OperationsConfig{}, true, err + } + return loaded.(domain.OperationsConfig), true, nil +} + +func (m *Manager) fixedFallbackNode(ctx context.Context, scope domain.Scope, nodeID uint64) (domain.Node, error) { + if nodeID == 0 { + return domain.Node{}, errors.New("固定回退节点未指定") + } + selected, err := m.repository.GetEgressNode(ctx, nodeID) + if err != nil { + return domain.Node{}, fmt.Errorf("读取固定回退节点 %d: %w", nodeID, err) + } + if !domain.SupportsScope(selected.Scope, scope) { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 与 %s 作用域不兼容", nodeID, scope) + } + if !selected.Enabled { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 已禁用", nodeID) + } + if selected.ProxyPool { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 使用代理池模式", nodeID) + } + if strings.TrimSpace(selected.EncryptedProxyURL) == "" { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 未配置代理地址", nodeID) + } + if selected.CooldownUntil != nil && time.Now().UTC().Before(*selected.CooldownUntil) { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 正在冷却", nodeID) + } + proxyURL, err := m.cipher.Decrypt(selected.EncryptedProxyURL) + if err != nil { + return domain.Node{}, fmt.Errorf("读取固定回退节点 %d 代理配置: %w", nodeID, err) + } + proxyURL, err = application.NormalizeProxyURL(proxyURL) + if err != nil || proxyURL == "" { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 代理地址无效", nodeID) + } + if strings.Contains(proxyURL, application.ProxyAccountPlaceholder) { + return domain.Node{}, fmt.Errorf("固定回退节点 %d 使用账号代理模板", nodeID) + } + return selected, nil +} + +func fallbackError(primaryErr, fallbackErr error) error { + if primaryErr == nil { + return fallbackErr + } + return fmt.Errorf("%w;出口回退不可用: %v", primaryErr, fallbackErr) +} + +func (m *Manager) leaseForNode(ctx context.Context, scope domain.Scope, affinity, encryptedCredentialCookies string, managedClearance bool, selected domain.Node) (*Lease, bool, error) { + credentialCookies := "" + if !managedClearance && scope != domain.ScopeBuild && strings.TrimSpace(encryptedCredentialCookies) != "" { + decryptedCookies, decryptErr := m.cipher.Decrypt(encryptedCredentialCookies) + if decryptErr != nil { + return nil, true, decryptErr + } + credentialCookies = application.SanitizeCloudflareCookies(decryptedCookies) + } + proxyURL, err := m.cipher.Decrypt(selected.EncryptedProxyURL) + if err != nil { + return nil, false, err + } + proxyURL, err = application.NormalizeProxyURL(proxyURL) + if err != nil { + return nil, false, err + } + sticky := strings.Contains(proxyURL, application.ProxyAccountPlaceholder) + proxyPool := selected.ProxyPool || sticky + freshTunnel := selected.ProxyPool && !sticky + if sticky { + accountKey := accountFromContext(ctx) + if accountKey == "" && strings.TrimSpace(affinity) != "" { + accountKey = string(scope) + "_" + strings.TrimSpace(affinity) + } + proxyURL, err = renderAccountProxyURL(proxyURL, accountKey) + if err != nil { + return nil, false, err + } + } + cookies := "" + if scope != domain.ScopeBuild { + cookies, err = m.cipher.Decrypt(selected.EncryptedCloudflareCookie) + if err != nil { + // Managed mode can recover a damaged persisted cookie by asking the + // solver for a fresh one. Manual mode must still surface the storage + // error because it has no safe replacement source. + if !managedClearance { + return nil, false, err + } + cookies = "" + } + cookies = application.SanitizeCloudflareCookies(cookies) + if credentialCookies != "" { + cookies = credentialCookies + } + } + userAgent := "" + if scope != domain.ScopeBuild { + userAgent = strings.TrimSpace(selected.UserAgent) + } + if scope != domain.ScopeBuild && userAgent == "" { + userAgent = DefaultUserAgent + } + clearanceKey := "" + // Manual mode may prefer account-bound cookies. Managed mode always enters + // the FlareSolverr lifecycle so stale imported cookies cannot bypass refresh. + if managedClearance { + clearanceKey = clearanceCacheKey(selected.ID, proxyURL, sticky) + cookies, userAgent, err = m.ensureClearance(ctx, selected, proxyURL, cookies, userAgent, clearanceKey, !sticky) + if err != nil { + return nil, false, err + } + } + client, err := m.clientFor(selected.ID, scope, proxyURL, userAgent, cookies, sticky) + if err != nil { + return nil, false, err + } + m.incrementInflight(selected.ID) + recordSelection(ctx, Selection{NodeID: selected.ID, NodeName: selected.Name, Scope: scope, Proxied: proxyURL != ""}) + var once sync.Once + return &Lease{NodeID: selected.ID, NodeName: selected.Name, Scope: scope, ProxyURL: proxyURL, UserAgent: userAgent, CFCookies: cookies, client: client.client, browser: client.browser, sticky: sticky, proxyPool: proxyPool, freshTunnel: freshTunnel, clearanceKey: clearanceKey, clearanceManager: m, release: func() { + once.Do(func() { + m.decrementInflight(selected.ID) + }) + }}, true, nil +} + +func (m *Manager) inflightCounter(nodeID uint64) *atomic.Int64 { + // Counters remain address-stable for the manager lifetime so a concurrent + // release can never decrement a replacement counter after an ABA deletion. + if value, ok := m.inflight.Load(nodeID); ok { + return value.(*atomic.Int64) + } + candidate := &atomic.Int64{} + actual, _ := m.inflight.LoadOrStore(nodeID, candidate) + return actual.(*atomic.Int64) +} + +func (m *Manager) incrementInflight(nodeID uint64) { + m.inflightCounter(nodeID).Add(1) +} + +func (m *Manager) decrementInflight(nodeID uint64) { + if value, ok := m.inflight.Load(nodeID); ok { + value.(*atomic.Int64).Add(-1) + } +} + +func clearanceCacheKey(nodeID uint64, proxyURL string, sticky bool) string { + if nodeID == 0 { + return "direct" + } + base := "node:" + strconv.FormatUint(nodeID, 10) + if !sticky { + return base + } + digest := sha256.Sum256([]byte(proxyURL)) + return base + ":account:" + fmt.Sprintf("%x", digest[:16]) +} + +func renderAccountProxyURL(template, accountKey string) (string, error) { + if !strings.Contains(template, application.ProxyAccountPlaceholder) { + return template, nil + } + accountKey = normalizeProxyAccount(accountKey) + if accountKey == "" { + return "", errors.New("粘性代理需要有效的账号身份") + } + return strings.ReplaceAll(template, application.ProxyAccountPlaceholder, accountKey), nil +} + +func normalizeProxyAccount(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.Map(func(character rune) rune { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '_' || character == '-' { + return character + } + return '_' + }, value) + if len(value) <= 128 { + return value + } + digest := sha256.Sum256([]byte(value)) + return value[:95] + "_" + fmt.Sprintf("%x", digest[:16]) +} + +func (m *Manager) listNodes(ctx context.Context, scope domain.Scope, now time.Time) ([]domain.Node, error) { + for { + m.nodeMu.RLock() + if snapshot, ok := m.nodes[scope]; ok && now.Before(snapshot.expiresAt) { + // Node snapshots are replaced with a copied slice and treated as immutable + // by callers. Returning the shared read-only slice avoids a per-request copy. + values := snapshot.values + m.nodeMu.RUnlock() + return values, nil + } + m.nodeMu.RUnlock() + loaded, err, _ := m.nodeLoads.Do(string(scope), func() (any, error) { + checkTime := time.Now().UTC() + m.nodeMu.RLock() + if snapshot, ok := m.nodes[scope]; ok && checkTime.Before(snapshot.expiresAt) { + values := snapshot.values + m.nodeMu.RUnlock() + return values, nil + } + version := m.nodeVersions[scope] + m.nodeMu.RUnlock() + values, err := m.repository.ListEgressNodes(ctx, scope, repository.SortQuery{}) + if err != nil { + return nil, err + } + m.nodeMu.Lock() + if m.nodeVersions[scope] != version { + m.nodeMu.Unlock() + return nil, errNodeSnapshotInvalidated + } + m.replaceNodeSnapshotLocked(scope, values, checkTime.Add(nodeSnapshotTTL)) + values = m.nodes[scope].values + m.nodeMu.Unlock() + return values, nil + }) + if err != nil { + if errors.Is(err, errNodeSnapshotInvalidated) && ctx.Err() == nil { + now = time.Now().UTC() + continue + } + return nil, err + } + return loaded.([]domain.Node), nil + } +} + +func (m *Manager) invalidateNodes(scope domain.Scope) { + m.nodeMu.Lock() + m.nodeVersions[scope]++ + snapshot, ok := m.nodes[scope] + if ok { + delete(m.nodes, scope) + } + for _, node := range snapshot.values { + delete(m.healthyNodes, node.ID) + } + m.nodeMu.Unlock() +} + +func (m *Manager) replaceNodeSnapshotLocked(scope domain.Scope, values []domain.Node, expiresAt time.Time) { + for _, node := range m.nodes[scope].values { + delete(m.healthyNodes, node.ID) + } + for _, node := range values { + if nodeIsHealthy(node) { + m.healthyNodes[node.ID] = expiresAt + } else { + delete(m.healthyNodes, node.ID) + } + } + m.nodes[scope] = cachedNodeSnapshot{values: append([]domain.Node(nil), values...), expiresAt: expiresAt} +} + +func (m *Manager) InvalidateOperationsConfig() { + m.operationsMu.Lock() + m.operationsConfig = cachedOperationsConfig{} + m.operationsConfigVer++ + m.operationsMu.Unlock() +} + +func fallbackScopes(scope domain.Scope) []domain.Scope { + if scope == domain.ScopeWebAsset { + return []domain.Scope{domain.ScopeWebAsset, domain.ScopeWeb} + } + if scope == domain.ScopeConsole { + // Console uses the same browser/clearance surface as Grok Web. A + // dedicated Console node is preferred, but a Web node is a safe and + // expected fallback for deployments that configure one shared pool. + return []domain.Scope{domain.ScopeConsole, domain.ScopeWeb} + } + return []domain.Scope{scope} +} + +func (m *Manager) selectNode(nodes []domain.Node, affinity string) domain.Node { + if affinity != "" { + digest := sha256.Sum256([]byte(affinity)) + selected := nodes[int(binary.BigEndian.Uint64(digest[:8])%uint64(len(nodes)))] + if selected.Health >= 0.8 || len(nodes) == 1 { + return selected + } + for _, node := range nodes { + if node.Health > selected.Health { + selected = node + } + } + return selected + } + best := nodes[0] + bestCurrent := m.inflightCount(best.ID) + for _, node := range nodes[1:] { + current := m.inflightCount(node.ID) + if current < bestCurrent || (current == bestCurrent && node.Health > best.Health) { + best = node + bestCurrent = current + } + } + return best +} + +func (m *Manager) inflightCount(nodeID uint64) int64 { + if value, ok := m.inflight.Load(nodeID); ok { + return value.(*atomic.Int64).Load() + } + return 0 +} + +func (m *Manager) clientFor(id uint64, scope domain.Scope, proxyURL, userAgent, cookies string, sticky bool) (cachedClient, error) { + clientKind := "browser" + buildHeaderTimeout := time.Duration(0) + if scope == domain.ScopeBuild { + clientKind = "build" + buildHeaderTimeout = time.Duration(m.buildHeaderTimeout.Load()) + if buildHeaderTimeout <= 0 { + buildHeaderTimeout = settingsdomain.DefaultBuildResponseHeaderTimeout + } + clientKind += "\x00" + strconv.FormatInt(int64(buildHeaderTimeout), 10) + } + fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(clientKind+"\x00"+proxyURL+"\x00"+userAgent+"\x00"+cookies))) + cacheScope := scope + if cacheScope == domain.ScopeWebAsset { + cacheScope = domain.ScopeWeb + } + key := clientCacheKey{nodeID: id, scope: cacheScope, fingerprint: fingerprint} + loadKey := strconv.FormatUint(key.nodeID, 10) + "\x00" + string(key.scope) + "\x00" + key.fingerprint + for attempt := 0; attempt < clientCreationRetryLimit; attempt++ { + now := time.Now().UTC() + m.clientMu.RLock() + cached, cachedOK := m.clients[key] + cleanupDue := m.lastClientCleanup.IsZero() || now.Sub(m.lastClientCleanup) >= clientCacheCleanupInterval + touchDue := cachedOK && (cached.lastUsed.IsZero() || now.Sub(cached.lastUsed) >= clientCacheTouchInterval) + m.clientMu.RUnlock() + if cachedOK && !cleanupDue && !touchDue { + return cached, nil + } + + m.clientMu.Lock() + stale := m.cleanupClientCacheLocked(now) + if cached, ok := m.clients[key]; ok { + cached.lastUsed = now + m.clients[key] = cached + m.clientMu.Unlock() + closeRequestClients(stale) + return cached, nil + } + m.clientMu.Unlock() + closeRequestClients(stale) + + loaded, err, _ := m.clientLoads.Do(loadKey, func() (any, error) { + return m.createAndCacheClient(key, id, scope, proxyURL, userAgent, sticky, buildHeaderTimeout) + }) + if errors.Is(err, errClientCacheInvalidated) { + continue + } + if err != nil { + return cachedClient{}, err + } + return loaded.(cachedClient), nil + } + return cachedClient{}, errClientCacheInvalidated +} + +func (m *Manager) createAndCacheClient(key clientCacheKey, id uint64, scope domain.Scope, proxyURL, userAgent string, sticky bool, buildHeaderTimeout time.Duration) (cachedClient, error) { + now := time.Now().UTC() + m.clientMu.Lock() + stale := m.cleanupClientCacheLocked(now) + if cached, ok := m.clients[key]; ok { + cached.lastUsed = now + m.clients[key] = cached + m.clientMu.Unlock() + closeRequestClients(stale) + return cached, nil + } + version := m.clientVersionLocked(id) + m.clientMu.Unlock() + closeRequestClients(stale) + + value, err := m.buildCachedClient(scope, proxyURL, userAgent, buildHeaderTimeout) + if err != nil { + return cachedClient{}, err + } + value.lastUsed = time.Now().UTC() + + m.clientMu.Lock() + stale = m.cleanupClientCacheLocked(value.lastUsed) + if cached, ok := m.clients[key]; ok { + cached.lastUsed = value.lastUsed + m.clients[key] = cached + m.clientMu.Unlock() + closeRequestClients(append(stale, value.client)) + return cached, nil + } + if m.clientVersionLocked(id) != version { + m.clientMu.Unlock() + closeRequestClients(append(stale, value.client)) + return cachedClient{}, errClientCacheInvalidated + } + if id != 0 && !sticky { + for previousKey, previous := range m.clients { + if previousKey.nodeID == id { + stale = append(stale, m.evictClientLocked(previousKey, previous)) + } + } + } + stale = append(stale, m.ensureClientCacheCapacityLocked()...) + m.clients[key] = value + m.clientMu.Unlock() + closeRequestClients(stale) + return value, nil +} + +func (m *Manager) buildCachedClient(scope domain.Scope, proxyURL, userAgent string, buildHeaderTimeout time.Duration) (cachedClient, error) { + if scope == domain.ScopeBuild { + factory := m.newBuildClient + if factory == nil { + factory = newBuildRequestClient + } + client, err := factory(proxyURL, buildHeaderTimeout) + if err != nil { + return cachedClient{}, err + } + return cachedClient{client: client}, nil + } + factory := m.newBrowserClient + if factory == nil { + factory = newBrowserClient + } + client, err := factory(proxyURL, userAgent) + if err != nil { + return cachedClient{}, err + } + return cachedClient{client: client, browser: client}, nil +} + +func newBuildRequestClient(proxyURL string, responseHeaderTimeout time.Duration) (requestClient, error) { + return newBuildClient(proxyURL, responseHeaderTimeout) +} + +func (m *Manager) cleanupClientCacheLocked(now time.Time) []requestClient { + if m.clients == nil { + m.clients = make(map[clientCacheKey]cachedClient) + } + if !m.lastClientCleanup.IsZero() && now.Sub(m.lastClientCleanup) < clientCacheCleanupInterval { + return nil + } + m.lastClientCleanup = now + var stale []requestClient + for key, value := range m.clients { + if !value.lastUsed.IsZero() && now.Sub(value.lastUsed) >= clientCacheIdleTTL { + stale = append(stale, m.evictClientLocked(key, value)) + } + } + return stale +} + +func (m *Manager) ensureClientCacheCapacityLocked() []requestClient { + var stale []requestClient + for len(m.clients) >= maxCachedClients { + var oldestKey clientCacheKey + var oldest cachedClient + found := false + for key, value := range m.clients { + if !found || value.lastUsed.Before(oldest.lastUsed) { + oldestKey, oldest, found = key, value, true + } + } + if !found { + break + } + stale = append(stale, m.evictClientLocked(oldestKey, oldest)) + } + return stale +} + +func (m *Manager) evictClientLocked(key clientCacheKey, value cachedClient) requestClient { + delete(m.clients, key) + return value.client +} + +func closeRequestClients(values []requestClient) { + for _, value := range values { + if value != nil { + value.CloseIdleConnections() + } + } +} + +func (m *Manager) clientVersionLocked(nodeID uint64) uint64 { + return m.clientGeneration + m.clientVersions[nodeID] +} + +func (m *Manager) invalidateClientVersionLocked(nodeID uint64) { + if m.clientVersions == nil { + m.clientVersions = make(map[uint64]uint64) + } + if _, exists := m.clientVersions[nodeID]; !exists && len(m.clientVersions) >= maxClientVersionEntries { + // A generation bump invalidates in-flight creations before the tombstone map is reset. + m.clientGeneration++ + clear(m.clientVersions) + } + m.clientVersions[nodeID]++ +} + +func (m *Manager) invalidateAllClientVersionsLocked() { + m.clientGeneration++ + clear(m.clientVersions) +} + +func (m *Manager) Feedback(ctx context.Context, nodeID uint64, status int, transportErr error) { + m.FeedbackForScope(ctx, domain.ScopeWeb, nodeID, status, transportErr) +} + +func (m *Manager) FeedbackForScope(ctx context.Context, scope domain.Scope, nodeID uint64, status int, transportErr error) { + if status == clientClosedRequestStatus || errors.Is(transportErr, context.Canceled) { + return + } + if scope == domain.ScopeBuild && neterrorpkg.IsResponseHeaderTimeout(transportErr) { + return + } + if nodeID == 0 { + if transportErr != nil || (scope != domain.ScopeBuild && status == http.StatusForbidden) { + m.clearanceMu.Lock() + if isGrokWebScope(scope) && status == http.StatusForbidden && m.clearanceConfig.Mode == "flaresolverr" { + state := m.clearances["direct"] + state.invalid = true + state.used = true + m.clearances["direct"] = state + } + m.clearanceMu.Unlock() + m.clientMu.Lock() + stale := m.invalidateClientForScopeLocked(0, scope) + m.clientMu.Unlock() + closeRequestClients(stale) + } + return + } + succeeded := transportErr == nil && status >= 200 && status < 400 + if succeeded && m.cachedNodeIsHealthy(nodeID) { + return + } + value, err := m.repository.GetEgressNode(ctx, nodeID) + if err != nil { + return + } + if succeeded && nodeIsHealthy(value) { + m.nodeMu.Lock() + m.healthyNodes[nodeID] = time.Now().UTC().Add(nodeSnapshotTTL) + m.nodeMu.Unlock() + return + } + now := time.Now().UTC() + var stale []requestClient + switch { + case succeeded: + value.Health = min(1, value.Health+0.1) + value.FailureCount = 0 + value.CooldownUntil = nil + value.LastError = "" + case status == http.StatusUnauthorized || status == http.StatusTooManyRequests: + return + case scope == domain.ScopeBuild && status == http.StatusForbidden: + // Build 403 may indicate account permissions, quota, token, or egress policy. The gateway classifies the body; + // status alone must not misclassify standard CLI egress as Web anti-bot behavior. + return + case scope == domain.ScopeBuild && status == http.StatusBadRequest: + // Device OAuth polls with 400 + authorization_pending before user confirmation. + // This is a normal protocol state and must not cool the egress node. + return + case status == http.StatusForbidden: + if m.isProxyPoolNode(value) { + // A request-level 403 does not prove that a shared proxy pool is unhealthy. + return + } + value.FailureCount++ + value.Health = max(0.05, value.Health*0.7) + value.CooldownUntil = nil + value.LastError = "anti-bot rejection" + m.clearanceMu.Lock() + if isGrokWebScope(scope) && m.clearanceConfig.Mode == "flaresolverr" { + key := clearanceCacheKey(nodeID, "", false) + state := m.clearances[key] + state.invalid = true + state.used = true + m.clearances[key] = state + } + m.clearanceMu.Unlock() + m.clientMu.Lock() + stale = m.invalidateClientLocked(nodeID) + m.clientMu.Unlock() + case transportErr != nil: + if m.isProxyPoolNode(value) { + return + } + value.FailureCount++ + value.Health = max(0.05, value.Health*0.7) + cooldown := min(10*time.Minute, 30*time.Second*time.Duration(1<= 1 && value.FailureCount == 0 && value.CooldownUntil == nil && value.LastError == "" +} + +func (m *Manager) clearanceMode() string { + m.clearanceMu.Lock() + defer m.clearanceMu.Unlock() + return m.clearanceConfig.Mode +} + +func (m *Manager) ensureClearance(ctx context.Context, node domain.Node, proxyURL, existingCookies, existingUserAgent, key string, persist bool) (string, string, error) { + m.clearanceMu.Lock() + cfg := m.clearanceConfig + version := m.clearanceVersion + interval := clearanceRefreshInterval(cfg) + now := time.Now().UTC() + fingerprint := clearanceFingerprint(cfg, proxyURL) + bindingFingerprint := clearanceBindingFingerprint(cfg, proxyURL) + m.cleanupClearanceCacheLocked(now, interval) + state, known := m.clearances[key] + if key == "direct" { + if !known { + m.ensureClearanceCacheCapacityLocked() + } + state.used = true + m.clearances[key] = state + } + if (!known || state.userAgent == "") && persist && (existingCookies != "" || node.ClearanceRefreshedAt != nil) { + if !known { + m.ensureClearanceCacheCapacityLocked() + } + state = clearanceState{ + cookies: existingCookies, userAgent: existingUserAgent, used: true, version: version, + fingerprint: node.ClearanceFingerprint, bindingFingerprint: node.ClearanceBindingFingerprint, + lastUsedAt: now, + } + if node.ClearanceRefreshedAt != nil { + state.refreshedAt = *node.ClearanceRefreshedAt + } + known = true + m.clearances[key] = state + } + // A successful solve may legitimately return no Cloudflare cookies when the + // selected egress does not trigger a challenge. The solver User-Agent marks + // that cookie-less result as complete so requests do not block on re-solving. + fresh := known && !state.invalid && state.userAgent != "" && state.version == version && + state.fingerprint == fingerprint && (state.bindingFingerprint == "" || state.bindingFingerprint == bindingFingerprint) && + !state.refreshedAt.IsZero() && now.Sub(state.refreshedAt) < interval + if fresh { + state.lastUsedAt = now + m.clearances[key] = state + cookies, userAgent := state.cookies, state.userAgent + m.clearanceMu.Unlock() + return cookies, userAgent, nil + } + fallbackAllowed := known && !state.invalid && state.userAgent != "" && + (state.bindingFingerprint == "" || state.bindingFingerprint == bindingFingerprint) + fallback := clearanceSolution{Cookies: state.cookies, UserAgent: state.userAgent} + forceRefresh := known && state.invalid + refreshAfter := time.Time{} + if forceRefresh { + refreshAfter = state.refreshedAt + } + if fallbackAllowed { + state.lastUsedAt = now + m.clearances[key] = state + } + if cfg.Mode != "flaresolverr" { + m.clearanceMu.Unlock() + return existingCookies, existingUserAgent, nil + } + m.clearanceMu.Unlock() + + result, err, _ := m.clearanceLoads.Do(key, func() (any, error) { + return m.refreshNode(ctx, node, proxyURL, key, persist, forceRefresh, !fallbackAllowed, refreshAfter) + }) + if err != nil { + if fallbackAllowed { + return fallback.Cookies, fallback.UserAgent, nil + } + return "", "", err + } + solution := result.(clearanceSolution) + return solution.Cookies, solution.UserAgent, nil +} + +func (m *Manager) refreshNode(ctx context.Context, node domain.Node, proxyURL, key string, persist, force, waitForPeer bool, refreshAfter time.Time) (clearanceSolution, error) { + m.clearanceMu.Lock() + cfg := m.clearanceConfig + solveVersion := m.clearanceVersion + solver := m.solver + lock := m.clearanceLock + m.clearanceMu.Unlock() + if cfg.Mode != "flaresolverr" { + return clearanceSolution{}, errors.New("FlareSolverr Clearance 未启用") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = time.Minute + } + fingerprint := clearanceFingerprint(cfg, proxyURL) + bindingFingerprint := clearanceBindingFingerprint(cfg, proxyURL) + interval := clearanceRefreshInterval(cfg) + if persist && node.ID != 0 && lock != nil { + release, acquired, err := lock.Acquire(ctx, "egress-clearance:"+strconv.FormatUint(node.ID, 10), timeout+clearanceLockGrace) + if err != nil { + return clearanceSolution{}, fmt.Errorf("协调 Clearance 刷新: %w", err) + } + if !acquired { + if !force { + if solution, refreshedAt, ok := m.loadPersistedClearance(ctx, node.ID, fingerprint, bindingFingerprint, interval); ok { + m.cacheClearance(key, solution, refreshedAt, solveVersion, fingerprint, bindingFingerprint, interval) + return solution, nil + } + } + if waitForPeer { + if solution, refreshedAt, ok := m.waitPersistedClearance(ctx, node.ID, fingerprint, bindingFingerprint, interval, timeout, refreshAfter); ok { + m.cacheClearance(key, solution, refreshedAt, solveVersion, fingerprint, bindingFingerprint, interval) + return solution, nil + } + } + return clearanceSolution{}, errors.New("另一个实例正在刷新 Cloudflare Clearance") + } + defer release() + if !force { + if solution, refreshedAt, ok := m.loadPersistedClearance(ctx, node.ID, fingerprint, bindingFingerprint, interval); ok { + m.cacheClearance(key, solution, refreshedAt, solveVersion, fingerprint, bindingFingerprint, interval) + return solution, nil + } + } + } + solveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + solution, err := solver.Solve(solveCtx, cfg, proxyURL) + if err != nil { + m.recordClearanceError(ctx, node, persist) + return clearanceSolution{}, fmt.Errorf("刷新出口 %q 的 Cloudflare Clearance: %w", node.Name, err) + } + now := time.Now().UTC() + if persist && node.ID != 0 { + encryptedCookies, encryptErr := m.cipher.Encrypt(solution.Cookies) + if encryptErr != nil { + return clearanceSolution{}, encryptErr + } + if stateRepository, ok := m.repository.(egressStateRepository); ok { + if updateErr := stateRepository.UpdateEgressNodeClearance(ctx, node.ID, encryptedCookies, solution.UserAgent, fingerprint, bindingFingerprint, now); updateErr != nil { + return clearanceSolution{}, updateErr + } + } else { + latest, loadErr := m.repository.GetEgressNode(ctx, node.ID) + if loadErr != nil { + return clearanceSolution{}, loadErr + } + latest.EncryptedCloudflareCookie = encryptedCookies + latest.UserAgent = solution.UserAgent + latest.ClearanceFingerprint = fingerprint + latest.ClearanceBindingFingerprint = bindingFingerprint + latest.ClearanceRefreshedAt = &now + latest.LastError = "" + if _, updateErr := m.repository.UpdateEgressNode(ctx, latest); updateErr != nil { + return clearanceSolution{}, updateErr + } + } + m.invalidateNodes(node.Scope) + } + m.cacheClearance(key, solution, now, solveVersion, fingerprint, bindingFingerprint, interval) + return solution, nil +} + +func (m *Manager) waitPersistedClearance(ctx context.Context, nodeID uint64, fingerprint, bindingFingerprint string, interval, timeout time.Duration, refreshAfter time.Time) (clearanceSolution, time.Time, bool) { + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-waitCtx.Done(): + return clearanceSolution{}, time.Time{}, false + case <-ticker.C: + if solution, refreshedAt, ok := m.loadPersistedClearance(waitCtx, nodeID, fingerprint, bindingFingerprint, interval); ok && + (refreshAfter.IsZero() || refreshedAt.After(refreshAfter)) { + return solution, refreshedAt, true + } + } + } +} + +func (m *Manager) loadPersistedClearance(ctx context.Context, nodeID uint64, fingerprint, bindingFingerprint string, interval time.Duration) (clearanceSolution, time.Time, bool) { + latest, err := m.repository.GetEgressNode(ctx, nodeID) + if err != nil || latest.ClearanceRefreshedAt == nil || latest.ClearanceFingerprint != fingerprint || + (latest.ClearanceBindingFingerprint != "" && latest.ClearanceBindingFingerprint != bindingFingerprint) || + time.Since(*latest.ClearanceRefreshedAt) >= interval { + return clearanceSolution{}, time.Time{}, false + } + cookies, err := m.cipher.Decrypt(latest.EncryptedCloudflareCookie) + if err != nil { + return clearanceSolution{}, time.Time{}, false + } + cookies = application.SanitizeCloudflareCookies(cookies) + userAgent := strings.TrimSpace(latest.UserAgent) + if userAgent == "" { + return clearanceSolution{}, time.Time{}, false + } + return clearanceSolution{Cookies: cookies, UserAgent: userAgent}, *latest.ClearanceRefreshedAt, true +} + +func (m *Manager) cacheClearance(key string, solution clearanceSolution, refreshedAt time.Time, version uint64, fingerprint, bindingFingerprint string, interval time.Duration) { + m.clearanceMu.Lock() + now := time.Now().UTC() + m.cleanupClearanceCacheLocked(now, interval) + if _, exists := m.clearances[key]; !exists { + m.ensureClearanceCacheCapacityLocked() + } + m.clearances[key] = clearanceState{ + cookies: solution.Cookies, userAgent: solution.UserAgent, refreshedAt: refreshedAt, + used: true, version: version, fingerprint: fingerprint, bindingFingerprint: bindingFingerprint, lastUsedAt: now, + } + m.clearanceMu.Unlock() +} + +func (m *Manager) cleanupClearanceCacheLocked(now time.Time, interval time.Duration) { + if m.clearances == nil { + m.clearances = make(map[string]clearanceState) + } + if !m.lastClearanceCleanup.IsZero() && now.Sub(m.lastClearanceCleanup) < clearanceCacheCleanupInterval { + return + } + m.lastClearanceCleanup = now + idleTTL := interval * 2 + if idleTTL < clearanceCacheMinIdleTTL { + idleTTL = clearanceCacheMinIdleTTL + } + for key, state := range m.clearances { + lastUsedAt := state.lastUsedAt + if lastUsedAt.IsZero() { + lastUsedAt = state.refreshedAt + } + if !lastUsedAt.IsZero() && now.Sub(lastUsedAt) >= idleTTL { + delete(m.clearances, key) + } + } +} + +func (m *Manager) ensureClearanceCacheCapacityLocked() { + if len(m.clearances) < maxCachedClearances { + return + } + type candidate struct { + key string + lastUsed time.Time + } + candidates := make([]candidate, 0, len(m.clearances)) + for key, state := range m.clearances { + lastUsedAt := state.lastUsedAt + if lastUsedAt.IsZero() { + lastUsedAt = state.refreshedAt + } + candidates = append(candidates, candidate{key: key, lastUsed: lastUsedAt}) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].lastUsed.Before(candidates[j].lastUsed) + }) + removeCount := min(clearanceCacheEvictionBatch, len(candidates)) + for _, entry := range candidates[:removeCount] { + delete(m.clearances, entry.key) + } +} + +func clearanceRefreshInterval(cfg ClearanceConfig) time.Duration { + if cfg.RefreshInterval > 0 { + return cfg.RefreshInterval + } + return 10 * time.Minute +} + +func clearanceFingerprint(cfg ClearanceConfig, proxyURL string) string { + value := strings.TrimSpace(cfg.FlareSolverrURL) + "\x00" + clearanceBindingFingerprint(cfg, proxyURL) + return fmt.Sprintf("%x", sha256.Sum256([]byte(value))) +} + +func clearanceBindingFingerprint(cfg ClearanceConfig, proxyURL string) string { + value := strings.TrimRight(strings.TrimSpace(cfg.TargetURL), "/") + "\x00" + strings.TrimSpace(proxyURL) + return fmt.Sprintf("%x", sha256.Sum256([]byte(value))) +} + +func (m *Manager) recordClearanceError(ctx context.Context, node domain.Node, persist bool) { + if node.ID == 0 || !persist { + return + } + if stateRepository, ok := m.repository.(egressStateRepository); ok { + if err := stateRepository.UpdateEgressNodeLastError(ctx, node.ID, "clearance refresh failed"); err == nil { + m.invalidateNodes(node.Scope) + return + } + } + latest, err := m.repository.GetEgressNode(ctx, node.ID) + if err != nil { + return + } + latest.LastError = "clearance refresh failed" + if _, err := m.repository.UpdateEgressNode(ctx, latest); err == nil { + m.invalidateNodes(latest.Scope) + } +} + +func (m *Manager) RefreshClearance(ctx context.Context, nodeID uint64) error { + if nodeID == 0 { + _, err, _ := m.clearanceLoads.Do("direct", func() (any, error) { + return m.refreshNode(ctx, domain.Node{Name: "direct", Scope: domain.ScopeWeb, Enabled: true}, "", "direct", false, true, true, time.Time{}) + }) + return err + } + node, err := m.repository.GetEgressNode(ctx, nodeID) + if err != nil { + return err + } + if !isGrokWebScope(node.Scope) { + return fmt.Errorf("出口节点 %q 不支持 Clearance 刷新", node.Name) + } + proxyURL, err := m.cipher.Decrypt(node.EncryptedProxyURL) + if err != nil { + return err + } + if strings.Contains(proxyURL, application.ProxyAccountPlaceholder) { + return fmt.Errorf("出口节点 %q 使用账号粘性代理,将在账号请求时按租约自动刷新 Clearance", node.Name) + } + proxyURL, err = application.NormalizeProxyURL(proxyURL) + if err != nil { + return err + } + key := clearanceCacheKey(node.ID, proxyURL, false) + _, err, _ = m.clearanceLoads.Do(key, func() (any, error) { + return m.refreshNode(ctx, node, proxyURL, key, true, true, true, time.Time{}) + }) + return err +} + +func (m *Manager) InvalidateClearance(nodeID uint64) { + m.clearanceMu.Lock() + prefix := "node:" + strconv.FormatUint(nodeID, 10) + if nodeID == 0 { + prefix = "direct" + } + for key, state := range m.clearances { + if key == prefix || strings.HasPrefix(key, prefix+":") { + state.invalid = true + state.used = true + m.clearances[key] = state + } + } + m.clearanceMu.Unlock() + m.clientMu.Lock() + stale := m.invalidateClientLocked(nodeID) + m.clientMu.Unlock() + closeRequestClients(stale) +} + +// ForgetClearance evicts runtime state after an administrator changes or +// removes a node. Unlike a 403 rejection, it does not mark the persisted +// last-known-good cookie as invalid; ensureClearance will still verify its +// binding before using it as a solver-failure fallback. +func (m *Manager) ForgetClearance(nodeID uint64) { + m.ForgetClearances([]uint64{nodeID}) +} + +// ForgetClearances evicts a batch of node-scoped runtime state with one cache +// scan and one lock acquisition. Administrative bulk updates can contain +// thousands of nodes, so repeating the global snapshot invalidation per ID +// would add avoidable lock contention and CPU work. +func (m *Manager) ForgetClearances(nodeIDs []uint64) { + ids := make(map[uint64]struct{}, len(nodeIDs)) + prefixes := make(map[string]struct{}, len(nodeIDs)) + for _, nodeID := range nodeIDs { + if _, exists := ids[nodeID]; exists { + continue + } + ids[nodeID] = struct{}{} + prefix := "node:" + strconv.FormatUint(nodeID, 10) + if nodeID == 0 { + prefix = "direct" + } + prefixes[prefix] = struct{}{} + } + if len(ids) == 0 { + return + } + m.clearanceMu.Lock() + m.nodeMu.Lock() + m.clientMu.Lock() + for key := range m.clearances { + prefix := key + if strings.HasPrefix(key, "node:") { + if separator := strings.IndexByte(key[len("node:"):], ':'); separator >= 0 { + prefix = key[:len("node:")+separator] + } + } else if strings.HasPrefix(key, "direct:") { + prefix = "direct" + } + if _, selected := prefixes[prefix]; selected { + delete(m.clearances, key) + } + } + // Node mutations are rare administration operations. Clearing the small + // one-second snapshots prevents a just-edited proxy from being used once + // more before its scope cache expires. + if m.nodeVersions == nil { + m.nodeVersions = make(map[domain.Scope]uint64) + } + for _, scope := range []domain.Scope{domain.ScopeBuild, domain.ScopeWeb, domain.ScopeConsole, domain.ScopeWebAsset} { + m.nodeVersions[scope]++ + } + clear(m.nodes) + clear(m.healthyNodes) + var stale []requestClient + for nodeID := range ids { + m.invalidateClientVersionLocked(nodeID) + } + for key, cached := range m.clients { + if _, selected := ids[key.nodeID]; !selected { + continue + } + delete(m.clients, key) + stale = append(stale, cached.client) + } + m.clientMu.Unlock() + m.nodeMu.Unlock() + m.clearanceMu.Unlock() + closeRequestClients(stale) +} + +func (m *Manager) invalidateClearanceKey(key string, client requestClient) { + m.clearanceMu.Lock() + state := m.clearances[key] + state.invalid = true + state.used = true + state.lastUsedAt = time.Now().UTC() + m.clearances[key] = state + m.clearanceMu.Unlock() + if client != nil { + client.CloseIdleConnections() + } +} + +func (m *Manager) RefreshDueClearances(ctx context.Context, force bool) error { + m.clearanceMu.Lock() + cfg := m.clearanceConfig + direct := m.clearances["direct"] + version := m.clearanceVersion + m.clearanceMu.Unlock() + if cfg.Mode != "flaresolverr" { + return nil + } + interval := clearanceRefreshInterval(cfg) + now := time.Now().UTC() + nodes, err := m.repository.ListEgressNodes(ctx, "", repository.SortQuery{}) + if err != nil { + return err + } + var refreshErrors []error + webNodeCount := 0 + for _, node := range nodes { + if !node.Enabled || !isGrokWebScope(node.Scope) { + continue + } + webNodeCount++ + proxyURL, decryptErr := m.cipher.Decrypt(node.EncryptedProxyURL) + if decryptErr != nil { + refreshErrors = append(refreshErrors, decryptErr) + continue + } + if strings.Contains(proxyURL, application.ProxyAccountPlaceholder) { + // Resin clearance is account/IP bound and has no safe node-wide value + // for a background task to solve or persist. + continue + } + proxyURL, normalizeErr := application.NormalizeProxyURL(proxyURL) + if normalizeErr != nil { + refreshErrors = append(refreshErrors, normalizeErr) + continue + } + m.clearanceMu.Lock() + key := clearanceCacheKey(node.ID, proxyURL, false) + state, known := m.clearances[key] + m.clearanceMu.Unlock() + fingerprint := clearanceFingerprint(cfg, proxyURL) + memoryFresh := known && !state.invalid && state.version == version && state.fingerprint == fingerprint && now.Sub(state.refreshedAt) < interval + persistedFresh := (!known || !state.invalid) && node.ClearanceRefreshedAt != nil && node.ClearanceFingerprint == fingerprint && now.Sub(*node.ClearanceRefreshedAt) < interval + if !force && (memoryFresh || persistedFresh) { + continue + } + refreshForce := force || (known && state.invalid) + refreshAfter := time.Time{} + if refreshForce && known && state.invalid { + refreshAfter = state.refreshedAt + } + _, refreshErr, _ := m.clearanceLoads.Do(key, func() (any, error) { + return m.refreshNode(ctx, node, proxyURL, key, true, refreshForce, false, refreshAfter) + }) + if refreshErr != nil { + refreshErrors = append(refreshErrors, refreshErr) + } + } + shouldUseDirect := direct.used || force && webNodeCount == 0 + if shouldUseDirect && (force || direct.invalid || direct.userAgent == "" || direct.version != version || now.Sub(direct.refreshedAt) >= interval) { + _, err, _ := m.clearanceLoads.Do("direct", func() (any, error) { + return m.refreshNode(ctx, domain.Node{Name: "direct", Scope: domain.ScopeWeb, Enabled: true}, "", "direct", false, force, false, time.Time{}) + }) + if err != nil { + refreshErrors = append(refreshErrors, err) + } + } + return errors.Join(refreshErrors...) +} + +func isGrokWebScope(scope domain.Scope) bool { + return scope == domain.ScopeWeb || scope == domain.ScopeWebAsset || scope == domain.ScopeConsole +} + +func (m *Manager) isStickyProxyNode(value domain.Node) bool { + if m == nil || m.cipher == nil || strings.TrimSpace(value.EncryptedProxyURL) == "" { + return false + } + proxyURL, err := m.cipher.Decrypt(value.EncryptedProxyURL) + return err == nil && strings.Contains(proxyURL, application.ProxyAccountPlaceholder) +} + +func (m *Manager) isProxyPoolNode(value domain.Node) bool { + return value.ProxyPool || m.isStickyProxyNode(value) +} + +func (m *Manager) invalidateClientLocked(nodeID uint64) []requestClient { + m.invalidateClientVersionLocked(nodeID) + var stale []requestClient + for key, cached := range m.clients { + if key.nodeID != nodeID { + continue + } + delete(m.clients, key) + stale = append(stale, cached.client) + } + return stale +} + +func (m *Manager) invalidateClientForScopeLocked(nodeID uint64, scope domain.Scope) []requestClient { + m.invalidateClientVersionLocked(nodeID) + if scope == domain.ScopeWebAsset { + scope = domain.ScopeWeb + } + var stale []requestClient + for key, cached := range m.clients { + if key.nodeID != nodeID || key.scope != scope { + continue + } + delete(m.clients, key) + stale = append(stale, cached.client) + } + return stale +} + +func BuildSSOCookie(token, cloudflareCookies string) string { + token = strings.TrimSpace(token) + if strings.HasPrefix(strings.ToLower(token), "sso=") { + token = strings.TrimSpace(token[len("sso="):]) + } + if value, _, found := strings.Cut(token, ";"); found { + token = strings.TrimSpace(value) + } + token = strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(token) + cookies := "sso=" + token + "; sso-rw=" + token + if sanitized := application.SanitizeCloudflareCookies(cloudflareCookies); sanitized != "" { + cookies += "; " + sanitized + } + return cookies +} diff --git a/backend/internal/infra/egress/manager_test.go b/backend/internal/infra/egress/manager_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f32ec3ffd2d9d0add58ab2821305196c8325caa0 --- /dev/null +++ b/backend/internal/infra/egress/manager_test.go @@ -0,0 +1,2228 @@ +package egress + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptrace" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + "github.com/bogdanfinn/tls-client/profiles" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type responseHeaderTimeoutError struct{} + +func (responseHeaderTimeoutError) Error() string { return "http2: timeout awaiting response headers" } +func (responseHeaderTimeoutError) Timeout() bool { return true } +func (responseHeaderTimeoutError) Temporary() bool { return true } + +func TestForgetClearancesEvictsSelectedNodesInOneBatch(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + first := &scriptedRequestClient{} + second := &scriptedRequestClient{} + untouched := &scriptedRequestClient{} + manager.clients[clientCacheKey{nodeID: 1, scope: domain.ScopeBuild}] = cachedClient{client: first} + manager.clients[clientCacheKey{nodeID: 2, scope: domain.ScopeWeb}] = cachedClient{client: second} + manager.clients[clientCacheKey{nodeID: 3, scope: domain.ScopeConsole}] = cachedClient{client: untouched} + manager.clearances["node:1"] = clearanceState{cookies: "one"} + manager.clearances["node:2:sticky"] = clearanceState{cookies: "two"} + manager.clearances["node:3"] = clearanceState{cookies: "three"} + manager.nodes[domain.ScopeBuild] = cachedNodeSnapshot{values: []domain.Node{{ID: 1}}} + manager.healthyNodes[1] = time.Now().UTC() + + manager.ForgetClearances([]uint64{1, 2, 1}) + + if _, exists := manager.clearances["node:1"]; exists { + t.Fatal("node 1 clearance was retained") + } + if _, exists := manager.clearances["node:2:sticky"]; exists { + t.Fatal("node 2 clearance was retained") + } + if _, exists := manager.clearances["node:3"]; !exists { + t.Fatal("unselected clearance was removed") + } + if len(manager.clients) != 1 || first.closedIdle != 1 || second.closedIdle != 1 || untouched.closedIdle != 0 { + t.Fatalf("clients=%d closed=(%d,%d,%d)", len(manager.clients), first.closedIdle, second.closedIdle, untouched.closedIdle) + } + if len(manager.nodes) != 0 || len(manager.healthyNodes) != 0 { + t.Fatalf("node snapshots were not invalidated: nodes=%d healthy=%d", len(manager.nodes), len(manager.healthyNodes)) + } + if manager.clientVersions[1] != 1 || manager.clientVersions[2] != 1 || manager.clientVersions[3] != 0 { + t.Fatalf("client versions = %#v", manager.clientVersions) + } +} + +func TestBuildResponseHeaderTimeoutHotUpdateRebuildsCachedClients(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + var observed []time.Duration + var clients []*scriptedRequestClient + manager.newBuildClient = func(_ string, timeout time.Duration) (requestClient, error) { + client := &scriptedRequestClient{} + observed = append(observed, timeout) + clients = append(clients, client) + return client, nil + } + if _, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false); err != nil { + t.Fatal(err) + } + manager.UpdateBuildResponseHeaderTimeout(7 * time.Minute) + if len(clients) != 1 || clients[0].closedIdle != 1 { + t.Fatalf("old clients=%d closed=%d", len(clients), clients[0].closedIdle) + } + if _, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false); err != nil { + t.Fatal(err) + } + if len(observed) != 2 || observed[0] != 5*time.Minute || observed[1] != 7*time.Minute { + t.Fatalf("observed timeouts = %v", observed) + } +} + +func TestResponseHeaderTimeoutDoesNotPenalizeEgress(t *testing.T) { + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "fixed", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, nil) + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, 0, responseHeaderTimeoutError{}) + if repository.updates != 0 || repository.node.Health != 1 || repository.node.CooldownUntil != nil { + t.Fatalf("response-header timeout changed node health: updates=%d node=%#v", repository.updates, repository.node) + } + key := clientCacheKey{nodeID: 0, scope: domain.ScopeBuild, fingerprint: "direct"} + manager.clients[key] = cachedClient{client: &scriptedRequestClient{}} + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 0, 0, responseHeaderTimeoutError{}) + if _, exists := manager.clients[key]; !exists { + t.Fatal("response-header timeout invalidated the direct Build client") + } +} + +func TestResponseHeaderTimeoutRetainsWebEgressFeedback(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + key := clientCacheKey{nodeID: 0, scope: domain.ScopeWeb, fingerprint: "direct"} + manager.clients[key] = cachedClient{client: &scriptedRequestClient{}} + manager.FeedbackForScope(context.Background(), domain.ScopeWeb, 0, 0, responseHeaderTimeoutError{}) + if _, exists := manager.clients[key]; exists { + t.Fatal("Build-specific timeout policy suppressed Web egress feedback") + } +} + +func TestCanceledRequestDoesNotPenalizeEgress(t *testing.T) { + tests := []struct { + name string + status int + transportErr error + }{ + {name: "canceled transport", transportErr: context.Canceled}, + {name: "wrapped canceled transport", transportErr: fmt.Errorf("request failed: %w", context.Canceled)}, + {name: "client closed status", status: clientClosedRequestStatus}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "fixed", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, nil) + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, test.status, test.transportErr) + if repository.updates != 0 || repository.node.Health != 1 || repository.node.FailureCount != 0 || repository.node.CooldownUntil != nil { + t.Fatalf("canceled request changed node health: updates=%d node=%#v", repository.updates, repository.node) + } + }) + } +} + +func TestCanceledRequestDoesNotInvalidateDirectClient(t *testing.T) { + tests := []struct { + name string + status int + transportErr error + }{ + {name: "canceled transport", transportErr: context.Canceled}, + {name: "client closed status", status: clientClosedRequestStatus}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + key := clientCacheKey{nodeID: 0, scope: domain.ScopeBuild, fingerprint: "direct"} + manager.clients[key] = cachedClient{client: &scriptedRequestClient{}} + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 0, test.status, test.transportErr) + if _, exists := manager.clients[key]; !exists { + t.Fatal("canceled request invalidated the direct Build client") + } + }) + } +} + +func TestProbeEgressNodeLogsSuccessWithoutProxyCredentials(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("socks5://user:secret@proxy.example:1080") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 42, Name: "web-us", Scope: domain.ScopeWeb, EncryptedProxyURL: encryptedProxy}} + manager := NewManager(repository, cipher) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(int, *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"ip":"203.0.113.8"}`))}, nil + }}, nil + } + var output bytes.Buffer + manager.SetLogger(slog.New(slog.NewTextHandler(&output, nil))) + + result, err := manager.probeEgressEndpoint(context.Background(), preparedEgressProbe{ + nodeID: 42, nodeName: "web-us", nodeScope: domain.ScopeWeb, proxyURL: "socks5://user:secret@proxy.example:1080", + }, "test", "ipv4", "https://probe.example/ip") + if err != nil || result.Status != domain.ProbeStatusHealthy || result.ExitIP != "203.0.113.8" { + t.Fatalf("result=%#v err=%v", result, err) + } + logOutput := output.String() + if !strings.Contains(logOutput, "egress_probe_succeeded") || !strings.Contains(logOutput, "node_id=42") || !strings.Contains(logOutput, "exit_ip=203.0.113.8") { + t.Fatalf("probe log missing fields: %s", logOutput) + } + if strings.Contains(logOutput, "user") || strings.Contains(logOutput, "secret") || strings.Contains(logOutput, "proxy.example") { + t.Fatalf("probe log leaked proxy credentials: %s", logOutput) + } +} + +func TestProbeEgressNodeLogsSanitizedFailureStage(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("socks5://user:secret@proxy.example:1080") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 7, Name: "web-jp", Scope: domain.ScopeWeb, EncryptedProxyURL: encryptedProxy}} + manager := NewManager(repository, cipher) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(request.Context()) + trace.ConnectStart("tcp", "proxy.example:1080") + trace.ConnectDone("tcp", "proxy.example:1080", errors.New("connection refused")) + return nil, errors.New("dial socks5://user:secret@proxy.example:1080 failed; token=abc123") + }}, nil + } + var output bytes.Buffer + manager.SetLogger(slog.New(slog.NewTextHandler(&output, nil))) + + result, err := manager.probeEgressEndpoint(context.Background(), preparedEgressProbe{ + nodeID: 7, nodeName: "web-jp", nodeScope: domain.ScopeWeb, proxyURL: "socks5://user:secret@proxy.example:1080", + }, "test", "ipv4", "https://probe.example/ip") + if err == nil || result.Error != "代理连接失败" || result.LatencyMS < 1 { + t.Fatalf("result=%#v err=%v", result, err) + } + logOutput := output.String() + if !strings.Contains(logOutput, "egress_probe_failed") || !strings.Contains(logOutput, "stage=connect") || !strings.Contains(logOutput, "token=[redacted]") || !strings.Contains(logOutput, "socks5://***:***@") { + t.Fatalf("probe failure log missing fields or redaction: %s", logOutput) + } + if strings.Contains(logOutput, "secret") || strings.Contains(logOutput, "abc123") { + t.Fatalf("probe failure log leaked credentials: %s", logOutput) + } +} + +func TestProbeEgressNodeClassifiesTLSFailure(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(request.Context()) + trace.ConnectStart("tcp", "proxy.example:1080") + trace.ConnectDone("tcp", "proxy.example:1080", nil) + trace.TLSHandshakeStart() + trace.TLSHandshakeDone(tls.ConnectionState{}, errors.New("certificate rejected")) + return nil, errors.New("TLS handshake failed") + }}, nil + } + var output bytes.Buffer + manager.SetLogger(slog.New(slog.NewTextHandler(&output, nil))) + + result, err := manager.probeEgressEndpoint(context.Background(), preparedEgressProbe{ + nodeID: 8, nodeName: "tls-failure", nodeScope: domain.ScopeBuild, proxyURL: "http://proxy.example:1080", + }, domain.ProbeProviderCloudflare, "ipv4", cloudflareIPv4ProbeEndpoint) + if err == nil || result.Error != "代理连接失败" || result.LatencyMS < 1 { + t.Fatalf("result=%#v err=%v", result, err) + } + logOutput := output.String() + if !strings.Contains(logOutput, "stage=tls") || strings.Contains(logOutput, "tls_ms=0") { + t.Fatalf("TLS failure log missing stage or duration: %s", logOutput) + } +} + +func TestProbeEgressNodeClassifiesFirstByteFailure(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(request.Context()) + trace.ConnectStart("tcp", "proxy.example:1080") + trace.ConnectDone("tcp", "proxy.example:1080", nil) + trace.TLSHandshakeStart() + trace.TLSHandshakeDone(tls.ConnectionState{}, nil) + return nil, errors.New("timeout awaiting response headers") + }}, nil + } + var output bytes.Buffer + manager.SetLogger(slog.New(slog.NewTextHandler(&output, nil))) + + result, err := manager.probeEgressEndpoint(context.Background(), preparedEgressProbe{ + nodeID: 9, nodeName: "first-byte-failure", nodeScope: domain.ScopeBuild, proxyURL: "http://proxy.example:1080", + }, domain.ProbeProviderCloudflare, "ipv4", cloudflareIPv4ProbeEndpoint) + if err == nil || result.Error != "代理连接失败" || result.LatencyMS < 1 { + t.Fatalf("result=%#v err=%v", result, err) + } + logOutput := output.String() + if !strings.Contains(logOutput, "stage=first_byte") || strings.Contains(logOutput, "first_byte_ms=0") { + t.Fatalf("first-byte failure log missing stage or duration: %s", logOutput) + } +} + +func TestProbeEgressNodeKeepsUntracedFailureAtExecuteRequest(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, _ *http.Request) (*http.Response, error) { + return nil, errors.New("request execution failed") + }}, nil + } + var output bytes.Buffer + manager.SetLogger(slog.New(slog.NewTextHandler(&output, nil))) + + _, err := manager.probeEgressEndpoint(context.Background(), preparedEgressProbe{ + nodeID: 10, nodeName: "untraced-failure", nodeScope: domain.ScopeBuild, proxyURL: "http://proxy.example:1080", + }, domain.ProbeProviderCloudflare, "ipv4", cloudflareIPv4ProbeEndpoint) + if err == nil { + t.Fatal("expected probe failure") + } + if logOutput := output.String(); !strings.Contains(logOutput, "stage=execute_request") { + t.Fatalf("untraced failure was misclassified: %s", logOutput) + } +} + +func TestProbeEgressNodeKeepsIPv4AndIPv6ResultsSeparate(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("http://proxy.example:8080") + if err != nil { + t.Fatal(err) + } + node := domain.Node{ID: 9, Name: "dual", Scope: domain.ScopeBuild, EncryptedProxyURL: encryptedProxy} + manager := NewManager(&mutableEgressRepository{node: node}, cipher) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + payload := `{"ip":"198.51.100.9"}` + if request.URL.Hostname() == "2606:4700:4700::1111" { + payload = `{"ip":"2001:db8::9"}` + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload))}, nil + }}, nil + } + + result, err := manager.ProbeEgressNode(context.Background(), node) + if err != nil { + t.Fatal(err) + } + if result.Status != domain.ProbeStatusHealthy || result.IPv4.ExitIP != "198.51.100.9" || result.IPv6.ExitIP != "2001:db8::9" { + t.Fatalf("dual-stack result = %#v", result) + } +} + +func TestProbeEgressNodeIsHealthyWhenOnlyIPv4Works(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("http://proxy.example:8080") + if err != nil { + t.Fatal(err) + } + node := domain.Node{ID: 10, Name: "v4-only", Scope: domain.ScopeBuild, EncryptedProxyURL: encryptedProxy} + manager := NewManager(&mutableEgressRepository{node: node}, cipher) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + if request.URL.Hostname() == "2606:4700:4700::1111" { + return nil, errors.New("network is unreachable") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"ip":"198.51.100.10"}`))}, nil + }}, nil + } + + result, err := manager.ProbeEgressNode(context.Background(), node) + if err != nil { + t.Fatal(err) + } + if result.Status != domain.ProbeStatusHealthy || result.IPv4.Status != domain.ProbeStatusHealthy || result.IPv6.Status != domain.ProbeStatusUnhealthy || result.IPv6.Error == "" { + t.Fatalf("IPv4-only result = %#v", result) + } +} + +func TestProbeEgressNodeUsesConfiguredCloudflareEndpoints(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("http://proxy.example:8080") + if err != nil { + t.Fatal(err) + } + config := domain.DefaultOperationsConfig() + config.ProbeProvider = domain.ProbeProviderCloudflare + node := domain.Node{ID: 11, Name: "cloudflare", Scope: domain.ScopeBuild, EncryptedProxyURL: encryptedProxy} + repository := fallbackEgressRepository{ + egressRepositoryTestStub: egressRepositoryTestStub{nodes: []domain.Node{node}}, + config: config, + } + manager := NewManager(repository, cipher) + var requested sync.Map + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + requested.Store(request.URL.String(), true) + exitIP := "198.51.100.11" + if request.URL.Hostname() == "2606:4700:4700::1111" { + exitIP = "2001:db8::11" + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("fl=test\nip=" + exitIP + "\ncolo=SJC\n"))}, nil + }}, nil + } + + result, err := manager.ProbeEgressNode(context.Background(), node) + if err != nil { + t.Fatal(err) + } + if result.Provider != domain.ProbeProviderCloudflare || result.IPv4.ExitIP != "198.51.100.11" || result.IPv6.ExitIP != "2001:db8::11" { + t.Fatalf("Cloudflare probe result = %#v", result) + } + for _, endpoint := range []string{cloudflareIPv4ProbeEndpoint, cloudflareIPv6ProbeEndpoint} { + if _, ok := requested.Load(endpoint); !ok { + t.Fatalf("Cloudflare endpoint %q was not requested", endpoint) + } + } +} + +func TestProbeEndpointsDefaultToCloudflare(t *testing.T) { + if ipv4, ipv6 := probeEndpoints(""); ipv4 != cloudflareIPv4ProbeEndpoint || ipv6 != cloudflareIPv6ProbeEndpoint { + t.Fatalf("default probe endpoints = %q, %q", ipv4, ipv6) + } +} + +func TestDecodeProbeIPSupportsJSONAndCloudflareTrace(t *testing.T) { + for name, body := range map[string]string{ + "json": `{"ip":"203.0.113.12"}`, + "cloudflare trace": "fl=test\nip=2001:db8::12\ncolo=SJC\n", + } { + t.Run(name, func(t *testing.T) { + value, err := decodeProbeIP([]byte(body)) + if err != nil || (value != "203.0.113.12" && value != "2001:db8::12") { + t.Fatalf("decodeProbeIP() = %q, %v", value, err) + } + }) + } +} + +func TestDirectFallbackRebuildsClientAfterAntiBotRejection(t *testing.T) { + manager := &Manager{clients: map[clientCacheKey]cachedClient{{nodeID: 0, scope: domain.ScopeWeb, fingerprint: "web"}: {}}} + manager.Feedback(context.Background(), 0, http.StatusForbidden, nil) + if len(manager.clients) != 0 { + t.Fatal("direct fallback client was not invalidated after anti-bot rejection") + } +} + +func TestClientCacheEvictsIdleEntriesAndEnforcesCapacity(t *testing.T) { + now := time.Now() + idleClient := &scriptedRequestClient{} + freshClient := &scriptedRequestClient{} + idleKey := clientCacheKey{nodeID: 1, scope: domain.ScopeWeb, fingerprint: "idle"} + freshKey := clientCacheKey{nodeID: 1, scope: domain.ScopeWeb, fingerprint: "fresh"} + manager := &Manager{clients: map[clientCacheKey]cachedClient{ + idleKey: {client: idleClient, lastUsed: now.Add(-clientCacheIdleTTL)}, + freshKey: {client: freshClient, lastUsed: now}, + }} + closeRequestClients(manager.cleanupClientCacheLocked(now)) + if _, exists := manager.clients[idleKey]; exists || idleClient.closedIdle != 1 { + t.Fatalf("idle client exists=%v closed=%d", exists, idleClient.closedIdle) + } + if _, exists := manager.clients[freshKey]; !exists || freshClient.closedIdle != 0 { + t.Fatalf("fresh client exists=%v closed=%d", exists, freshClient.closedIdle) + } + + oldestClient := &scriptedRequestClient{} + oldestKey := clientCacheKey{nodeID: 2, scope: domain.ScopeBuild, fingerprint: "oldest"} + manager.clients = make(map[clientCacheKey]cachedClient, maxCachedClients) + manager.clients[oldestKey] = cachedClient{client: oldestClient, lastUsed: now.Add(-time.Hour)} + for index := 1; index < maxCachedClients; index++ { + key := clientCacheKey{nodeID: uint64(index + 2), scope: domain.ScopeBuild, fingerprint: "cached"} + manager.clients[key] = cachedClient{lastUsed: now} + } + closeRequestClients(manager.ensureClientCacheCapacityLocked()) + if len(manager.clients) != maxCachedClients-1 || oldestClient.closedIdle != 1 { + t.Fatalf("cache size=%d oldest closed=%d", len(manager.clients), oldestClient.closedIdle) + } +} + +func TestClientVersionTombstonesRemainBounded(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + manager.clientMu.Lock() + for nodeID := uint64(1); nodeID <= maxClientVersionEntries+256; nodeID++ { + manager.invalidateClientVersionLocked(nodeID) + } + manager.clientMu.Unlock() + if len(manager.clientVersions) > maxClientVersionEntries { + t.Fatalf("client version tombstones = %d, limit = %d", len(manager.clientVersions), maxClientVersionEntries) + } + if manager.clientGeneration == 0 { + t.Fatal("bounded version reset did not advance the invalidation generation") + } +} + +func TestClientCreationDoesNotHoldManagerLock(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + started := make(chan struct{}) + release := make(chan struct{}) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + close(started) + <-release + return &scriptedRequestClient{}, nil + } + result := make(chan error, 1) + go func() { + _, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false) + result <- err + }() + <-started + + selected := make(chan struct{}) + go func() { + manager.selectNode([]domain.Node{{ID: 1, Health: 1}}, "") + close(selected) + }() + select { + case <-selected: + case <-time.After(time.Second): + t.Fatal("client creation held the manager lock") + } + close(release) + if err := <-result; err != nil { + t.Fatal(err) + } +} + +func TestSelectNodeUsesAtomicInflightCounters(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + nodes := []domain.Node{{ID: 1, Health: 1}, {ID: 2, Health: 1}} + manager.incrementInflight(1) + if selected := manager.selectNode(nodes, ""); selected.ID != 2 { + t.Fatalf("selected node = %d, want 2", selected.ID) + } + manager.decrementInflight(1) + if selected := manager.selectNode(nodes, ""); selected.ID != 1 { + t.Fatalf("selected node after release = %d, want stable node 1", selected.ID) + } +} + +func TestInflightCountersRemainBalancedConcurrently(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + const workers = 64 + const iterations = 1000 + var wait sync.WaitGroup + for range workers { + wait.Add(1) + go func() { + defer wait.Done() + for range iterations { + manager.incrementInflight(1) + manager.decrementInflight(1) + } + }() + } + wait.Wait() + if value := manager.inflightCount(1); value != 0 { + t.Fatalf("inflight count = %d, want 0", value) + } +} + +func TestClientCacheCoalescesLastUsedWrites(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + client := &scriptedRequestClient{} + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { return client, nil } + if _, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false); err != nil { + t.Fatal(err) + } + + base := time.Now().UTC() + var key clientCacheKey + manager.clientMu.Lock() + for candidate, value := range manager.clients { + key = candidate + value.lastUsed = base + manager.clients[candidate] = value + } + manager.lastClientCleanup = base + manager.clientMu.Unlock() + + if _, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false); err != nil { + t.Fatal(err) + } + manager.clientMu.RLock() + untouched := manager.clients[key].lastUsed + manager.clientMu.RUnlock() + if !untouched.Equal(base) { + t.Fatalf("fresh cache hit rewrote lastUsed: got %s want %s", untouched, base) + } + + stale := base.Add(-clientCacheTouchInterval - time.Second) + manager.clientMu.Lock() + value := manager.clients[key] + value.lastUsed = stale + manager.clients[key] = value + manager.lastClientCleanup = time.Now().UTC() + manager.clientMu.Unlock() + if _, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false); err != nil { + t.Fatal(err) + } + manager.clientMu.RLock() + refreshed := manager.clients[key].lastUsed + manager.clientMu.RUnlock() + if !refreshed.After(stale) { + t.Fatalf("stale cache hit did not refresh lastUsed: got %s, stale %s", refreshed, stale) + } +} + +func TestClientCreationDiscardsInvalidatedResult(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{}, nil) + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + first := &scriptedRequestClient{} + second := &scriptedRequestClient{} + var calls atomic.Int32 + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + if calls.Add(1) == 1 { + close(firstStarted) + <-releaseFirst + return first, nil + } + return second, nil + } + result := make(chan cachedClient, 1) + errorsCh := make(chan error, 1) + go func() { + value, err := manager.clientFor(1, domain.ScopeBuild, "", "", "", false) + if err != nil { + errorsCh <- err + return + } + result <- value + }() + <-firstStarted + manager.InvalidateClearance(1) + close(releaseFirst) + select { + case err := <-errorsCh: + t.Fatal(err) + case value := <-result: + if value.client != second || calls.Load() != 2 || first.closedIdle != 1 { + t.Fatalf("client result=%#v calls=%d firstClosed=%d", value, calls.Load(), first.closedIdle) + } + case <-time.After(time.Second): + t.Fatal("client creation did not recover after invalidation") + } +} + +func TestClearanceCacheEvictsIdleEntriesAndEnforcesCapacity(t *testing.T) { + now := time.Now().UTC() + manager := &Manager{clearances: map[string]clearanceState{ + "idle": {cookies: "cf_clearance=idle", lastUsedAt: now.Add(-clearanceCacheMinIdleTTL)}, + "fresh": {cookies: "cf_clearance=fresh", lastUsedAt: now}, + }} + manager.cleanupClearanceCacheLocked(now, time.Minute) + if _, exists := manager.clearances["idle"]; exists { + t.Fatal("idle Clearance entry was not evicted") + } + if _, exists := manager.clearances["fresh"]; !exists { + t.Fatal("fresh Clearance entry was evicted") + } + + manager.clearances = make(map[string]clearanceState, maxCachedClearances) + manager.clearances["oldest"] = clearanceState{lastUsedAt: now.Add(-time.Hour)} + for index := 1; index < maxCachedClearances; index++ { + manager.clearances[fmt.Sprintf("cached-%d", index)] = clearanceState{lastUsedAt: now} + } + manager.ensureClearanceCacheCapacityLocked() + if len(manager.clearances) != maxCachedClearances-clearanceCacheEvictionBatch { + t.Fatalf("Clearance cache size = %d", len(manager.clearances)) + } + if _, exists := manager.clearances["oldest"]; exists { + t.Fatal("oldest Clearance entry was not evicted") + } +} + +func TestDirectBuildAndWebClientsDoNotEvictEachOther(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{}, cipher) + buildFirst, err := manager.Acquire(context.Background(), domain.ScopeBuild, "") + if err != nil { + t.Fatal(err) + } + defer buildFirst.Release() + web, err := manager.Acquire(context.Background(), domain.ScopeWeb, "") + if err != nil { + t.Fatal(err) + } + defer web.Release() + buildSecond, err := manager.Acquire(context.Background(), domain.ScopeBuild, "") + if err != nil { + t.Fatal(err) + } + defer buildSecond.Release() + + if buildFirst.client != buildSecond.client { + t.Fatal("Web direct traffic evicted the reusable Build connection pool") + } + if buildFirst.client == web.client || len(manager.clients) != 2 { + t.Fatalf("direct clients were not isolated: build=%T web=%T cached=%d", buildFirst.client, web.client, len(manager.clients)) + } + manager.FeedbackForScope(context.Background(), domain.ScopeWeb, 0, http.StatusForbidden, nil) + buildAfterWebFailure, err := manager.Acquire(context.Background(), domain.ScopeBuild, "") + if err != nil { + t.Fatal(err) + } + defer buildAfterWebFailure.Release() + if buildAfterWebFailure.client != buildFirst.client || len(manager.clients) != 1 { + t.Fatalf("Web failure evicted Build direct client: reused=%v cached=%d", buildAfterWebFailure.client == buildFirst.client, len(manager.clients)) + } +} + +func TestBrowserRequestLeavesHeaderOrderingToTLSProfile(t *testing.T) { + request, err := http.NewRequest(http.MethodPost, "https://grok.com/rest/app-chat/conversations/new", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("User-Agent", DefaultUserAgent) + request.Header.Set("Accept", "*/*") + converted, err := toFHTTPRequest(request) + if err != nil { + t.Fatal(err) + } + if len(converted.Header[fhttp.HeaderOrderKey]) != 0 || len(converted.Header[fhttp.PHeaderOrderKey]) != 0 { + t.Fatalf("manual header order=%#v pseudo=%#v", converted.Header[fhttp.HeaderOrderKey], converted.Header[fhttp.PHeaderOrderKey]) + } +} + +func TestBrowserProfileTracksFlareSolverrChromiumUserAgent(t *testing.T) { + if actual := browserProfile("Mozilla/5.0 Chrome/144.0.0.0 Safari/537.36").GetClientHelloStr(); actual != profiles.Chrome_144.GetClientHelloStr() { + t.Fatalf("Chrome 144 selected %q", actual) + } + if actual := browserProfile("Mozilla/5.0 Chrome/145.0.0.0 Safari/537.36").GetClientHelloStr(); actual != profiles.Chrome_146.GetClientHelloStr() { + t.Fatalf("Chrome 145 did not select nearest profile: %q", actual) + } +} + +func TestConfiguredCoolingAppNodesNeverFallBackToDirect(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + until := time.Now().Add(time.Minute) + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "proxy", Scope: domain.ScopeWeb, Enabled: true, CooldownUntil: &until, + }}}, cipher) + if _, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account"); err == nil { + t.Fatal("cooling configured node unexpectedly fell back to direct") + } +} + +func TestUnavailablePrimaryUsesConfiguredDirectFallback(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + until := time.Now().Add(time.Minute) + config := domain.DefaultOperationsConfig() + config.Fallbacks[domain.ScopeWeb] = domain.FallbackConfig{Mode: domain.FallbackModeDirect} + manager := NewManager(fallbackEgressRepository{ + egressRepositoryTestStub: egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "cooling", Scope: domain.ScopeWeb, Enabled: true, CooldownUntil: &until, + }}}, + config: config, + }, cipher) + ctx, trace := WithTrace(context.Background()) + lease, err := manager.Acquire(ctx, domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.NodeID != 0 || lease.NodeName != "direct" || lease.ProxyURL != "" { + t.Fatalf("direct fallback lease = %#v", lease) + } + selection, ok := trace.Selection(domain.ScopeWeb) + if !ok || selection.NodeID != 0 || selection.NodeName != "direct" || selection.Proxied { + t.Fatalf("direct fallback selection = %#v, ok=%v", selection, ok) + } +} + +func TestUnavailableBuildPrimaryUsesConfiguredDirectFallbackTransport(t *testing.T) { + until := time.Now().Add(time.Minute) + config := domain.DefaultOperationsConfig() + config.Fallbacks[domain.ScopeBuild] = domain.FallbackConfig{Mode: domain.FallbackModeDirect} + manager := NewManager(fallbackEgressRepository{ + egressRepositoryTestStub: egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "cooling", Scope: domain.ScopeBuild, Enabled: true, CooldownUntil: &until, + }}}, + config: config, + }, nil) + ctx, trace := WithTrace(context.Background()) + lease, configured, err := manager.AcquireIfConfigured(ctx, domain.ScopeBuild, "account") + if err != nil || configured || lease != nil { + t.Fatalf("direct fallback transport lease=%#v configured=%v err=%v", lease, configured, err) + } + selection, ok := trace.Selection(domain.ScopeBuild) + if !ok || selection.NodeID != 0 || selection.NodeName != "direct" || selection.Proxied { + t.Fatalf("direct fallback selection = %#v, ok=%v", selection, ok) + } +} + +func TestFixedFallbackIsReservedFromPrimarySelection(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + primaryURL, err := cipher.Encrypt("http://primary.example:8080") + if err != nil { + t.Fatal(err) + } + fallbackURL, err := cipher.Encrypt("http://fallback.example:8080") + if err != nil { + t.Fatal(err) + } + config := domain.DefaultOperationsConfig() + config.Fallbacks[domain.ScopeBuild] = domain.FallbackConfig{Mode: domain.FallbackModeFixed, NodeID: 2} + manager := NewManager(fallbackEgressRepository{ + egressRepositoryTestStub: egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 1, Name: "primary", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: primaryURL}, + {ID: 2, Name: "fallback", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: fallbackURL}, + }}, + config: config, + }, cipher) + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "reserved-fallback") + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if !configured || lease.NodeID != 1 { + t.Fatalf("primary lease=%#v configured=%v", lease, configured) + } +} + +func TestDisabledConfiguredNodesAllowDirectFallback(t *testing.T) { + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "disabled-proxy", Scope: domain.ScopeBuild, Enabled: false, Health: 1, + }}}, nil) + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil || configured || lease != nil { + t.Fatalf("disabled proxy fallback: lease=%#v configured=%v err=%v", lease, configured, err) + } +} + +func TestAcquireIfConfiguredDoesNotChangeBuildDirectTransport(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{}, cipher) + ctx, trace := WithTrace(context.Background()) + lease, configured, err := manager.AcquireIfConfigured(ctx, domain.ScopeBuild, "") + if err != nil || configured || lease != nil { + t.Fatalf("lease=%#v configured=%v err=%v", lease, configured, err) + } + selection, ok := trace.Selection(domain.ScopeBuild) + if !ok || selection.NodeID != 0 || selection.NodeName != "direct" || selection.Proxied { + t.Fatalf("direct selection = %#v, ok=%v", selection, ok) + } +} + +func TestTraceRecordsConfiguredProxyWithoutCredentials(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("socks5h://secret:password@127.0.0.1:1080") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 42, Name: "primary-proxy", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: encryptedProxy, + }}}, cipher) + ctx, trace := WithTrace(context.Background()) + lease, configured, err := manager.AcquireIfConfigured(ctx, domain.ScopeBuild, "") + if err != nil || !configured || lease == nil { + t.Fatalf("lease=%#v configured=%v err=%v", lease, configured, err) + } + defer lease.Release() + selection, ok := trace.Selection(domain.ScopeBuild) + if !ok || selection.NodeID != 42 || selection.NodeName != "primary-proxy" || !selection.Proxied { + t.Fatalf("proxy selection = %#v, ok=%v", selection, ok) + } +} + +func TestConfiguredBuildNodeDoesNotOverrideProviderUserAgent(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("socks5h://warp:1080") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "build", Scope: domain.ScopeBuild, Enabled: true, Health: 1, UserAgent: "legacy-build-agent", EncryptedProxyURL: encryptedProxy, + }}}, cipher) + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil { + t.Fatal(err) + } + if !configured || lease == nil { + t.Fatal("configured build node did not produce a lease") + } + defer lease.Release() + if lease.UserAgent != "" { + t.Fatalf("build lease userAgent = %q", lease.UserAgent) + } + if _, ok := lease.client.(*http.Client); !ok || lease.browser != nil || lease.Scope != domain.ScopeBuild { + t.Fatalf("build lease client=%T browser=%p scope=%q", lease.client, lease.browser, lease.Scope) + } + if _, _, err := lease.DialWebSocket(context.Background(), "wss://example.com", nil, time.Second); err == nil { + t.Fatal("build lease unexpectedly exposed browser WebSocket") + } +} + +func TestConfiguredWebNodeKeepsChromeBrowserTransport(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, + }}}, cipher) + lease, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if _, ok := lease.client.(*browserClient); !ok || lease.browser == nil || lease.Scope != domain.ScopeWeb { + t.Fatalf("web lease client=%T browser=%p scope=%q", lease.client, lease.browser, lease.Scope) + } +} + +func TestAcquireCredentialRendersResinAccountAndOverridesNodeCookie(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + nodeCookie, err := cipher.Encrypt("cf_clearance=node") + if err != nil { + t.Fatal(err) + } + accountCookie, err := cipher.Encrypt("cf_clearance=account") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "resin", Scope: domain.ScopeWeb, Enabled: true, Health: 1, + EncryptedProxyURL: proxyURL, EncryptedCloudflareCookie: nodeCookie, + }}}, cipher) + first, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderWeb, EncryptedCloudflareCookie: accountCookie, + }) + if err != nil { + t.Fatal(err) + } + defer first.Release() + if first.ProxyURL != "socks5h://Default.grok_web_42:token@resin:2260" { + t.Fatalf("first proxy URL = %q", first.ProxyURL) + } + if first.CFCookies != "cf_clearance=account" || !first.sticky { + t.Fatalf("first lease cookie=%q sticky=%v", first.CFCookies, first.sticky) + } + second, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 43, Provider: accountdomain.ProviderWeb, + }) + if err != nil { + t.Fatal(err) + } + defer second.Release() + if second.ProxyURL != "socks5h://Default.grok_web_43:token@resin:2260" { + t.Fatalf("second proxy URL = %q", second.ProxyURL) + } + if second.CFCookies != "cf_clearance=node" { + t.Fatalf("second lease cookie = %q", second.CFCookies) + } + if first.client == second.client { + t.Fatal("different Resin accounts unexpectedly shared one connection pool") + } + if len(manager.clients) != 2 { + t.Fatalf("cached Resin account pools = %d, want 2", len(manager.clients)) + } +} + +func TestAcquireCredentialUsesExplicitBoundNode(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("http://bound-node.example:8080") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 1, Name: "pool-node", Scope: domain.ScopeBuild, Enabled: true, Health: 1}, + {ID: 2, Name: "bound-node", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + }}, cipher) + lease, err := manager.AcquireCredential(context.Background(), domain.ScopeBuild, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderBuild, EgressNodeID: 2, + }) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.NodeID != 2 || lease.NodeName != "bound-node" { + t.Fatalf("bound lease = node %d (%q)", lease.NodeID, lease.NodeName) + } +} + +func TestAcquireCredentialDoesNotRouteDirectWhenBoundNodeHasNoProxy(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 2, Name: "empty-node", Scope: domain.ScopeBuild, Enabled: true, Health: 1}, + }}, cipher) + _, err = manager.AcquireCredential(context.Background(), domain.ScopeBuild, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderBuild, EgressNodeID: 2, + }) + if err == nil || !strings.Contains(err.Error(), "未配置代理地址") { + t.Fatalf("bound node without proxy error = %v", err) + } +} + +func TestAcquireCredentialDoesNotFallbackWhenBoundNodeIsUnavailable(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 1, Name: "pool-node", Scope: domain.ScopeBuild, Enabled: true, Health: 1}, + {ID: 2, Name: "disabled-node", Scope: domain.ScopeBuild, Enabled: false, Health: 1}, + }}, cipher) + _, err = manager.AcquireCredential(context.Background(), domain.ScopeBuild, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderBuild, EgressNodeID: 2, + }) + if err == nil || !strings.Contains(err.Error(), "已禁用") { + t.Fatalf("bound unavailable error = %v", err) + } +} + +func TestAcquireCredentialUsesConfiguredFixedFallbackWhenBoundNodeIsUnavailable(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("http://fixed-fallback.example:8080") + if err != nil { + t.Fatal(err) + } + config := domain.DefaultOperationsConfig() + config.Fallbacks[domain.ScopeBuild] = domain.FallbackConfig{Mode: domain.FallbackModeFixed, NodeID: 2} + manager := NewManager(fallbackEgressRepository{ + egressRepositoryTestStub: egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 1, Name: "disabled", Scope: domain.ScopeBuild, Enabled: false}, + {ID: 2, Name: "fixed-fallback", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + }}, + config: config, + }, cipher) + lease, err := manager.AcquireCredential(context.Background(), domain.ScopeBuild, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderBuild, EgressNodeID: 1, + }) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.NodeID != 2 || lease.NodeName != "fixed-fallback" || lease.ProxyURL != "http://fixed-fallback.example:8080" { + t.Fatalf("fixed fallback lease = %#v", lease) + } +} + +func TestFlareSolverrModeIgnoresCredentialCookie(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + credentialCookie, err := cipher.Encrypt("cf_clearance=imported-account") + if err != nil { + t.Fatal(err) + } + solver := &clearanceSolverStub{} + repository := &mutableEgressRepository{node: domain.Node{ + ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, + }} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + + lease, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderWeb, EncryptedCloudflareCookie: credentialCookie, + }) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if solver.calls != 1 || lease.CFCookies != "cf_clearance=value-1" { + t.Fatalf("solver calls=%d lease cookie=%q", solver.calls, lease.CFCookies) + } +} + +func TestFlareSolverrModeRecoversFromDamagedStoredCookies(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + solver := &clearanceSolverStub{} + repository := &mutableEgressRepository{node: domain.Node{ + ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, + EncryptedCloudflareCookie: "damaged-node-ciphertext", + }} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + + lease, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderWeb, EncryptedCloudflareCookie: "damaged-account-ciphertext", + }) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if solver.calls != 1 || lease.CFCookies != "cf_clearance=value-1" { + t.Fatalf("solver calls=%d lease cookie=%q", solver.calls, lease.CFCookies) + } +} + +func TestLinkedProvidersSharePersistedResinIdentity(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + firstToken, _ := cipher.Encrypt("first-sso") + rotatedToken, _ := cipher.Encrypt("rotated-sso") + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + {ID: 2, Name: "build", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + }}, cipher) + const identity = "sso_persisted_identity" + web, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 11, Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: firstToken, EgressIdentity: identity, + }) + if err != nil { + t.Fatal(err) + } + defer web.Release() + console, err := manager.AcquireCredential(context.Background(), domain.ScopeConsole, accountdomain.Credential{ + ID: 22, Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: rotatedToken, EgressIdentity: identity, + }) + if err != nil { + t.Fatal(err) + } + defer console.Release() + buildCtx := WithCredential(context.Background(), accountdomain.Credential{ID: 33, Provider: accountdomain.ProviderBuild, EgressIdentity: identity}) + build, configured, err := manager.AcquireIfConfigured(buildCtx, domain.ScopeBuild, AccountFromContext(buildCtx)) + if err != nil || !configured { + t.Fatalf("build configured=%v err=%v", configured, err) + } + defer build.Release() + for name, proxy := range map[string]string{"web": web.ProxyURL, "console": console.ProxyURL, "build": build.ProxyURL} { + if !strings.Contains(proxy, "Default."+identity+":") { + t.Fatalf("%s proxy = %q", name, proxy) + } + } +} + +func TestConsoleFallsBackToWebAndSharesSSOResinIdentity(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + token := "shared-web-console-sso" + encryptedToken, err := cipher.Encrypt(token) + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 7, Name: "shared-web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, + EncryptedProxyURL: proxyURL, + }}}, cipher) + web, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 11, Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: encryptedToken, + }) + if err != nil { + t.Fatal(err) + } + defer web.Release() + console, err := manager.AcquireCredential(context.Background(), domain.ScopeConsole, accountdomain.Credential{ + ID: 22, Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: encryptedToken, + }) + if err != nil { + t.Fatal(err) + } + defer console.Release() + wantAccount := "sso_" + security.HashToken(token)[:32] + if web.NodeID != 7 || console.NodeID != 7 { + t.Fatalf("nodes web=%d console=%d, want shared Web node", web.NodeID, console.NodeID) + } + if !strings.Contains(web.ProxyURL, "Default."+wantAccount+":") || web.ProxyURL != console.ProxyURL { + t.Fatalf("proxy identities web=%q console=%q", web.ProxyURL, console.ProxyURL) + } +} + +func TestBuildForbiddenDoesNotPoisonEgressNode(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "build", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, cipher) + lease, _, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil { + t.Fatal(err) + } + lease.Release() + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, http.StatusForbidden, nil) + if repository.updates != 0 || repository.node.Health != 1 || repository.node.LastError != "" { + t.Fatalf("build 403 poisoned node: updates=%d node=%#v", repository.updates, repository.node) + } + if !managerHasClientForNode(manager, 1) { + t.Fatal("build client was invalidated by an ambiguous 403") + } +} + +func TestUpstreamServerErrorDoesNotPoisonFixedEgressNode(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "build", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, cipher) + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, http.StatusBadGateway, nil) + if repository.updates != 0 || repository.node.Health != 1 || repository.node.CooldownUntil != nil { + t.Fatalf("upstream 502 poisoned fixed node: updates=%d node=%#v", repository.updates, repository.node) + } +} + +func TestHealthySuccessFeedbackSkipsRepositoryReadAndWrite(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "healthy", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, cipher) + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil || !configured || lease == nil { + t.Fatalf("lease = %#v, configured = %v, err = %v", lease, configured, err) + } + lease.Release() + + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, http.StatusOK, nil) + if repository.reads != 0 || repository.updates != 0 { + t.Fatalf("healthy success performed repository I/O: reads=%d updates=%d", repository.reads, repository.updates) + } +} + +func TestRecoveringSuccessFeedbackPersistsHealthTransition(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "recovering", Scope: domain.ScopeBuild, Enabled: true, Health: 0.8, FailureCount: 1, LastError: "transport error"}} + manager := NewManager(repository, cipher) + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil || !configured || lease == nil { + t.Fatalf("lease = %#v, configured = %v, err = %v", lease, configured, err) + } + lease.Release() + + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, http.StatusOK, nil) + if repository.reads != 1 || repository.updates != 1 { + t.Fatalf("recovery I/O: reads=%d updates=%d", repository.reads, repository.updates) + } + if repository.node.Health != 0.9 || repository.node.FailureCount != 0 || repository.node.LastError != "" { + t.Fatalf("recovered node = %#v", repository.node) + } +} + +func TestExpiredHealthySnapshotRechecksRepositoryOnSuccess(t *testing.T) { + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "healthy", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, nil) + if _, err := manager.listNodes(context.Background(), domain.ScopeBuild, time.Now().UTC()); err != nil { + t.Fatal(err) + } + manager.nodeMu.Lock() + manager.healthyNodes[1] = time.Now().UTC().Add(-time.Second) + manager.nodeMu.Unlock() + repository.node = domain.Node{ID: 1, Name: "recovering", Scope: domain.ScopeBuild, Enabled: true, Health: 0.8, FailureCount: 1, LastError: "transport error"} + + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, http.StatusOK, nil) + if repository.reads != 1 || repository.updates != 1 { + t.Fatalf("expired health state did not recheck repository: reads=%d updates=%d", repository.reads, repository.updates) + } +} + +func TestNodeSnapshotReplacementRemovesRetiredHealthState(t *testing.T) { + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "first", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, nil) + now := time.Now().UTC() + if _, err := manager.listNodes(context.Background(), domain.ScopeBuild, now); err != nil { + t.Fatal(err) + } + if !manager.cachedNodeIsHealthy(1) { + t.Fatal("initial node was not cached as healthy") + } + repository.node = domain.Node{ID: 2, Name: "replacement", Scope: domain.ScopeBuild, Enabled: true, Health: 1} + manager.nodeMu.Lock() + snapshot := manager.nodes[domain.ScopeBuild] + snapshot.expiresAt = now.Add(-time.Second) + manager.nodes[domain.ScopeBuild] = snapshot + manager.nodeMu.Unlock() + if _, err := manager.listNodes(context.Background(), domain.ScopeBuild, now); err != nil { + t.Fatal(err) + } + if manager.cachedNodeIsHealthy(1) { + t.Fatal("retired node retained healthy cache state") + } + if !manager.cachedNodeIsHealthy(2) { + t.Fatal("replacement node was not cached as healthy") + } +} + +func TestConcurrentFailurePreventsStaleHealthySnapshotInstall(t *testing.T) { + repository := &blockingEgressRepository{ + node: domain.Node{ID: 1, Name: "healthy", Scope: domain.ScopeBuild, Enabled: true, Health: 1}, + listStarted: make(chan struct{}), + listRelease: make(chan struct{}), + } + manager := NewManager(repository, nil) + loaded := make(chan []domain.Node, 1) + loadErrors := make(chan error, 1) + go func() { + values, err := manager.listNodes(context.Background(), domain.ScopeBuild, time.Now().UTC()) + if err != nil { + loadErrors <- err + return + } + loaded <- values + }() + <-repository.listStarted + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, 0, errors.New("proxy timeout")) + close(repository.listRelease) + select { + case err := <-loadErrors: + t.Fatal(err) + case values := <-loaded: + if len(values) != 1 || values[0].FailureCount != 1 || values[0].CooldownUntil == nil { + t.Fatalf("stale list result was returned after invalidation: %#v", values) + } + case <-time.After(time.Second): + t.Fatal("node list did not complete") + } + if manager.cachedNodeIsHealthy(1) { + t.Fatal("stale list result restored healthy cache state after failure feedback") + } + values, err := manager.listNodes(context.Background(), domain.ScopeBuild, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if len(values) != 1 || values[0].FailureCount != 1 || values[0].CooldownUntil == nil { + t.Fatalf("reloaded node = %#v", values) + } +} + +func TestForgetClearancePreventsStaleNodeSnapshotInstall(t *testing.T) { + repository := &blockingEgressRepository{ + node: domain.Node{ID: 1, Name: "before", Scope: domain.ScopeBuild, Enabled: true, Health: 1}, + listStarted: make(chan struct{}), + listRelease: make(chan struct{}), + } + manager := NewManager(repository, nil) + loaded := make(chan []domain.Node, 1) + loadErrors := make(chan error, 1) + go func() { + values, err := manager.listNodes(context.Background(), domain.ScopeBuild, time.Now().UTC()) + if err != nil { + loadErrors <- err + return + } + loaded <- values + }() + <-repository.listStarted + if _, err := repository.UpdateEgressNode(context.Background(), domain.Node{ID: 2, Name: "after", Scope: domain.ScopeBuild, Enabled: true, Health: 1}); err != nil { + t.Fatal(err) + } + manager.ForgetClearance(1) + close(repository.listRelease) + + select { + case err := <-loadErrors: + t.Fatal(err) + case values := <-loaded: + if len(values) != 1 || values[0].Name != "after" { + t.Fatalf("stale node snapshot returned after administrative invalidation: %#v", values) + } + case <-time.After(time.Second): + t.Fatal("node list did not complete") + } + + manager.nodeMu.RLock() + snapshot := manager.nodes[domain.ScopeBuild] + _, staleHealthy := manager.healthyNodes[1] + _, currentHealthy := manager.healthyNodes[2] + manager.nodeMu.RUnlock() + if len(snapshot.values) != 1 || snapshot.values[0].Name != "after" || staleHealthy || !currentHealthy { + t.Fatalf("runtime node state was not replaced cleanly: snapshot=%#v staleHealthy=%v currentHealthy=%v", snapshot, staleHealthy, currentHealthy) + } +} + +func TestProxyPoolTransportFailureDoesNotCreateGlobalCooldown(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + cooldown := time.Now().UTC().Add(time.Minute) + repository := &mutableEgressRepository{node: domain.Node{ + ID: 1, Name: "pool", Scope: domain.ScopeBuild, Enabled: true, ProxyPool: true, + Health: 0.2, FailureCount: 3, CooldownUntil: &cooldown, LastError: "old failure", + }} + manager := NewManager(repository, cipher) + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil || !configured || lease == nil { + t.Fatalf("pool lease blocked by stale cooldown: configured=%v lease=%#v err=%v", configured, lease, err) + } + if !lease.freshTunnel { + t.Fatal("explicit proxy-pool lease must request a fresh Build tunnel") + } + lease.Release() + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, 0, errors.New("connection refused")) + if repository.updates != 0 || repository.node.FailureCount != 3 || repository.node.CooldownUntil == nil { + t.Fatalf("pool transport failure changed global state: updates=%d node=%#v", repository.updates, repository.node) + } + if !managerHasClientForNode(manager, 1) { + t.Fatal("pool transport failure evicted the shared node client cache") + } +} + +func TestFixedProxyTransportFailureStillCreatesCooldown(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "fixed", Scope: domain.ScopeBuild, Enabled: true, Health: 1}} + manager := NewManager(repository, cipher) + manager.FeedbackForScope(context.Background(), domain.ScopeBuild, 1, 0, errors.New("connection refused")) + if repository.updates != 1 || repository.node.FailureCount != 1 || repository.node.CooldownUntil == nil || repository.node.LastError != "transport error" { + t.Fatalf("fixed transport failure did not create cooldown: updates=%d node=%#v", repository.updates, repository.node) + } +} + +func TestAccountTemplateIsAnEffectiveProxyPool(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("socks5h://Default.{account}:token@resin.example:2260") + if err != nil { + t.Fatal(err) + } + cooldown := time.Now().UTC().Add(time.Minute) + repository := &mutableEgressRepository{node: domain.Node{ + ID: 1, Name: "resin", Scope: domain.ScopeBuild, Enabled: true, Health: 0.2, + EncryptedProxyURL: encryptedProxy, CooldownUntil: &cooldown, + }} + manager := NewManager(repository, cipher) + lease, configured, err := manager.AcquireIfConfigured(WithAccountIdentity(context.Background(), "account-1"), domain.ScopeBuild, "") + if err != nil || !configured || lease == nil { + t.Fatalf("account-template lease blocked by stale cooldown: configured=%v lease=%#v err=%v", configured, lease, err) + } + defer lease.Release() + if !lease.sticky || !lease.proxyPool || lease.freshTunnel { + t.Fatalf("account-template lease flags: sticky=%v proxyPool=%v freshTunnel=%v", lease.sticky, lease.proxyPool, lease.freshTunnel) + } +} + +func TestWebForbiddenStillRebuildsBrowserSession(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + manager := NewManager(repository, cipher) + lease, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + lease.Release() + manager.Feedback(context.Background(), 1, http.StatusForbidden, nil) + if repository.updates != 1 || repository.node.Health >= 1 || repository.node.LastError != "anti-bot rejection" { + t.Fatalf("web 403 feedback = updates=%d node=%#v", repository.updates, repository.node) + } + if managerHasClientForNode(manager, 1) { + t.Fatal("web browser session was not invalidated after 403") + } +} + +func TestFlareSolverrRefreshesRejectedNodeBeforeNextLease(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + + first, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + if first.CFCookies != "cf_clearance=value-1" || first.UserAgent != "Chrome/146 test" { + t.Fatalf("first lease = %#v", first) + } + first.Release() + manager.Feedback(context.Background(), 1, http.StatusForbidden, nil) + second, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer second.Release() + if solver.calls != 2 || second.CFCookies != "cf_clearance=value-2" { + t.Fatalf("calls=%d second cookies=%q", solver.calls, second.CFCookies) + } + stored, err := cipher.Decrypt(repository.node.EncryptedCloudflareCookie) + if err != nil || stored != "cf_clearance=value-2" { + t.Fatalf("stored cookies=%q err=%v", stored, err) + } +} + +func TestFlareSolverrSupportsDirectWebEgress(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + solver := &clearanceSolverStub{} + manager := NewManager(egressRepositoryTestStub{}, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + lease, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.NodeID != 0 || lease.CFCookies != "cf_clearance=value-1" || solver.proxyURL != "" { + t.Fatalf("direct lease=%#v proxy=%q", lease, solver.proxyURL) + } +} + +func TestFlareSolverrPrewarmsDirectWebEgressWhenNoNodesExist(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + solver := &clearanceSolverStub{} + manager := NewManager(egressRepositoryTestStub{}, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + if err := manager.RefreshDueClearances(context.Background(), true); err != nil { + t.Fatal(err) + } + lease, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if solver.calls != 1 || lease.CFCookies != "cf_clearance=value-1" { + t.Fatalf("calls=%d cookies=%q", solver.calls, lease.CFCookies) + } +} + +func TestStickyProxyForbiddenDoesNotCooldownSharedNode(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxy, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "resin", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxy}} + manager := NewManager(repository, cipher) + lease, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ID: 42, Provider: accountdomain.ProviderWeb}) + if err != nil { + t.Fatal(err) + } + lease.Release() + manager.Feedback(context.Background(), 1, http.StatusForbidden, nil) + if repository.updates != 0 || repository.node.Health != 1 || repository.node.LastError != "" { + t.Fatalf("sticky proxy 403 changed shared node: updates=%d node=%#v", repository.updates, repository.node) + } +} + +func TestFlareSolverrIsolatesResinClearancePerAccount(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxy, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ + ID: 1, Name: "resin", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxy, + }} + solver := &clearanceSolverStub{} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + + first, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ID: 42, Provider: accountdomain.ProviderWeb}) + if err != nil { + t.Fatal(err) + } + first.Release() + second, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ID: 43, Provider: accountdomain.ProviderWeb}) + if err != nil { + t.Fatal(err) + } + second.Release() + again, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ID: 42, Provider: accountdomain.ProviderWeb}) + if err != nil { + t.Fatal(err) + } + again.Release() + + if first.CFCookies != "cf_clearance=value-1" || second.CFCookies != "cf_clearance=value-2" || again.CFCookies != first.CFCookies { + t.Fatalf("clearances leaked across accounts: first=%q second=%q again=%q", first.CFCookies, second.CFCookies, again.CFCookies) + } + if solver.calls != 2 || repository.updates != 0 || repository.node.EncryptedCloudflareCookie != "" { + t.Fatalf("calls=%d updates=%d persisted=%q", solver.calls, repository.updates, repository.node.EncryptedCloudflareCookie) + } +} + +func TestClearanceRefreshFailureUsesLastKnownGoodUntilRejected(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Nanosecond}) + + first, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + first.Release() + solver.err = errors.New("solver unavailable") + second, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil || second.CFCookies != first.CFCookies { + t.Fatalf("last-known-good was not used: cookies=%q err=%v", second.CFCookies, err) + } + second.Release() + + manager.InvalidateClearance(1) + if _, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account"); err == nil { + t.Fatal("invalid clearance was reused after a rejection") + } +} + +func TestClearanceFallbackSurvivesSolverAddressChangeOnly(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{} + manager := NewManager(repository, cipher) + manager.solver = solver + base := ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver-a", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour} + manager.UpdateClearanceConfig(base) + first, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + first.Release() + + base.FlareSolverrURL = "http://solver-b" + manager.UpdateClearanceConfig(base) + solver.err = errors.New("new solver unavailable") + second, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer second.Release() + if second.CFCookies != first.CFCookies || solver.calls != 2 { + t.Fatalf("fallback cookie=%q want=%q solver calls=%d", second.CFCookies, first.CFCookies, solver.calls) + } +} + +func TestNodeEditForgetsRuntimeStateButKeepsBoundFallback(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + first, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + first.Release() + + // Service updates clear freshness but preserve the binding that proves the + // old cookie still belongs to this target/proxy pair. + repository.node.Name = "renamed" + repository.node.ClearanceRefreshedAt = nil + repository.node.ClearanceFingerprint = "" + manager.ForgetClearance(repository.node.ID) + solver.err = errors.New("solver unavailable") + second, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + defer second.Release() + if second.CFCookies != first.CFCookies || solver.calls != 2 { + t.Fatalf("fallback cookie=%q want=%q solver calls=%d", second.CFCookies, first.CFCookies, solver.calls) + } +} + +func TestClearanceFallbackRejectsDifferentBinding(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyA, err := cipher.Encrypt("socks5h://proxy-a:1080") + if err != nil { + t.Fatal(err) + } + proxyB, err := cipher.Encrypt("socks5h://proxy-b:1080") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxyA}} + solver := &clearanceSolverStub{} + manager := NewManager(repository, cipher) + manager.solver = solver + config := ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour} + manager.UpdateClearanceConfig(config) + first, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + first.Release() + solver.err = errors.New("solver unavailable") + + config.TargetURL = "https://console.x.ai" + manager.UpdateClearanceConfig(config) + if _, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account"); err == nil { + t.Fatal("Clearance from a different target binding was reused") + } + + config.TargetURL = "https://grok.com" + manager.UpdateClearanceConfig(config) + repository.node.EncryptedProxyURL = proxyB + manager.invalidateNodes(domain.ScopeWeb) + if _, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account"); err == nil { + t.Fatal("Clearance from a different proxy binding was reused") + } +} + +func TestClearanceBackgroundRefreshSkipsResinTemplate(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxy, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + solver := &clearanceSolverStub{} + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{{ + ID: 1, Name: "resin", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxy, + }}}, cipher) + manager.solver = solver + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + if err := manager.RefreshDueClearances(context.Background(), true); err != nil { + t.Fatal(err) + } + if solver.calls != 0 { + t.Fatalf("background refresh solved an account template %d times", solver.calls) + } +} + +func TestPersistedClearancePreventsDuplicateInstanceRefresh(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{} + config := ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour} + firstManager := NewManager(repository, cipher) + firstManager.solver = solver + firstManager.UpdateClearanceConfig(config) + first, err := firstManager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + first.Release() + + secondManager := NewManager(repository, cipher) + secondManager.solver = solver + secondManager.UpdateClearanceConfig(config) + second, err := secondManager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + second.Release() + if solver.calls != 1 || second.CFCookies != first.CFCookies { + t.Fatalf("instances did not reuse persisted clearance: calls=%d first=%q second=%q", solver.calls, first.CFCookies, second.CFCookies) + } +} + +func TestNoChallengeClearanceDoesNotBlockOrRefreshRepeatedly(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{noCookies: true} + config := ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour} + firstManager := NewManager(repository, cipher) + firstManager.solver = solver + firstManager.UpdateClearanceConfig(config) + for range 2 { + lease, acquireErr := firstManager.Acquire(context.Background(), domain.ScopeWeb, "account") + if acquireErr != nil { + t.Fatal(acquireErr) + } + if lease.CFCookies != "" || lease.UserAgent != "Chrome/146 test" { + t.Fatalf("cookie-less lease = %#v", lease) + } + lease.Release() + } + + secondManager := NewManager(repository, cipher) + secondManager.solver = solver + secondManager.UpdateClearanceConfig(config) + lease, err := secondManager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + lease.Release() + if solver.calls != 1 || repository.node.ClearanceRefreshedAt == nil || repository.node.UserAgent != "Chrome/146 test" { + t.Fatalf("cookie-less clearance was not reused: calls=%d node=%#v", solver.calls, repository.node) + } +} + +func TestRejectedNoChallengeClearanceForcesRefreshWithDistributedLock(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{noCookies: true} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.SetClearanceLock(alwaysAcquiredDistributedLock{}) + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + + first, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + first.InvalidateClearance() + first.Release() + + second, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + second.Release() + if solver.calls != 2 { + t.Fatalf("rejected cookie-less clearance reused persisted state: calls=%d", solver.calls) + } +} + +func TestBackgroundRefreshDoesNotReuseRejectedNoChallengeClearance(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := &mutableEgressRepository{node: domain.Node{ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1}} + solver := &clearanceSolverStub{noCookies: true} + manager := NewManager(repository, cipher) + manager.solver = solver + manager.SetClearanceLock(alwaysAcquiredDistributedLock{}) + manager.UpdateClearanceConfig(ClearanceConfig{Mode: "flaresolverr", FlareSolverrURL: "http://solver", TargetURL: "https://grok.com", Timeout: time.Second, RefreshInterval: time.Hour}) + + lease, err := manager.Acquire(context.Background(), domain.ScopeWeb, "account") + if err != nil { + t.Fatal(err) + } + lease.InvalidateClearance() + lease.Release() + if err := manager.RefreshDueClearances(context.Background(), false); err != nil { + t.Fatal(err) + } + if solver.calls != 2 { + t.Fatalf("background refresh reused rejected cookie-less clearance: calls=%d", solver.calls) + } +} + +func TestWebAssetCredentialFallsBackToWebWithSameResinIdentity(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + accountCookie, err := cipher.Encrypt("cf_clearance=account") + if err != nil { + t.Fatal(err) + } + token := "shared-web-asset-sso" + encryptedToken, err := cipher.Encrypt(token) + if err != nil { + t.Fatal(err) + } + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 2, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + }}, cipher) + credential := accountdomain.Credential{ + ID: 42, Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: encryptedToken, EncryptedCloudflareCookie: accountCookie, + } + webLease, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, credential) + if err != nil { + t.Fatal(err) + } + defer webLease.Release() + lease, err := manager.AcquireCredential(context.Background(), domain.ScopeWebAsset, credential) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.NodeID != 2 { + t.Fatalf("node = %d, want web fallback node 2", lease.NodeID) + } + wantAccount := "sso_" + security.HashToken(token)[:32] + if lease.ProxyURL != webLease.ProxyURL || !strings.Contains(lease.ProxyURL, "Default."+wantAccount+":") { + t.Fatalf("proxy identities web=%q asset=%q", webLease.ProxyURL, lease.ProxyURL) + } + if lease.CFCookies != "cf_clearance=account" { + t.Fatalf("asset lease cookie = %q", lease.CFCookies) + } + if lease.client != webLease.client { + t.Fatal("Web Asset credential fallback did not reuse the matching Web browser session") + } +} + +func TestEgressNodeSnapshotAvoidsRepeatedRepositoryReads(t *testing.T) { + repository := &countingEgressRepository{egressRepositoryTestStub: egressRepositoryTestStub{nodes: []domain.Node{{ID: 1, Scope: domain.ScopeWeb, Enabled: true}}}} + manager := NewManager(repository, nil) + now := time.Now().UTC() + for range 2 { + values, err := manager.listNodes(context.Background(), domain.ScopeWeb, now) + if err != nil || len(values) != 1 { + t.Fatalf("nodes=%#v err=%v", values, err) + } + } + if repository.calls != 1 { + t.Fatalf("repository reads = %d, want 1", repository.calls) + } +} + +func TestOperationsConfigSnapshotAvoidsRepeatedRepositoryReads(t *testing.T) { + repository := &countingFallbackRepository{config: domain.DefaultOperationsConfig()} + manager := NewManager(repository, nil) + for range 2 { + lease, configured, err := manager.AcquireIfConfigured(context.Background(), domain.ScopeBuild, "") + if err != nil || configured || lease != nil { + t.Fatalf("lease=%#v configured=%v err=%v", lease, configured, err) + } + } + if repository.configCalls != 1 { + t.Fatalf("operations config reads = %d, want 1", repository.configCalls) + } +} + +func TestOperationsConfigSnapshotCanBeInvalidated(t *testing.T) { + repository := &countingFallbackRepository{config: domain.DefaultOperationsConfig()} + manager := NewManager(repository, nil) + first, _, err := manager.fallbackFor(context.Background(), domain.ScopeWeb, time.Now().UTC()) + if err != nil || first.Mode != domain.FallbackModeNone { + t.Fatalf("first fallback=%#v err=%v", first, err) + } + repository.config.Fallbacks[domain.ScopeWeb] = domain.FallbackConfig{Mode: domain.FallbackModeDirect} + manager.InvalidateOperationsConfig() + second, _, err := manager.fallbackFor(context.Background(), domain.ScopeWeb, time.Now().UTC()) + if err != nil || second.Mode != domain.FallbackModeDirect { + t.Fatalf("second fallback=%#v err=%v", second, err) + } + if repository.configCalls != 2 { + t.Fatalf("operations config reads = %d, want 2", repository.configCalls) + } +} + +type egressRepositoryTestStub struct{ nodes []domain.Node } + +type fallbackEgressRepository struct { + egressRepositoryTestStub + config domain.OperationsConfig + configErr error +} + +type countingFallbackRepository struct { + egressRepositoryTestStub + config domain.OperationsConfig + configCalls int +} + +func (r *countingFallbackRepository) GetEgressOperationsConfig(context.Context) (domain.OperationsConfig, error) { + r.configCalls++ + return r.config, nil +} + +func (r fallbackEgressRepository) GetEgressOperationsConfig(context.Context) (domain.OperationsConfig, error) { + if r.configErr != nil { + return domain.OperationsConfig{}, r.configErr + } + return r.config, nil +} + +func managerHasClientForNode(manager *Manager, nodeID uint64) bool { + manager.clientMu.Lock() + defer manager.clientMu.Unlock() + for key := range manager.clients { + if key.nodeID == nodeID { + return true + } + } + return false +} + +type countingEgressRepository struct { + egressRepositoryTestStub + calls int +} + +type mutableEgressRepository struct { + node domain.Node + reads int + updates int +} + +type blockingEgressRepository struct { + egressRepositoryTestStub + mu sync.Mutex + node domain.Node + listStarted chan struct{} + listRelease chan struct{} + listOnce sync.Once +} + +type clearanceSolverStub struct { + calls int + proxyURL string + err error + noCookies bool +} + +type alwaysAcquiredDistributedLock struct{} + +func (alwaysAcquiredDistributedLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) { + return func() {}, true, nil +} + +func (s *clearanceSolverStub) Solve(_ context.Context, _ ClearanceConfig, proxyURL string) (clearanceSolution, error) { + s.calls++ + s.proxyURL = proxyURL + if s.err != nil { + return clearanceSolution{}, s.err + } + if s.noCookies { + return clearanceSolution{UserAgent: "Chrome/146 test"}, nil + } + return clearanceSolution{Cookies: fmt.Sprintf("cf_clearance=value-%d", s.calls), UserAgent: "Chrome/146 test"}, nil +} + +func (r *mutableEgressRepository) ListEgressNodes(_ context.Context, scope domain.Scope, _ repository.SortQuery) ([]domain.Node, error) { + if scope != "" && r.node.Scope != scope { + return nil, nil + } + return []domain.Node{r.node}, nil +} + +func (r *mutableEgressRepository) GetEgressNode(_ context.Context, id uint64) (domain.Node, error) { + r.reads++ + if r.node.ID != id { + return domain.Node{}, errors.New("not found") + } + return r.node, nil +} + +func (r *mutableEgressRepository) CreateEgressNode(_ context.Context, value domain.Node) (domain.Node, error) { + r.node = value + return value, nil +} + +func (r *mutableEgressRepository) UpdateEgressNode(_ context.Context, value domain.Node) (domain.Node, error) { + r.node = value + r.updates++ + return value, nil +} + +func (r *mutableEgressRepository) DeleteEgressNode(_ context.Context, id uint64) error { + if r.node.ID != id { + return errors.New("not found") + } + r.node = domain.Node{} + return nil +} + +func (r *blockingEgressRepository) ListEgressNodes(_ context.Context, scope domain.Scope, _ repository.SortQuery) ([]domain.Node, error) { + r.mu.Lock() + node := r.node + r.mu.Unlock() + r.listOnce.Do(func() { + close(r.listStarted) + <-r.listRelease + }) + if scope != "" && node.Scope != scope { + return nil, nil + } + return []domain.Node{node}, nil +} + +func (r *blockingEgressRepository) GetEgressNode(_ context.Context, id uint64) (domain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.node.ID != id { + return domain.Node{}, errors.New("not found") + } + return r.node, nil +} + +func (r *blockingEgressRepository) UpdateEgressNode(_ context.Context, value domain.Node) (domain.Node, error) { + r.mu.Lock() + r.node = value + r.mu.Unlock() + return value, nil +} + +func (r *countingEgressRepository) ListEgressNodes(ctx context.Context, scope domain.Scope, sort repository.SortQuery) ([]domain.Node, error) { + r.calls++ + return r.egressRepositoryTestStub.ListEgressNodes(ctx, scope, sort) +} + +func (s egressRepositoryTestStub) ListEgressNodes(_ context.Context, scope domain.Scope, _ repository.SortQuery) ([]domain.Node, error) { + values := make([]domain.Node, 0, len(s.nodes)) + for _, node := range s.nodes { + if scope == "" || node.Scope == scope { + values = append(values, node) + } + } + return values, nil +} +func (s egressRepositoryTestStub) GetEgressNode(_ context.Context, id uint64) (domain.Node, error) { + for _, node := range s.nodes { + if node.ID == id { + return node, nil + } + } + return domain.Node{}, errors.New("not found") +} + +func BenchmarkManagerAcquireCachedBuild(b *testing.B) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + b.Fatal(err) + } + node := domain.Node{ID: 1, Name: "build", Scope: domain.ScopeBuild, Enabled: true, Health: 1} + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{node}}, cipher) + manager.newBuildClient = func(string, time.Duration) (requestClient, error) { + return &scriptedRequestClient{do: func(int, *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }}, nil + } + lease, err := manager.Acquire(context.Background(), domain.ScopeBuild, "") + if err != nil { + b.Fatal(err) + } + lease.Release() + manager.nodeMu.Lock() + manager.nodes[domain.ScopeBuild] = cachedNodeSnapshot{values: []domain.Node{node}, expiresAt: time.Now().Add(time.Hour)} + manager.nodeMu.Unlock() + + b.ReportAllocs() + b.RunParallel(func(worker *testing.PB) { + for worker.Next() { + lease, acquireErr := manager.Acquire(context.Background(), domain.ScopeBuild, "") + if acquireErr != nil { + b.Error(acquireErr) + continue + } + lease.Release() + } + }) +} +func (egressRepositoryTestStub) CreateEgressNode(context.Context, domain.Node) (domain.Node, error) { + return domain.Node{}, errors.New("unsupported") +} +func (egressRepositoryTestStub) UpdateEgressNode(context.Context, domain.Node) (domain.Node, error) { + return domain.Node{}, errors.New("unsupported") +} +func (egressRepositoryTestStub) DeleteEgressNode(context.Context, uint64) error { + return errors.New("unsupported") +} diff --git a/backend/internal/infra/egress/physical_calls.go b/backend/internal/infra/egress/physical_calls.go new file mode 100644 index 0000000000000000000000000000000000000000..73a708702ac570141d900155db0a8bf0be1f3c37 --- /dev/null +++ b/backend/internal/infra/egress/physical_calls.go @@ -0,0 +1,175 @@ +package egress + +import ( + "context" + "net/http" + "strconv" + "strings" + "sync/atomic" + + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" +) + +type physicalCallTrace struct { + provider string + operation string + ordinal atomic.Uint64 +} + +type physicalCallContext struct { + trace *physicalCallTrace + plane string + stage string +} + +type physicalCallContextKey struct{} + +// WithPhysicalCallTrace starts observe-only physical-call accounting for one +// downstream request. It does not impose a retry budget or alter transport. +func WithPhysicalCallTrace(ctx context.Context, provider, operation string) context.Context { + if ctx == nil { + return ctx + } + if current := physicalCallFromContext(ctx); current.trace != nil { + return ctx + } + provider = normalizePhysicalProvider(provider) + value := physicalCallContext{ + trace: &physicalCallTrace{provider: provider, operation: normalizePhysicalOperation(operation)}, + plane: defaultPhysicalPlane(provider), + stage: "primary", + } + return context.WithValue(ctx, physicalCallContextKey{}, value) +} + +// WithPhysicalCallPlane annotates a bounded upstream plane while preserving +// the request-wide ordinal and current stage. +func WithPhysicalCallPlane(ctx context.Context, plane string) context.Context { + value := physicalCallFromContext(ctx) + if value.trace == nil { + return ctx + } + value.plane = normalizePhysicalPlane(plane) + return context.WithValue(ctx, physicalCallContextKey{}, value) +} + +// WithPhysicalCallStage annotates an internal retry or preparation stage while +// preserving the request-wide ordinal and upstream plane. +func WithPhysicalCallStage(ctx context.Context, stage string) context.Context { + value := physicalCallFromContext(ctx) + if value.trace == nil { + return ctx + } + value.stage = normalizePhysicalStage(stage) + return context.WithValue(ctx, physicalCallContextKey{}, value) +} + +func recordPhysicalCall(ctx context.Context, response *http.Response, err error) { + value := physicalCallFromContext(ctx) + if value.trace == nil { + return + } + ordinal := value.trace.ordinal.Add(1) + perfmetrics.Default.Inc("upstream_physical_call_total", perfmetrics.Labels{ + Subsystem: "upstream", + Operation: value.trace.operation, + Provider: value.trace.provider, + Plane: value.plane, + Stage: value.stage, + Ordinal: physicalOrdinalBucket(ordinal), + Outcome: physicalCallOutcome(response, err), + }) +} + +// RecordDirectPhysicalCall records a transport call that intentionally bypasses +// the managed egress lease because no Build node is configured. +func RecordDirectPhysicalCall(ctx context.Context, response *http.Response, err error) { + recordPhysicalCall(ctx, response, err) +} + +func physicalCallFromContext(ctx context.Context) physicalCallContext { + if ctx == nil { + return physicalCallContext{} + } + value, _ := ctx.Value(physicalCallContextKey{}).(physicalCallContext) + return value +} + +func normalizePhysicalProvider(value string) string { + switch strings.TrimSpace(value) { + case "grok_build", "grok_web", "grok_console": + return strings.TrimSpace(value) + default: + return "unknown" + } +} + +func normalizePhysicalOperation(value string) string { + switch strings.TrimSpace(value) { + case "responses", "chat", "messages", "compaction", "response_get", "response_delete": + return strings.TrimSpace(value) + case "responses_compact": + return "compaction" + default: + return "other" + } +} + +func defaultPhysicalPlane(provider string) string { + switch provider { + case "grok_build": + return "build" + case "grok_web": + return "web" + case "grok_console": + return "console" + default: + return "unknown" + } +} + +func normalizePhysicalPlane(value string) string { + switch strings.TrimSpace(value) { + case "build", "xai", "web", "console": + return strings.TrimSpace(value) + default: + return "unknown" + } +} + +func normalizePhysicalStage(value string) string { + switch strings.TrimSpace(value) { + case "primary", "plane_fallback", "reasoning_replay", "reasoning_session_reset", "compaction", "compaction_retry", "anti_bot_retry", "statsig_meta": + return strings.TrimSpace(value) + default: + return "other" + } +} + +func physicalOrdinalBucket(value uint64) string { + if value >= 5 { + return "5_plus" + } + return strconv.FormatUint(value, 10) +} + +func physicalCallOutcome(response *http.Response, err error) string { + if err != nil { + return "transport_error" + } + if response == nil { + return "empty_response" + } + switch { + case response.StatusCode >= 200 && response.StatusCode < 300: + return "success" + case response.StatusCode >= 300 && response.StatusCode < 400: + return "redirect" + case response.StatusCode >= 400 && response.StatusCode < 500: + return "client_error" + case response.StatusCode >= 500 && response.StatusCode < 600: + return "server_error" + default: + return "other" + } +} diff --git a/backend/internal/infra/egress/physical_calls_test.go b/backend/internal/infra/egress/physical_calls_test.go new file mode 100644 index 0000000000000000000000000000000000000000..86efd11dd5b377e37d71291411c92d403279083e --- /dev/null +++ b/backend/internal/infra/egress/physical_calls_test.go @@ -0,0 +1,103 @@ +package egress + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" +) + +func TestPhysicalCallTraceRecordsPlaneStageOrdinalAndOutcome(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + + ctx := WithPhysicalCallTrace(context.Background(), "grok_build", "responses") + recordPhysicalCall(ctx, &http.Response{StatusCode: http.StatusForbidden}, nil) + fallbackCtx := WithPhysicalCallPlane(WithPhysicalCallStage(ctx, "plane_fallback"), "xai") + recordPhysicalCall(fallbackCtx, &http.Response{StatusCode: http.StatusOK}, nil) + recordPhysicalCall(WithPhysicalCallStage(ctx, "reasoning_replay"), nil, errors.New("connection reset")) + + samples := registry.CollectAndReset() + assertPhysicalCallMetric(t, samples, "grok_build", "build", "primary", "1", "client_error") + assertPhysicalCallMetric(t, samples, "grok_build", "xai", "plane_fallback", "2", "success") + assertPhysicalCallMetric(t, samples, "grok_build", "build", "reasoning_replay", "3", "transport_error") +} + +func TestPhysicalCallTraceNormalizesCompactionOperation(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + + ctx := WithPhysicalCallTrace(context.Background(), "grok_build", "compaction") + recordPhysicalCall(ctx, &http.Response{StatusCode: http.StatusOK}, nil) + legacyCtx := WithPhysicalCallTrace(context.Background(), "grok_build", "responses_compact") + recordPhysicalCall(legacyCtx, &http.Response{StatusCode: http.StatusOK}, nil) + + samples := registry.CollectAndReset() + var count uint64 + for _, sample := range samples { + if sample.Name == "upstream_physical_call_total" && sample.Labels.Operation == "compaction" { + count += sample.Count + } + if sample.Name == "upstream_physical_call_total" && sample.Labels.Operation == "other" { + t.Fatalf("compaction was classified as other: %#v", sample) + } + } + if count != 2 { + t.Fatalf("normalized compaction count = %d, want 2: %#v", count, samples) + } +} + +func TestProxyPoolConnectionRetriesCountAsOneProviderCall(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + + client := &scriptedRequestClient{do: func(call int, _ *http.Request) (*http.Response, error) { + if call == 1 { + return nil, errors.New("proxyconnect tcp: connection refused") + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }} + lease := &Lease{client: client, proxyPool: true} + ctx := WithPhysicalCallTrace(context.Background(), "grok_web", "messages") + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://grok.com/rest/app-chat", http.NoBody) + if err != nil { + t.Fatal(err) + } + response, err := lease.Do(request) + if err != nil || response.StatusCode != http.StatusOK || client.calls != 2 { + t.Fatalf("response=%#v calls=%d err=%v", response, client.calls, err) + } + samples := registry.CollectAndReset() + assertPhysicalCallMetric(t, samples, "grok_web", "web", "primary", "1", "success") + var total uint64 + for _, sample := range samples { + if sample.Name == "upstream_physical_call_total" { + total += sample.Count + } + } + if total != 1 { + t.Fatalf("physical call count = %d, want 1: %#v", total, samples) + } +} + +func assertPhysicalCallMetric(t *testing.T, samples []perfmetrics.Sample, provider, plane, stage, ordinal, outcome string) { + t.Helper() + for _, sample := range samples { + labels := sample.Labels + if sample.Name == "upstream_physical_call_total" && labels.Provider == provider && labels.Plane == plane && labels.Stage == stage && labels.Ordinal == ordinal && labels.Outcome == outcome { + if sample.Count != 1 || sample.Total != 1 { + t.Fatalf("physical call sample = %#v", sample) + } + return + } + } + t.Fatalf("physical call metric not found: %#v", samples) +} diff --git a/backend/internal/infra/egress/pinnedclient.go b/backend/internal/infra/egress/pinnedclient.go new file mode 100644 index 0000000000000000000000000000000000000000..11fa832862a47581d57234c83d71ae5611a1415f --- /dev/null +++ b/backend/internal/infra/egress/pinnedclient.go @@ -0,0 +1,94 @@ +package egress + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + + xproxy "golang.org/x/net/proxy" +) + +// DoPinnedHTTPS sends a request whose URL host is an already validated IP +// address while preserving serverName for TLS verification. Keeping this +// transport separate from the shared browser client prevents a later DNS +// lookup or a pooled connection from reopening an SSRF validation gap. +func (l *Lease) DoPinnedHTTPS(request *http.Request, serverName string) (*http.Response, error) { + if l == nil { + return nil, errors.New("出口租约未初始化") + } + if request == nil || request.URL == nil || request.URL.Scheme != "https" || request.URL.Port() != "443" { + return nil, errors.New("固定地址请求必须使用 HTTPS 443") + } + address, err := netip.ParseAddr(request.URL.Hostname()) + if err != nil { + return nil, errors.New("固定地址请求必须使用 IP 主机") + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsMulticast() || address.IsUnspecified() { + return nil, errors.New("固定地址请求必须使用公网 IP") + } + serverName = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(serverName)), ".") + hostURL, err := url.Parse("https://" + request.Host) + if err != nil || strings.TrimSuffix(strings.ToLower(hostURL.Hostname()), ".") != serverName { + return nil, errors.New("固定地址请求的 Host 与 TLS ServerName 不一致") + } + client, err := newPinnedHTTPSClient(l.ProxyURL, serverName, nil) + if err != nil { + return nil, err + } + return client.Do(request) +} + +func newPinnedHTTPSClient(proxyURL, serverName string, tlsConfig *tls.Config) (*http.Client, error) { + serverName = strings.TrimSuffix(strings.TrimSpace(serverName), ".") + if serverName == "" { + return nil, errors.New("TLS ServerName 不能为空") + } + direct := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + if tlsConfig == nil { + tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } else { + tlsConfig = tlsConfig.Clone() + } + tlsConfig.ServerName = serverName + transport := &http.Transport{ + Proxy: nil, + DialContext: direct.DialContext, + ForceAttemptHTTP2: true, + DisableKeepAlives: true, + TLSClientConfig: tlsConfig, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: time.Second, + } + if strings.TrimSpace(proxyURL) != "" { + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("解析固定地址出口代理: %w", err) + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + transport.Proxy = http.ProxyURL(parsed) + case "socks4", "socks4a", "socks5", "socks5h": + dialer, err := xproxy.FromURL(parsed, direct) + if err != nil { + return nil, fmt.Errorf("创建固定地址 SOCKS 代理: %w", err) + } + transport.DialContext = dialContext(dialer) + default: + return nil, fmt.Errorf("固定地址请求不支持代理协议 %q", parsed.Scheme) + } + } + return &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, nil +} diff --git a/backend/internal/infra/egress/pinnedclient_test.go b/backend/internal/infra/egress/pinnedclient_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e0b1eea5b0c722f802ebec1b3083259023c792aa --- /dev/null +++ b/backend/internal/infra/egress/pinnedclient_test.go @@ -0,0 +1,148 @@ +package egress + +import ( + "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestPinnedHTTPSClientPreservesHostAndTLSServerName(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Host != "example.com" { + t.Errorf("Host = %q", request.Host) + } + _, _ = io.WriteString(writer, "ok") + })) + defer server.Close() + roots := x509.NewCertPool() + roots.AddCert(server.Certificate()) + client, err := newPinnedHTTPSClient("", "example.com", &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, server.URL+"/image.png", nil) + if err != nil { + t.Fatal(err) + } + request.Host = "example.com" + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if readErr != nil || string(body) != "ok" { + t.Fatalf("body=%q err=%v", body, readErr) + } +} + +func TestPinnedHTTPSClientHTTPProxyConnectsToPinnedIP(t *testing.T) { + connected := make(chan string, 1) + proxyServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + connected <- request.Host + http.Error(writer, "stop", http.StatusBadGateway) + })) + defer proxyServer.Close() + client, err := newPinnedHTTPSClient(proxyServer.URL, "images.example.test", nil) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, "https://93.184.216.34:443/image.png", nil) + if err != nil { + t.Fatal(err) + } + request.Host = "images.example.test" + _, _ = client.Do(request) + select { + case host := <-connected: + if host != "93.184.216.34:443" { + t.Fatalf("proxy CONNECT host = %q", host) + } + case <-time.After(2 * time.Second): + t.Fatal("proxy did not receive CONNECT") + } +} + +func TestPinnedHTTPSClientSOCKS5HReceivesPinnedIP(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = io.WriteString(writer, "ok") + })) + defer server.Close() + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + upstreamAddress := serverURL.Host + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + requestedHost := make(chan string, 1) + proxyDone := make(chan error, 1) + go func() { proxyDone <- serveSOCKS5TunnelOnce(listener, upstreamAddress, requestedHost) }() + roots := x509.NewCertPool() + roots.AddCert(server.Certificate()) + client, err := newPinnedHTTPSClient("socks5h://"+listener.Addr().String(), "example.com", &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(upstreamAddress) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:"+port+"/image.png", nil) + if err != nil { + t.Fatal(err) + } + request.Host = "example.com" + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + select { + case host := <-requestedHost: + if host != "127.0.0.1" { + t.Fatalf("SOCKS target host = %q", host) + } + case <-time.After(2 * time.Second): + t.Fatal("SOCKS proxy did not receive target") + } + select { + case err := <-proxyDone: + if err != nil && !strings.Contains(strings.ToLower(err.Error()), "closed") && !strings.Contains(strings.ToLower(err.Error()), "reset by peer") { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("SOCKS proxy did not finish") + } +} + +func TestLeaseDoPinnedHTTPSRejectsHostnameTarget(t *testing.T) { + request, err := http.NewRequest(http.MethodGet, "https://images.example.test:443/image.png", nil) + if err != nil { + t.Fatal(err) + } + if _, err := (&Lease{}).DoPinnedHTTPS(request, "images.example.test"); err == nil { + t.Fatal("hostname target was accepted") + } +} + +func TestLeaseDoPinnedHTTPSRejectsPrivateTarget(t *testing.T) { + request, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:443/image.png", nil) + if err != nil { + t.Fatal(err) + } + request.Host = "images.example.test" + if _, err := (&Lease{}).DoPinnedHTTPS(request, "images.example.test"); err == nil { + t.Fatal("private target was accepted") + } +} diff --git a/backend/internal/infra/egress/sticky_retry.go b/backend/internal/infra/egress/sticky_retry.go new file mode 100644 index 0000000000000000000000000000000000000000..1d525c38a64e7fcb624cf24b3702e08c75ad90b4 --- /dev/null +++ b/backend/internal/infra/egress/sticky_retry.go @@ -0,0 +1,105 @@ +package egress + +import ( + "crypto/tls" + "errors" + "net/http" + "net/http/httptrace" + "strings" +) + +// do retries only the connection phase of a proxy-pool request. +// Once the request is written to the upstream tunnel, replaying a POST could +// duplicate generation or billing and is therefore never attempted here. +func (l *Lease) do(request *http.Request) (*http.Response, error) { + if l == nil || l.client == nil { + return nil, errors.New("出口客户端未初始化") + } + if !l.proxyPool { + return l.client.Do(request) + } + current := request + for attempt := 0; ; attempt++ { + written := false + trace := &httptrace.ClientTrace{WroteRequest: func(httptrace.WroteRequestInfo) { + // The callback also fires when writing fails after a partial write; + // treat that as submitted because the upstream may have received it. + written = true + }} + traced := current.WithContext(httptrace.WithClientTrace(current.Context(), trace)) + response, err := l.client.Do(traced) + if err == nil && !retryableResinResponse(response) { + return response, nil + } + if attempt >= proxyPoolRetryLimit || written || !safeProxyConnectionFailure(err, response) { + if safeProxyConnectionFailure(err, response) { + l.client.CloseIdleConnections() + } + if err != nil { + return nil, err + } + return response, nil + } + next, cloneErr := cloneRequestBody(request) + if cloneErr != nil { + l.client.CloseIdleConnections() + if err != nil { + return nil, err + } + return response, nil + } + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + l.client.CloseIdleConnections() + current = next + } +} + +func cloneRequestBody(request *http.Request) (*http.Request, error) { + if request == nil { + return nil, errors.New("请求为空") + } + if request.Body == nil || request.Body == http.NoBody { + return request.Clone(request.Context()), nil + } + if request.GetBody == nil { + return nil, errors.New("请求体不可重放") + } + body, err := request.GetBody() + if err != nil { + return nil, err + } + cloned := request.Clone(request.Context()) + cloned.Body = body + return cloned, nil +} + +func safeProxyConnectionFailure(err error, response *http.Response) bool { + if response != nil { + resinError := strings.ToUpper(strings.TrimSpace(response.Header.Get("X-Resin-Error"))) + return response.StatusCode >= http.StatusBadGateway && (resinError == "UPSTREAM_CONNECT_FAILED" || resinError == "NO_AVAILABLE_NODES") + } + if err == nil { + return false + } + value := strings.ToLower(err.Error()) + for _, marker := range []string{ + "proxyconnect", "socks connect", "socks5: authentication", "tls handshake timeout", + "connection refused", "no route to host", + } { + if strings.Contains(value, marker) { + return true + } + } + var tlsError *tls.RecordHeaderError + return errors.As(err, &tlsError) +} + +func retryableResinResponse(response *http.Response) bool { + if response == nil { + return false + } + resinError := strings.ToUpper(strings.TrimSpace(response.Header.Get("X-Resin-Error"))) + return (resinError == "UPSTREAM_CONNECT_FAILED" || resinError == "NO_AVAILABLE_NODES") && response.StatusCode >= 502 +} diff --git a/backend/internal/infra/egress/sticky_retry_test.go b/backend/internal/infra/egress/sticky_retry_test.go new file mode 100644 index 0000000000000000000000000000000000000000..26b636e7c07278c75eae174937c4def50ff704f2 --- /dev/null +++ b/backend/internal/infra/egress/sticky_retry_test.go @@ -0,0 +1,217 @@ +package egress + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptrace" + "strings" + "testing" + + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" +) + +type scriptedRequestClient struct { + calls int + closedIdle int + do func(int, *http.Request) (*http.Response, error) +} + +func (c *scriptedRequestClient) Do(request *http.Request) (*http.Response, error) { + c.calls++ + return c.do(c.calls, request) +} + +func (c *scriptedRequestClient) CloseIdleConnections() { c.closedIdle++ } + +func TestBuildProxyPoolLeaseForcesFreshTunnel(t *testing.T) { + client := &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + if !request.Close { + t.Fatal("Build proxy-pool request must disable connection reuse") + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil + }} + lease := &Lease{client: client, Scope: egressdomain.ScopeBuild, proxyPool: true, freshTunnel: true} + request, err := http.NewRequest(http.MethodPost, "https://example.com/generate", bytes.NewReader([]byte("payload"))) + if err != nil { + t.Fatal(err) + } + if _, err := lease.Do(request); err != nil { + t.Fatal(err) + } +} + +func TestFixedBuildWebAndAccountBoundProxyKeepConnectionReuse(t *testing.T) { + for _, test := range []struct { + name string + scope egressdomain.Scope + proxyPool bool + freshTunnel bool + }{ + {name: "fixed Build proxy", scope: egressdomain.ScopeBuild}, + {name: "Web proxy pool", scope: egressdomain.ScopeWeb, proxyPool: true, freshTunnel: true}, + {name: "account-bound Build proxy", scope: egressdomain.ScopeBuild, proxyPool: true}, + } { + t.Run(test.name, func(t *testing.T) { + client := &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + if request.Close { + t.Fatal("request unexpectedly disabled connection reuse") + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil + }} + lease := &Lease{client: client, Scope: test.scope, proxyPool: test.proxyPool, freshTunnel: test.freshTunnel} + request, err := http.NewRequest(http.MethodGet, "https://example.com/models", nil) + if err != nil { + t.Fatal(err) + } + if _, err := lease.Do(request); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestStickyLeaseRetriesSafeProxyConnectFailure(t *testing.T) { + client := &scriptedRequestClient{do: func(call int, _ *http.Request) (*http.Response, error) { + if call == 1 { + return nil, errors.New("proxyconnect tcp: connection refused") + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil + }} + lease := &Lease{client: client, sticky: true, proxyPool: true} + request, err := http.NewRequest(http.MethodPost, "https://example.com/generate", bytes.NewReader([]byte("payload"))) + if err != nil { + t.Fatal(err) + } + response, err := lease.Do(request) + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("response=%#v err=%v", response, err) + } + if client.calls != 2 || client.closedIdle != 1 { + t.Fatalf("calls=%d closedIdle=%d", client.calls, client.closedIdle) + } +} + +func TestStickyLeaseRetriesExplicitResinConnectResponse(t *testing.T) { + client := &scriptedRequestClient{do: func(call int, _ *http.Request) (*http.Response, error) { + if call == 1 { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{"X-Resin-Error": []string{"UPSTREAM_CONNECT_FAILED"}}, + Body: io.NopCloser(strings.NewReader("connect failed")), + }, nil + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil + }} + lease := &Lease{client: client, sticky: true, proxyPool: true} + request, err := http.NewRequest(http.MethodGet, "https://example.com/models", nil) + if err != nil { + t.Fatal(err) + } + response, err := lease.Do(request) + if err != nil || response.StatusCode != http.StatusOK || client.calls != 2 { + t.Fatalf("response=%#v calls=%d err=%v", response, client.calls, err) + } +} + +func TestStickyLeaseDoesNotRetryUnsafeUpstreamOutcomes(t *testing.T) { + for _, test := range []struct { + name string + response *http.Response + }{ + {name: "unauthorized", response: &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: http.NoBody}}, + {name: "rate limited", response: &http.Response{StatusCode: http.StatusTooManyRequests, Header: make(http.Header), Body: http.NoBody}}, + {name: "request may have reached upstream", response: &http.Response{StatusCode: http.StatusBadGateway, Header: http.Header{"X-Resin-Error": []string{"UPSTREAM_REQUEST_FAILED"}}, Body: http.NoBody}}, + } { + t.Run(test.name, func(t *testing.T) { + client := &scriptedRequestClient{do: func(int, *http.Request) (*http.Response, error) { return test.response, nil }} + lease := &Lease{client: client, sticky: true, proxyPool: true} + request, err := http.NewRequest(http.MethodPost, "https://example.com/generate", bytes.NewReader([]byte("payload"))) + if err != nil { + t.Fatal(err) + } + response, err := lease.Do(request) + if err != nil || response != test.response || client.calls != 1 { + t.Fatalf("response=%#v calls=%d err=%v", response, client.calls, err) + } + }) + } +} + +func TestLeaseDefersForbiddenClearanceInvalidationUntilClassification(t *testing.T) { + client := &scriptedRequestClient{do: func(int, *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusForbidden, Header: make(http.Header), Body: http.NoBody}, nil + }} + manager := &Manager{clearances: map[string]clearanceState{"account-bound": {}}} + lease := &Lease{client: client, clearanceManager: manager, clearanceKey: "account-bound"} + request, err := http.NewRequest(http.MethodPost, "https://example.com/generate", http.NoBody) + if err != nil { + t.Fatal(err) + } + response, err := lease.DoDeferredForbidden(request) + if err != nil || response.StatusCode != http.StatusForbidden { + t.Fatalf("response=%#v err=%v", response, err) + } + manager.clearanceMu.Lock() + invalidBeforeClassification := manager.clearances["account-bound"].invalid + manager.clearanceMu.Unlock() + if invalidBeforeClassification || client.closedIdle != 0 { + t.Fatalf("clearance invalid=%v closedIdle=%d before classification", invalidBeforeClassification, client.closedIdle) + } + + lease.InvalidateClearance() + manager.clearanceMu.Lock() + invalidAfterClassification := manager.clearances["account-bound"].invalid + manager.clearanceMu.Unlock() + if !invalidAfterClassification || client.closedIdle != 1 { + t.Fatalf("clearance invalid=%v closedIdle=%d after classification", invalidAfterClassification, client.closedIdle) + } +} + +func TestStickyLeaseDoesNotRetryAfterRequestWasWritten(t *testing.T) { + client := &scriptedRequestClient{do: func(_ int, request *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(request.Context()) + if trace != nil && trace.WroteRequest != nil { + trace.WroteRequest(httptrace.WroteRequestInfo{}) + } + return nil, errors.New("proxyconnect tcp: connection refused") + }} + lease := &Lease{client: client, sticky: true, proxyPool: true} + request, err := http.NewRequest(http.MethodPost, "https://example.com/generate", bytes.NewReader([]byte("payload"))) + if err != nil { + t.Fatal(err) + } + if _, err := lease.Do(request); err == nil { + t.Fatal("written request error was unexpectedly swallowed") + } + if client.calls != 1 || client.closedIdle != 1 { + t.Fatalf("calls=%d closedIdle=%d", client.calls, client.closedIdle) + } +} + +func TestStickyLeaseKeepsNonReplayableResinResponseReadable(t *testing.T) { + body := io.NopCloser(strings.NewReader("connect failed")) + resinResponse := &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{"X-Resin-Error": []string{"UPSTREAM_CONNECT_FAILED"}}, + Body: body, + } + client := &scriptedRequestClient{do: func(int, *http.Request) (*http.Response, error) { return resinResponse, nil }} + lease := &Lease{client: client, sticky: true, proxyPool: true} + request, err := http.NewRequest(http.MethodPost, "https://example.com/generate", io.NopCloser(strings.NewReader("payload"))) + if err != nil { + t.Fatal(err) + } + response, err := lease.Do(request) + if err != nil || response != resinResponse || client.calls != 1 { + t.Fatalf("response=%#v calls=%d err=%v", response, client.calls, err) + } + if client.closedIdle != 1 { + t.Fatalf("closedIdle=%d, want 1", client.closedIdle) + } + data, err := io.ReadAll(response.Body) + if err != nil || string(data) != "connect failed" { + t.Fatalf("body=%q err=%v", data, err) + } +} diff --git a/backend/internal/infra/egress/tlsclient.go b/backend/internal/infra/egress/tlsclient.go new file mode 100644 index 0000000000000000000000000000000000000000..4b9d2b2a3df9c5e7ae686c9d77ab4c5d66860fcd --- /dev/null +++ b/backend/internal/infra/egress/tlsclient.go @@ -0,0 +1,161 @@ +package egress + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" + "github.com/bogdanfinn/websocket" +) + +type browserClient struct{ inner tlsclient.HttpClient } + +var chromeMajorPattern = regexp.MustCompile(`(?i)Chrome/(\d+)`) + +func (l *Lease) DialWebSocket(ctx context.Context, endpoint string, headers fhttp.Header, handshakeTimeout time.Duration) (*websocket.Conn, *fhttp.Response, error) { + if l == nil || l.browser == nil { + return nil, nil, errors.New("当前出口客户端不支持浏览器 WebSocket") + } + for attempt := 0; ; attempt++ { + dialer := &websocket.Dialer{ + HandshakeTimeout: handshakeTimeout, + NetDialTLSContext: l.browser.inner.GetTLSDialer(), + NetDialContext: l.browser.inner.GetDialer().DialContext, + } + connection, response, err := dialer.DialContext(ctx, endpoint, headers) + if err == nil || !l.proxyPool || attempt >= proxyPoolRetryLimit || !safeProxyConnectionFailure(err, fhttpResponseAsHTTP(response)) { + if l.proxyPool && safeProxyConnectionFailure(err, fhttpResponseAsHTTP(response)) { + l.browser.CloseIdleConnections() + } + if response != nil && response.StatusCode == http.StatusForbidden && l.clearanceManager != nil && l.clearanceKey != "" { + l.clearanceManager.invalidateClearanceKey(l.clearanceKey, l.client) + } + return connection, response, err + } + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + l.browser.CloseIdleConnections() + } +} + +func fhttpResponseAsHTTP(response *fhttp.Response) *http.Response { + if response == nil { + return nil + } + return &http.Response{StatusCode: response.StatusCode, Header: http.Header(response.Header), Body: response.Body} +} + +func newBrowserClient(proxyURL, userAgent string) (*browserClient, error) { + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(7200), + tlsclient.WithClientProfile(browserProfile(userAgent)), + tlsclient.WithNotFollowRedirects(), + } + if proxyURL != "" { + options = append(options, tlsclient.WithProxyUrl(proxyURL)) + } + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) + if err != nil { + return nil, err + } + return &browserClient{inner: client}, nil +} + +func browserProfile(userAgent string) profiles.ClientProfile { + match := chromeMajorPattern.FindStringSubmatch(strings.TrimSpace(userAgent)) + if len(match) == 2 { + if profile, ok := profiles.MappedTLSClients["chrome_"+match[1]]; ok { + return profile + } + major, err := strconv.Atoi(match[1]) + if err == nil { + bestMajor, bestDistance := 0, int(^uint(0)>>1) + for _, candidate := range []int{146, 144, 133, 131, 124, 120, 117} { + distance := candidate - major + if distance < 0 { + distance = -distance + } + if distance < bestDistance { + bestMajor, bestDistance = candidate, distance + } + } + if profile, ok := profiles.MappedTLSClients[fmt.Sprintf("chrome_%d", bestMajor)]; ok { + return profile + } + } + } + return profiles.Chrome_146 +} + +func (c *browserClient) Do(request *http.Request) (*http.Response, error) { + frequest, err := toFHTTPRequest(request) + if err != nil { + return nil, err + } + fresponse, err := c.inner.Do(frequest) + if err != nil { + return nil, err + } + return fromFHTTPResponse(fresponse), nil +} + +func fromFHTTPResponse(fresponse *fhttp.Response) *http.Response { + header := http.Header(fresponse.Header).Clone() + contentLength := fresponse.ContentLength + if fresponse.Uncompressed { + header.Del("Content-Encoding") + header.Del("Content-Length") + contentLength = -1 + } + transferEncoding := append([]string(nil), fresponse.TransferEncoding...) + return &http.Response{ + Status: fresponse.Status, StatusCode: fresponse.StatusCode, Proto: fresponse.Proto, + ProtoMajor: fresponse.ProtoMajor, ProtoMinor: fresponse.ProtoMinor, Header: header, + Body: fresponse.Body, ContentLength: contentLength, TransferEncoding: transferEncoding, + // fhttp 在读取 Body 到 EOF 时原地填充 Trailer,因此这里必须保留共享 map。 + Close: fresponse.Close, Uncompressed: fresponse.Uncompressed, Trailer: http.Header(fresponse.Trailer), + } +} + +func (c *browserClient) CloseIdleConnections() { + if c != nil && c.inner != nil { + c.inner.CloseIdleConnections() + } +} + +func toFHTTPRequest(request *http.Request) (*fhttp.Request, error) { + var body io.Reader + if request.Body != nil { + body = request.Body + } + result, err := fhttp.NewRequestWithContext(request.Context(), request.Method, request.URL.String(), body) + if err != nil { + return nil, err + } + result.ContentLength = request.ContentLength + result.TransferEncoding = append([]string(nil), request.TransferEncoding...) + result.Close = request.Close + if request.Host != "" { + result.Host = request.Host + } + if request.GetBody != nil { + result.GetBody = request.GetBody + } + result.Trailer = fhttp.Header(request.Trailer.Clone()) + for name, values := range request.Header { + for _, value := range values { + result.Header.Add(name, value) + } + } + return result, nil +} diff --git a/backend/internal/infra/egress/tlsclient_test.go b/backend/internal/infra/egress/tlsclient_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7c4fefbf930f5e7ff6a0eb6bd79859884b76d91b --- /dev/null +++ b/backend/internal/infra/egress/tlsclient_test.go @@ -0,0 +1,104 @@ +package egress + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "testing" + + fhttp "github.com/bogdanfinn/fhttp" +) + +func TestToFHTTPRequestPreservesRequestFraming(t *testing.T) { + payload := []byte(`{"message":"hello"}`) + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://grok.com/rest/test", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + request.Host = "grok.com" + request.Header.Set("Content-Type", "application/json") + + converted, err := toFHTTPRequest(request) + if err != nil { + t.Fatal(err) + } + if converted.ContentLength != int64(len(payload)) || len(converted.TransferEncoding) != 0 { + t.Fatalf("contentLength=%d transferEncoding=%v", converted.ContentLength, converted.TransferEncoding) + } + if converted.Host != request.Host || converted.Header.Get("Content-Type") != "application/json" { + t.Fatalf("host=%q headers=%v", converted.Host, converted.Header) + } + if converted.GetBody == nil { + t.Fatal("GetBody was not preserved") + } + body, err := converted.GetBody() + if err != nil { + t.Fatal(err) + } + defer body.Close() + got, err := io.ReadAll(body) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("body=%q err=%v", got, err) + } +} + +func TestFromFHTTPResponseNormalizesAutoDecompressedHeaders(t *testing.T) { + response := fromFHTTPResponse(&fhttp.Response{ + Status: "200 OK", StatusCode: http.StatusOK, Proto: "HTTP/2.0", ProtoMajor: 2, + Header: fhttp.Header{ + "Content-Encoding": []string{"gzip"}, + "Content-Length": []string{"128"}, + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"status":"completed"}`)), ContentLength: 128, Uncompressed: true, + }) + if response.Header.Get("Content-Encoding") != "" || response.Header.Get("Content-Length") != "" { + t.Fatalf("decoded response headers = %#v", response.Header) + } + if response.ContentLength != -1 || !response.Uncompressed { + t.Fatalf("contentLength=%d uncompressed=%v", response.ContentLength, response.Uncompressed) + } + data, err := io.ReadAll(response.Body) + if err != nil || !bytes.Equal(data, []byte(`{"status":"completed"}`)) { + t.Fatalf("body=%q err=%v", data, err) + } +} + +func TestFromFHTTPResponsePreservesCompressedHeaders(t *testing.T) { + response := fromFHTTPResponse(&fhttp.Response{ + Status: "200 OK", StatusCode: http.StatusOK, + Header: fhttp.Header{"Content-Encoding": []string{"gzip"}, "Content-Length": []string{"128"}}, + Body: io.NopCloser(bytes.NewReader(nil)), ContentLength: 128, + }) + if response.Header.Get("Content-Encoding") != "gzip" || response.Header.Get("Content-Length") != "128" || response.ContentLength != 128 { + t.Fatalf("compressed response = headers=%#v contentLength=%d", response.Header, response.ContentLength) + } +} + +func TestFromFHTTPResponseOwnsHeadersAndPreservesDeferredTrailers(t *testing.T) { + source := &fhttp.Response{ + Status: "200 OK", StatusCode: http.StatusOK, + Header: fhttp.Header{"X-Upstream": []string{"original"}}, + Trailer: fhttp.Header{"X-Usage": nil}, + TransferEncoding: []string{"chunked"}, + Body: io.NopCloser(bytes.NewReader(nil)), + } + response := fromFHTTPResponse(source) + + source.Header.Set("X-Upstream", "mutated") + source.TransferEncoding[0] = "identity" + // fhttp 会在读取 Body 到 EOF 时以这种方式填充已声明的 Trailer。 + source.Trailer.Set("X-Usage", "42") + + if response.Header.Get("X-Upstream") != "original" { + t.Fatalf("response header aliases fhttp header: %#v", response.Header) + } + if len(response.TransferEncoding) != 1 || response.TransferEncoding[0] != "chunked" { + t.Fatalf("response transfer encoding aliases fhttp value: %#v", response.TransferEncoding) + } + if response.Trailer.Get("X-Usage") != "42" { + t.Fatalf("deferred trailer was lost: %#v", response.Trailer) + } +} diff --git a/backend/internal/infra/egress/trace.go b/backend/internal/infra/egress/trace.go new file mode 100644 index 0000000000000000000000000000000000000000..4a2d374638c911b0f42a20732aa904f63c0d4953 --- /dev/null +++ b/backend/internal/infra/egress/trace.go @@ -0,0 +1,138 @@ +package egress + +import ( + "context" + "fmt" + "strings" + "sync" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + domain "github.com/chenyme/grok2api/backend/internal/domain/egress" +) + +// Selection is the egress snapshot actually selected for an upstream request. It contains only metadata safe for audit +// and excludes proxy URLs, credentials, User-Agent, and Cookies. +type Selection struct { + NodeID uint64 + NodeName string + Scope domain.Scope + Proxied bool +} + +// Trace retains the most recent actual egress selection per scope. When a request retries egress, audit records the final attempt. +// Web asset archival uses an independent scope and does not overwrite the primary Grok Web inference egress. +type Trace struct { + mu sync.RWMutex + selections map[domain.Scope]Selection +} + +type traceContextKey struct{} +type accountContextKey struct{} +type egressNodeContextKey struct{} + +// WithAccount passes a stable Provider account identity to the egress layer. It is used only to render +// authentication usernames for sticky proxies such as Resin and is never written to upstream headers or audit. +func WithAccount(ctx context.Context, provider string, accountID uint64) context.Context { + if ctx == nil || strings.TrimSpace(provider) == "" || accountID == 0 { + return ctx + } + return WithAccountIdentity(ctx, strings.TrimSpace(provider)+"_"+fmt.Sprintf("%d", accountID)) +} + +// WithCredential passes the stable egress identity of a weakly linked account to Build transport; +// unlinked accounts retain the existing Provider+ID identity. +func WithCredential(ctx context.Context, credential accountdomain.Credential) context.Context { + identity := strings.TrimSpace(credential.EgressIdentity) + if identity == "" { + provider := credential.Provider + if provider == "" { + provider = accountdomain.ProviderBuild + } + return WithEgressNode(WithAccount(ctx, string(provider), credential.ID), credential.EgressNodeID) + } + return WithEgressNode(WithAccountIdentity(ctx, identity), credential.EgressNodeID) +} + +// WithEgressNode attaches the explicitly assigned node ID for transports that +// only receive a request context (notably Grok Build's RoundTripper). +func WithEgressNode(ctx context.Context, nodeID uint64) context.Context { + if ctx == nil || nodeID == 0 { + return ctx + } + return context.WithValue(ctx, egressNodeContextKey{}, nodeID) +} + +func egressNodeFromContext(ctx context.Context) uint64 { + if ctx == nil { + return 0 + } + value, _ := ctx.Value(egressNodeContextKey{}).(uint64) + return value +} + +// EgressNodeFromContext exposes a non-sensitive binding identifier to the +// Build transport without exposing the context key itself. +func EgressNodeFromContext(ctx context.Context) uint64 { return egressNodeFromContext(ctx) } + +// WithAccountIdentity attaches the stable, non-sensitive identity used by +// account-bound proxy templates such as Resin. Providers that represent the +// same upstream login (for example Web and Console sharing one SSO token) can +// deliberately pass the same identity so their proxy and clearance lease is +// not split by the internal provider name. +func WithAccountIdentity(ctx context.Context, identity string) context.Context { + if ctx == nil || strings.TrimSpace(identity) == "" { + return ctx + } + return context.WithValue(ctx, accountContextKey{}, strings.TrimSpace(identity)) +} + +func accountFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + value, _ := ctx.Value(accountContextKey{}).(string) + return strings.TrimSpace(value) +} + +// AccountFromContext exposes the non-sensitive sticky account identity to +// provider transports while keeping the context key private. +func AccountFromContext(ctx context.Context) string { return accountFromContext(ctx) } + +// WithTrace creates or reuses a concurrency-safe egress selection trace for one gateway request. +func WithTrace(ctx context.Context) (context.Context, *Trace) { + if existing := TraceFromContext(ctx); existing != nil { + return ctx, existing + } + trace := &Trace{selections: make(map[domain.Scope]Selection)} + return context.WithValue(ctx, traceContextKey{}, trace), trace +} + +// TraceFromContext returns the egress trace from context, or nil when none is configured. +func TraceFromContext(ctx context.Context) *Trace { + if ctx == nil { + return nil + } + trace, _ := ctx.Value(traceContextKey{}).(*Trace) + return trace +} + +// Selection returns a safe snapshot of the most recent actual egress selection for a scope. +func (t *Trace) Selection(scope domain.Scope) (Selection, bool) { + if t == nil { + return Selection{}, false + } + t.mu.RLock() + defer t.mu.RUnlock() + value, ok := t.selections[scope] + return value, ok +} + +func recordSelection(ctx context.Context, value Selection) { + trace := TraceFromContext(ctx) + if trace == nil { + return + } + trace.mu.Lock() + trace.selections[value.Scope] = value + trace.mu.Unlock() +} diff --git a/backend/internal/infra/media/local_store.go b/backend/internal/infra/media/local_store.go new file mode 100644 index 0000000000000000000000000000000000000000..9167ac8f7995b8006b3986c32910b69fdddc33d3 --- /dev/null +++ b/backend/internal/infra/media/local_store.go @@ -0,0 +1,247 @@ +package media + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// LocalStore 将媒体对象限制在单一根目录内,并使用临时文件与原子硬链接完成提交。 +type LocalStore struct { + root string + removeTemporary func(string) error +} + +func NewLocalStore(root string) (*LocalStore, error) { + absolute, err := filepath.Abs(strings.TrimSpace(root)) + if err != nil || strings.TrimSpace(root) == "" { + return nil, fmt.Errorf("媒体存储目录无效") + } + absolute = filepath.Clean(absolute) + if err := os.MkdirAll(absolute, 0o700); err != nil { + return nil, fmt.Errorf("创建媒体存储目录: %w", err) + } + return &LocalStore{root: absolute, removeTemporary: os.Remove}, nil +} + +func (s *LocalStore) SaveImage(ctx context.Context, id, mimeType string, data []byte) (string, error) { + return s.saveObject(ctx, "images", ".image-*", id, mimeType, data, imageExtension) +} + +// SaveVideo 将视频对象写入 videos/ 子目录,提交语义与图片一致(原子硬链接、no-replace)。 +func (s *LocalStore) SaveVideo(ctx context.Context, id, mimeType string, data []byte) (string, error) { + return s.saveObject(ctx, "videos", ".video-*", id, mimeType, data, videoExtension) +} + +// BeginVideoUpload 创建视频临时文件,供流式限长写入后 CommitVideoUpload 提交。 +func (s *LocalStore) BeginVideoUpload(ctx context.Context, id, mimeType string) (tempPath, storageKey string, err error) { + if err := ctx.Err(); err != nil { + return "", "", err + } + extension, ok := videoExtension(mimeType) + if !ok || len(id) < 2 { + return "", "", fmt.Errorf("视频存储参数无效") + } + storageKey = filepath.ToSlash(filepath.Join("videos", id[:2], id+extension)) + path, err := s.resolve(storageKey) + if err != nil { + return "", "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", "", fmt.Errorf("创建视频目录: %w", err) + } + // 禁止覆盖已有文件:目标已存在时拒绝新的上传会话。 + if _, statErr := os.Stat(path); statErr == nil { + return "", "", fmt.Errorf("视频对象已存在") + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", "", statErr + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".video-*") + if err != nil { + return "", "", fmt.Errorf("创建视频临时文件: %w", err) + } + tempPath = temporary.Name() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + _ = s.removeTemporary(tempPath) + return "", "", err + } + if err := temporary.Close(); err != nil { + _ = s.removeTemporary(tempPath) + return "", "", err + } + return tempPath, storageKey, nil +} + +// CommitVideoUpload 将临时文件原子提交到 storageKey(硬链接 no-replace)。 +func (s *LocalStore) CommitVideoUpload(ctx context.Context, tempPath, storageKey string) error { + if err := ctx.Err(); err != nil { + return err + } + path, err := s.resolve(storageKey) + if err != nil { + return err + } + // 确保临时文件已落盘。 + file, err := os.Open(tempPath) + if err != nil { + return fmt.Errorf("打开视频临时文件: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("同步视频文件: %w", err) + } + if err := file.Close(); err != nil { + return err + } + if err := os.Link(tempPath, path); err != nil { + return fmt.Errorf("提交视频文件: %w", err) + } + if cleanupErr := s.removeTemporary(tempPath); cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) { + slog.Warn("media_temp_cleanup_failed", "path", tempPath, "error", cleanupErr) + } + return nil +} + +// AbortVideoUpload 清理未提交的临时文件。 +func (s *LocalStore) AbortVideoUpload(_ context.Context, tempPath string) error { + if strings.TrimSpace(tempPath) == "" { + return nil + } + if err := s.removeTemporary(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func (s *LocalStore) saveObject(ctx context.Context, kindDir, tempPattern, id, mimeType string, data []byte, extFn func(string) (string, bool)) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + extension, ok := extFn(mimeType) + if !ok || len(id) < 2 { + return "", fmt.Errorf("媒体存储参数无效") + } + storageKey := filepath.ToSlash(filepath.Join(kindDir, id[:2], id+extension)) + path, err := s.resolve(storageKey) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("创建媒体目录: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), tempPattern) + if err != nil { + return "", fmt.Errorf("创建媒体临时文件: %w", err) + } + temporaryPath := temporary.Name() + cleanupPending := true + defer func() { + _ = temporary.Close() + if cleanupPending { + if cleanupErr := s.removeTemporary(temporaryPath); cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) { + slog.Warn("media_temp_cleanup_failed", "path", temporaryPath, "error", cleanupErr) + } + } + }() + if err := temporary.Chmod(0o600); err != nil { + return "", err + } + if _, err := temporary.Write(data); err != nil { + return "", fmt.Errorf("写入媒体: %w", err) + } + if err := temporary.Sync(); err != nil { + return "", fmt.Errorf("同步媒体文件: %w", err) + } + if err := temporary.Close(); err != nil { + return "", fmt.Errorf("关闭媒体文件: %w", err) + } + // 硬链接提交具有 no-replace 语义,极端 ID 冲突时不会覆盖已有对象。 + if err := os.Link(temporaryPath, path); err != nil { + return "", fmt.Errorf("提交媒体文件: %w", err) + } + cleanupErr := s.removeTemporary(temporaryPath) + cleanupPending = cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) + return storageKey, nil +} + +func (s *LocalStore) Open(ctx context.Context, storageKey string) (io.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + path, err := s.resolve(storageKey) + if err != nil { + return nil, err + } + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, os.ErrNotExist + } + if err != nil { + return nil, fmt.Errorf("打开媒体文件: %w", err) + } + return file, nil +} + +func (s *LocalStore) Delete(ctx context.Context, storageKey string) error { + if err := ctx.Err(); err != nil { + return err + } + path, err := s.resolve(storageKey) + if err != nil { + return err + } + if err := os.Remove(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return os.ErrNotExist + } + return fmt.Errorf("删除媒体文件: %w", err) + } + return nil +} + +func (s *LocalStore) resolve(storageKey string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(strings.TrimSpace(storageKey))) + if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("媒体存储路径无效") + } + full := filepath.Join(s.root, clean) + relative, err := filepath.Rel(s.root, full) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("媒体存储路径越界") + } + return full, nil +} + +func imageExtension(mimeType string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/jpeg": + return ".jpg", true + case "image/png": + return ".png", true + case "image/webp": + return ".webp", true + case "image/gif": + return ".gif", true + default: + return "", false + } +} + +func videoExtension(mimeType string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "video/mp4": + return ".mp4", true + case "video/webm": + return ".webm", true + case "video/quicktime": + return ".mov", true + default: + return "", false + } +} diff --git a/backend/internal/infra/media/local_store_test.go b/backend/internal/infra/media/local_store_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a93734faa955024ca0fae6414a7c7b16d85f7e57 --- /dev/null +++ b/backend/internal/infra/media/local_store_test.go @@ -0,0 +1,83 @@ +package media + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +func TestLocalStoreWritesAndRejectsTraversal(t *testing.T) { + store, err := NewLocalStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + key, err := store.SaveImage(context.Background(), "img_abcdefghijklmnopqrstuvwxyz", "image/jpeg", []byte("image")) + if err != nil { + t.Fatal(err) + } + body, err := store.Open(context.Background(), key) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(body) + _ = body.Close() + if err != nil || string(data) != "image" { + t.Fatalf("stored image = %q, err=%v", data, err) + } + if _, err := store.SaveImage(context.Background(), "img_abcdefghijklmnopqrstuvwxyz", "image/jpeg", []byte("replacement")); err == nil { + t.Fatal("existing image was overwritten") + } + if _, err := store.Open(context.Background(), "../outside.jpg"); err == nil { + t.Fatal("path traversal was accepted") + } + if err := store.Delete(context.Background(), key); err != nil { + t.Fatal(err) + } + if err := store.Delete(context.Background(), key); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing object delete error = %v", err) + } +} + +func TestLocalStoreRetriesTemporaryCleanupWithoutDeletingCommittedImage(t *testing.T) { + root := t.TempDir() + store, err := NewLocalStore(root) + if err != nil { + t.Fatal(err) + } + removeTemporary := store.removeTemporary + removeAttempts := 0 + store.removeTemporary = func(path string) error { + removeAttempts++ + if removeAttempts == 1 { + return errors.New("transient cleanup failure") + } + return removeTemporary(path) + } + + key, err := store.SaveImage(context.Background(), "img_cleanup_retry_0000000001", "image/png", []byte("image")) + if err != nil { + t.Fatal(err) + } + if removeAttempts != 2 { + t.Fatalf("temporary cleanup attempts = %d, want 2", removeAttempts) + } + body, err := store.Open(context.Background(), key) + if err != nil { + t.Fatal(err) + } + data, readErr := io.ReadAll(body) + _ = body.Close() + if readErr != nil || string(data) != "image" { + t.Fatalf("committed image = %q, err = %v", data, readErr) + } + temporaryFiles, err := filepath.Glob(filepath.Join(root, "images", "im", ".image-*")) + if err != nil { + t.Fatal(err) + } + if len(temporaryFiles) != 0 { + t.Fatalf("temporary files were not cleaned: %#v", temporaryFiles) + } +} diff --git a/backend/internal/infra/observability/logger.go b/backend/internal/infra/observability/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..6955ef301f14ea8072ecee6e08149b878b17390c --- /dev/null +++ b/backend/internal/infra/observability/logger.go @@ -0,0 +1,11 @@ +package observability + +import ( + "log/slog" + "os" +) + +// NewLogger 创建结构化 JSON 日志器。 +func NewLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) +} diff --git a/backend/internal/infra/persistence/relational/account_auto_clean_test.go b/backend/internal/infra/persistence/relational/account_auto_clean_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e315ae17ea17fb7f617b991e86c5f214d45d8d62 --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_auto_clean_test.go @@ -0,0 +1,252 @@ +package relational + +import ( + "context" + "path/filepath" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/media" +) + +func TestDeleteAutoCleanReauthBatchSkipsActiveMediaJobs(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 20, 0, 0, 0, time.UTC) + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "auto-clean-media.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + + blocked, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "blocked", SourceKey: "blocked", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + if err != nil { + t.Fatal(err) + } + free, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "free", SourceKey: "free", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + if err != nil { + t.Fatal(err) + } + + key := clientKeyModel{Name: "auto-clean-key", Prefix: "auto-clean-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + accountID := blocked.ID + job := mediaJobModel{ + ID: "media_job_auto_clean_block", RequestID: "req_auto_clean_block", + ClientKeyID: key.ID, ClientKeyName: "key", AccountID: &accountID, AccountName: "blocked", + EgressScope: "grok_build", EgressMode: "direct", Provider: string(accountdomain.ProviderBuild), + Model: "video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "x", Seconds: 1, Size: "16:9", + Quality: "720p", Status: string(media.StatusInProgress), Progress: 10, InputJSON: "{}", + CreatedAt: now, UpdatedAt: now, + } + if err := database.db.WithContext(ctx).Create(&job).Error; err != nil { + t.Fatal(err) + } + + candidates, err := repo.ListAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, 0, 100) + if err != nil { + t.Fatal(err) + } + deleted, err := repo.DeleteAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, []uint64{blocked.ID, free.ID}) + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 || candidates[0] != free.ID { + t.Fatalf("candidates=%d deleted=%v", len(candidates), deleted) + } + if len(deleted) != 1 || deleted[0] != free.ID { + t.Fatalf("deleted=%v want only free=%d", deleted, free.ID) + } + if _, err := repo.Get(ctx, blocked.ID); err != nil { + t.Fatalf("blocked account should remain: %v", err) + } + if _, err := repo.Get(ctx, free.ID); err == nil { + t.Fatal("free account should be deleted") + } +} + +func ptrTime(value time.Time) *time.Time { return &value } + +func TestDeleteAutoCleanSkipsNullAnchorAndQueuedMedia(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 20, 21, 0, 0, 0, time.UTC) + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "auto-clean-null.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + + // 显式清空 reauth_marked_at:历史脏数据不得被删。 + nullAnchor, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "null-anchor", SourceKey: "null-anchor", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + if err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", nullAnchor.ID).Update("reauth_marked_at", nil).Error; err != nil { + t.Fatal(err) + } + + queued, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "queued", SourceKey: "queued", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "auto-clean-key-q", Prefix: "auto-clean-key-q", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + qid := queued.ID + job := mediaJobModel{ + ID: "media_job_auto_clean_queued", RequestID: "req_auto_clean_queued", + ClientKeyID: key.ID, ClientKeyName: "key", AccountID: &qid, AccountName: "queued", + EgressScope: "grok_build", EgressMode: "direct", Provider: string(accountdomain.ProviderBuild), + Model: "video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "x", Seconds: 1, Size: "16:9", + Quality: "720p", Status: string(media.StatusQueued), Progress: 0, InputJSON: "{}", + CreatedAt: now, UpdatedAt: now, + } + if err := database.db.WithContext(ctx).Create(&job).Error; err != nil { + t.Fatal(err) + } + + candidates, err := repo.ListAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, 0, 100) + if err != nil { + t.Fatal(err) + } + deleted, err := repo.DeleteAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, []uint64{queued.ID}) + if err != nil { + t.Fatal(err) + } + if len(candidates) != 0 || len(deleted) != 0 { + t.Fatalf("candidates=%d deleted=%v (null anchor excluded, queued skipped)", len(candidates), deleted) + } + if _, err := repo.Get(ctx, nullAnchor.ID); err != nil { + t.Fatalf("null-anchor account missing: %v", err) + } + if _, err := repo.Get(ctx, queued.ID); err != nil { + t.Fatalf("queued account missing: %v", err) + } +} + +func TestDeleteAutoCleanRevalidatesStatusAfterListing(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 21, 1, 0, 0, 0, time.UTC) + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "auto-clean-revalidate.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + value, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "recovered", SourceKey: "recovered", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)), + }) + if err != nil { + t.Fatal(err) + } + candidates, err := repo.ListAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, 0, 100) + if err != nil || len(candidates) != 1 { + t.Fatalf("candidates=%v err=%v", candidates, err) + } + value.AuthStatus = accountdomain.AuthStatusActive + if _, err := repo.Update(ctx, value); err != nil { + t.Fatal(err) + } + deleted, err := repo.DeleteAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, candidates) + if err != nil { + t.Fatal(err) + } + if len(deleted) != 0 { + t.Fatalf("recovered account deleted: %v", deleted) + } + if refreshed, err := repo.Get(ctx, value.ID); err != nil || refreshed.AuthStatus != accountdomain.AuthStatusActive || refreshed.ReauthMarkedAt != nil { + t.Fatalf("recovered account=%#v err=%v", refreshed, err) + } +} + +func TestReauthMarkedAtMigrationBackfillsAndIsIdempotent(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "auto-clean-migration.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + markedAt := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC) + reauth, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "legacy-reauth", SourceKey: "legacy-reauth", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired, + }) + if err != nil { + t.Fatal(err) + } + active, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, Name: "legacy-active", SourceKey: "legacy-active", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", reauth.ID).Update("updated_at", markedAt).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS idx_accounts_auto_clean_reauth").Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS idx_accounts_auto_clean_reauth_cursor").Error; err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&accountModel{}, "ReauthMarkedAt"); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("migration is not idempotent: %v", err) + } + reauthAfter, err := repo.Get(ctx, reauth.ID) + if err != nil { + t.Fatal(err) + } + activeAfter, err := repo.Get(ctx, active.ID) + if err != nil { + t.Fatal(err) + } + if reauthAfter.ReauthMarkedAt == nil || !reauthAfter.ReauthMarkedAt.Equal(markedAt) { + t.Fatalf("reauth anchor=%v want=%s", reauthAfter.ReauthMarkedAt, markedAt) + } + if activeAfter.ReauthMarkedAt != nil { + t.Fatalf("active anchor=%v", activeAfter.ReauthMarkedAt) + } +} diff --git a/backend/internal/infra/persistence/relational/account_batch_update_test.go b/backend/internal/infra/persistence/relational/account_batch_update_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9c8fe4e48d4adf19aec49d22ce16e8481c30dc4b --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_batch_update_test.go @@ -0,0 +1,112 @@ +package relational + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestAccountRepositoryUpdateManyChunksLargeAccountPool(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewAccountRepository(database) + rows := seedBatchUpdateAccounts(t, database, 2001) + ids := accountModelIDs(rows) + maxConcurrent := 3 + + updated, err := repo.UpdateMany(ctx, account.ProviderBuild, ids, repository.AccountUpdates{MaxConcurrent: &maxConcurrent}) + if err != nil { + t.Fatal(err) + } + if updated != int64(len(ids)) { + t.Fatalf("updated = %d, want %d", updated, len(ids)) + } + var count int64 + if err := database.db.Model(&accountModel{}).Where("max_concurrent = ?", maxConcurrent).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != int64(len(ids)) { + t.Fatalf("stored count = %d, want %d", count, len(ids)) + } +} + +func TestAccountRepositoryUpdateManyRollsBackAllChunks(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewAccountRepository(database) + rows := seedBatchUpdateAccounts(t, database, accountUpdateBatchSize+1) + ids := accountModelIDs(rows) + failingID := ids[len(ids)-1] + trigger := fmt.Sprintf(`CREATE TRIGGER fail_batch_account_update BEFORE UPDATE OF max_concurrent ON provider_accounts WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'forced batch failure'); END`, failingID) + if err := database.db.Exec(trigger).Error; err != nil { + t.Fatal(err) + } + maxConcurrent := 3 + + updated, err := repo.UpdateMany(ctx, account.ProviderBuild, ids, repository.AccountUpdates{MaxConcurrent: &maxConcurrent}) + if err == nil || updated != 0 { + t.Fatalf("updated = %d, error = %v", updated, err) + } + var changed int64 + if err := database.db.Model(&accountModel{}).Where("id IN ? AND max_concurrent = ?", ids, maxConcurrent).Count(&changed).Error; err != nil { + t.Fatal(err) + } + if changed != 0 { + t.Fatalf("transaction left %d changed rows", changed) + } +} + +func TestAccountRepositoryUpdateManyRejectsMixedProviderBeforeWriting(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewAccountRepository(database) + rows := seedBatchUpdateAccounts(t, database, accountUpdateBatchSize+1) + ids := accountModelIDs(rows) + if err := database.db.Model(&accountModel{}).Where("id = ?", ids[len(ids)-1]).Update("provider", account.ProviderWeb).Error; err != nil { + t.Fatal(err) + } + maxConcurrent := 3 + + updated, err := repo.UpdateMany(ctx, account.ProviderBuild, ids, repository.AccountUpdates{MaxConcurrent: &maxConcurrent}) + if !errors.Is(err, repository.ErrAccountPoolMismatch) || updated != 0 { + t.Fatalf("updated = %d, error = %v", updated, err) + } + var changed int64 + if err := database.db.Model(&accountModel{}).Where("id IN ? AND max_concurrent = ?", ids, maxConcurrent).Count(&changed).Error; err != nil { + t.Fatal(err) + } + if changed != 0 { + t.Fatalf("provider mismatch left %d changed rows", changed) + } +} + +func seedBatchUpdateAccounts(t *testing.T, database *Database, count int) []accountModel { + t.Helper() + now := time.Now().UTC() + rows := make([]accountModel, count) + for index := range rows { + rows[index] = accountModel{ + IdentityKey: fmt.Sprintf("%064x", index+1), + Provider: string(account.ProviderBuild), Name: fmt.Sprintf("batch-%d", index+1), SourceKey: fmt.Sprintf("batch-%d", index+1), + Enabled: true, AuthStatus: string(account.AuthStatusActive), Priority: account.DefaultPriority, MaxConcurrent: account.DefaultMaxConcurrent, + BuildRouteMode: string(account.BuildRouteAuto), CreatedAt: now, UpdatedAt: now, + } + } + if err := database.db.CreateInBatches(&rows, 200).Error; err != nil { + t.Fatal(err) + } + return rows +} + +func accountModelIDs(rows []accountModel) []uint64 { + ids := make([]uint64, len(rows)) + for index := range rows { + ids[index] = rows[index].ID + } + return ids +} diff --git a/backend/internal/infra/persistence/relational/account_links.go b/backend/internal/infra/persistence/relational/account_links.go new file mode 100644 index 0000000000000000000000000000000000000000..2928dc7b999512d25dda087689266d918a03e4a7 --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_links.go @@ -0,0 +1,776 @@ +package relational + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + accountLinkAdvisoryNamespace int32 = 0x47524F4B // GROK + accountLinkAdvisoryOperation int32 = 0x4C4E4B44 // LNKD + accountLinkLockTimeout = 5 * time.Second +) + +// lockAccountLinkMutation serializes link-table writes and account deletions on PostgreSQL. +// These maintenance operations are rare, and a shared transaction-scoped lock prevents +// link snapshot races and cyclic account/FK row-lock acquisition across mutation paths. +func lockAccountLinkMutation(tx *gorm.DB) error { + return lockAccountLinkMutationWithTimeout(tx, accountLinkLockTimeout) +} + +func lockAccountLinkMutationWithTimeout(tx *gorm.DB, timeout time.Duration) error { + if tx.Dialector.Name() != "postgres" { + return nil + } + if timeout <= 0 { + timeout = accountLinkLockTimeout + } + parentCtx := tx.Statement.Context + lockCtx, cancel := context.WithTimeout(parentCtx, timeout) + defer cancel() + err := tx.WithContext(lockCtx).Exec("SELECT pg_advisory_xact_lock(?, ?)", accountLinkAdvisoryNamespace, accountLinkAdvisoryOperation).Error + if err != nil && parentCtx.Err() != nil { + return parentCtx.Err() + } + if err != nil && errors.Is(lockCtx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("%w: 账号关联关系正在变更,请稍后重试", repository.ErrConflict) + } + return err +} + +func linkWebToConsole(tx *gorm.DB, webAccountID, consoleAccountID uint64) error { + var webAccount, consoleAccount accountModel + if err := tx.Select("id", "provider").First(&webAccount, webAccountID).Error; err != nil { + return err + } + if err := tx.Select("id", "provider").First(&consoleAccount, consoleAccountID).Error; err != nil { + return err + } + if webAccount.Provider != string(account.ProviderWeb) || consoleAccount.Provider != string(account.ProviderConsole) { + return repository.ErrConflict + } + var existing webConsoleAccountLinkModel + err := tx.Where("web_account_id = ? OR console_account_id = ?", webAccountID, consoleAccountID).First(&existing).Error + if err == nil { + if existing.WebAccountID == webAccountID && existing.ConsoleAccountID == consoleAccountID { + return nil + } + slog.Debug("account_provider_link_reconcile_skipped", + "relation", "web_console", + "reason", "existing_relation_conflict", + "web_account_id", webAccountID, + "console_account_id", consoleAccountID, + "existing_web_account_id", existing.WebAccountID, + "existing_console_account_id", existing.ConsoleAccountID, + ) + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&webConsoleAccountLinkModel{WebAccountID: webAccountID, ConsoleAccountID: consoleAccountID, CreatedAt: time.Now().UTC()}).Error +} + +func (r *AccountRepository) UpdateIdentityMetadata(ctx context.Context, accountID uint64, email, userID, teamID string) error { + if accountID == 0 { + return repository.ErrNotFound + } + updates := make(map[string]any, 3) + if email = strings.TrimSpace(email); email != "" { + updates["email"] = email + } + if userID = strings.TrimSpace(userID); userID != "" { + updates["user_id"] = userID + } + if teamID = strings.TrimSpace(teamID); teamID != "" { + updates["team_id"] = teamID + } + if len(updates) == 0 { + return nil + } + result := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", accountID).Updates(updates) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, AccountID: accountID}) + return nil +} + +// ReconcileProviderLinks 只建立无歧义的高可信关系;已有不同关系和多候选均保持不变。 +func (r *AccountRepository) ReconcileProviderLinks(ctx context.Context, accountID uint64) error { + if accountID == 0 { + return repository.ErrNotFound + } + err := mapError(r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + var value accountModel + if err := tx.Select("id", "provider", "source_key", "user_id", "team_id").First(&value, accountID).Error; err != nil { + return err + } + switch account.Provider(value.Provider) { + case account.ProviderWeb: + if consoleSource, ok := matchingConsoleSourceKey(value.SourceKey); ok { + if candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_console", account.ProviderConsole, "source_key = ?", consoleSource); err != nil { + return err + } else if found { + if err := linkWebToConsole(tx, value.ID, candidate.ID); err != nil { + return err + } + } + } + if err := reconcileWebConsoleByUserID(tx, value, true); err != nil { + return err + } + return reconcileWebBuildByUserID(tx, value, true) + case account.ProviderConsole: + if webSource, ok := matchingWebSourceKey(value.SourceKey); ok { + if candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_console", account.ProviderWeb, "source_key = ?", webSource); err != nil { + return err + } else if found { + return linkWebToConsole(tx, candidate.ID, value.ID) + } + } + return reconcileWebConsoleByUserID(tx, value, false) + case account.ProviderBuild: + return reconcileWebBuildByUserID(tx, value, false) + } + return nil + })) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountCredentialChanged, AccountID: accountID}) + } + return err +} + +func reconcileWebConsoleByUserID(tx *gorm.DB, value accountModel, valueIsWeb bool) error { + userID := strings.TrimSpace(value.UserID) + if userID == "" { + return nil + } + provider := account.ProviderWeb + if valueIsWeb { + provider = account.ProviderConsole + } + candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_console", provider, "user_id = ?", userID) + if err != nil || !found { + return err + } + webID, consoleID := candidate.ID, value.ID + if valueIsWeb { + webID, consoleID = value.ID, candidate.ID + } + return linkWebToConsole(tx, webID, consoleID) +} + +func reconcileWebBuildByUserID(tx *gorm.DB, value accountModel, valueIsWeb bool) error { + userID := strings.TrimSpace(value.UserID) + if userID == "" { + return nil + } + provider := account.ProviderWeb + if valueIsWeb { + provider = account.ProviderBuild + } + candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_build", provider, "user_id = ?", userID) + if err != nil || !found { + return err + } + webID, buildID := candidate.ID, value.ID + if valueIsWeb { + webID, buildID = value.ID, candidate.ID + } + return linkWebToBuildIfUnambiguous(tx, webID, buildID) +} + +func linkWebToBuildIfUnambiguous(tx *gorm.DB, webAccountID, buildAccountID uint64) error { + var existing accountProviderLinkModel + err := tx.Where("web_account_id = ? OR build_account_id = ?", webAccountID, buildAccountID).First(&existing).Error + if err == nil { + if existing.WebAccountID != webAccountID || existing.BuildAccountID != buildAccountID { + slog.Debug("account_provider_link_reconcile_skipped", + "relation", "web_build", + "reason", "existing_relation_conflict", + "web_account_id", webAccountID, + "build_account_id", buildAccountID, + "existing_web_account_id", existing.WebAccountID, + "existing_build_account_id", existing.BuildAccountID, + ) + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&accountProviderLinkModel{WebAccountID: webAccountID, BuildAccountID: buildAccountID, CreatedAt: time.Now().UTC()}).Error +} + +func uniqueLinkCandidate(tx *gorm.DB, sourceAccountID uint64, relation string, provider account.Provider, predicate string, args ...any) (accountModel, bool, error) { + var values []accountModel + err := tx.Select("id", "provider", "source_key", "user_id", "team_id"). + Where("provider = ?", provider).Where(predicate, args...).Limit(2).Find(&values).Error + if err != nil { + return accountModel{}, false, err + } + if len(values) > 1 { + slog.Debug("account_provider_link_reconcile_skipped", + "relation", relation, + "reason", "ambiguous_candidates", + "account_id", sourceAccountID, + "candidate_provider", provider, + "candidate_count", len(values), + ) + } + if len(values) != 1 { + return accountModel{}, false, nil + } + return values[0], true, nil +} + +func matchingConsoleSourceKey(webSourceKey string) (string, bool) { + if _, ok := egressIdentityFromWebSourceKey(webSourceKey); !ok { + return "", false + } + return "console-" + strings.TrimSpace(webSourceKey), true +} + +func matchingWebSourceKey(consoleSourceKey string) (string, bool) { + value := strings.TrimSpace(consoleSourceKey) + if !strings.HasPrefix(value, "console-") { + return "", false + } + webSource := strings.TrimPrefix(value, "console-") + if _, ok := egressIdentityFromWebSourceKey(webSource); !ok { + return "", false + } + return webSource, true +} + +// ResolveLinkedDeleteIDs expands root IDs with linked peers from binding tables only. +// Paths are fixed: Web↔Build and Web↔Console one hop; Build↔Console is two hops via Web +// without deleting the intermediate Web unless it is also a target/root. +// Preview path only — deletes must use DeleteManyWithLinked so resolve+delete share one transaction. +func (r *AccountRepository) ResolveLinkedDeleteIDs(ctx context.Context, providerValue account.Provider, rootIDs []uint64, targets []account.Provider) (repository.LinkedDeleteResolution, error) { + return resolveLinkedDeleteIDs(r.db.db.WithContext(ctx), providerValue, rootIDs, targets) +} + +// resolveLinkedDeleteIDs is the shared expander used by preview and by the delete transactions. +// It also returns RootGroups for group-level skips and PeerProviders for per-provider delete counts. +func resolveLinkedDeleteIDs(db *gorm.DB, providerValue account.Provider, rootIDs []uint64, targets []account.Provider) (repository.LinkedDeleteResolution, error) { + result := repository.LinkedDeleteResolution{ + LinkedByProvider: map[account.Provider]int{}, + RootGroups: map[uint64][]uint64{}, + PeerProviders: map[uint64]account.Provider{}, + } + if !providerValue.IsValid() { + return result, fmt.Errorf("账号来源无效") + } + roots := uniqueSortedIDs(rootIDs) + result.RootIDs = append([]uint64(nil), roots...) + if len(roots) == 0 { + result.FinalIDs = nil + return result, nil + } + targetSet := make(map[account.Provider]struct{}, len(targets)) + for _, target := range targets { + if !target.IsValid() { + return result, fmt.Errorf("关联删除目标无效") + } + if target == providerValue { + return result, fmt.Errorf("关联删除目标不能包含当前号池") + } + targetSet[target] = struct{}{} + } + if len(targetSet) == 0 { + result.FinalIDs = append([]uint64(nil), roots...) + return result, nil + } + + finalSet := make(map[uint64]struct{}, len(roots)*2) + for _, id := range roots { + finalSet[id] = struct{}{} + } + linkedCounts := map[account.Provider]int{} + addPairs := func(peerProvider account.Provider, pairs []accountLinkPair) { + for _, pair := range pairs { + if pair.RootID == 0 || pair.PeerID == 0 { + continue + } + // Skip roots and peers already claimed by an earlier 1:1 group. + if _, exists := finalSet[pair.PeerID]; exists { + continue + } + finalSet[pair.PeerID] = struct{}{} + linkedCounts[peerProvider]++ + result.RootGroups[pair.RootID] = append(result.RootGroups[pair.RootID], pair.PeerID) + result.PeerProviders[pair.PeerID] = peerProvider + } + } + + switch providerValue { + case account.ProviderWeb: + if _, ok := targetSet[account.ProviderBuild]; ok { + pairs, err := listWebBuildPairs(db, roots) + if err != nil { + return result, err + } + addPairs(account.ProviderBuild, pairs) + } + if _, ok := targetSet[account.ProviderConsole]; ok { + pairs, err := listWebConsolePairs(db, roots) + if err != nil { + return result, err + } + addPairs(account.ProviderConsole, pairs) + } + case account.ProviderBuild: + if _, ok := targetSet[account.ProviderWeb]; ok { + pairs, err := listBuildWebPairs(db, roots) + if err != nil { + return result, err + } + addPairs(account.ProviderWeb, pairs) + } + if _, ok := targetSet[account.ProviderConsole]; ok { + pairs, err := listBuildConsolePairs(db, roots) + if err != nil { + return result, err + } + addPairs(account.ProviderConsole, pairs) + } + case account.ProviderConsole: + if _, ok := targetSet[account.ProviderWeb]; ok { + pairs, err := listConsoleWebPairs(db, roots) + if err != nil { + return result, err + } + addPairs(account.ProviderWeb, pairs) + } + if _, ok := targetSet[account.ProviderBuild]; ok { + pairs, err := listConsoleBuildPairs(db, roots) + if err != nil { + return result, err + } + addPairs(account.ProviderBuild, pairs) + } + default: + return result, fmt.Errorf("账号来源无效") + } + + for provider, count := range linkedCounts { + if count > 0 { + result.LinkedByProvider[provider] = count + } + } + result.FinalIDs = make([]uint64, 0, len(finalSet)) + for id := range finalSet { + result.FinalIDs = append(result.FinalIDs, id) + } + sort.Slice(result.FinalIDs, func(i, j int) bool { return result.FinalIDs[i] < result.FinalIDs[j] }) + return result, nil +} + +// deleteLinkedGroupsTx expands links, locks the final set, checks media jobs, and deletes rows while roots are locked. +// skipMedia=false rejects the operation; skipMedia=true skips each blocked root group as a unit. +func deleteLinkedGroupsTx(tx *gorm.DB, providerValue account.Provider, lockedRoots []uint64, targets []account.Provider, skipMedia bool) (repository.LinkedDeleteOutcome, error) { + outcome := repository.LinkedDeleteOutcome{ + LinkedDeletedByProvider: map[account.Provider]int64{}, + } + + // 1) Expand links while the caller holds root row locks. + var resolution repository.LinkedDeleteResolution + var err error + if len(targets) == 0 { + resolution = repository.LinkedDeleteResolution{ + RootIDs: append([]uint64(nil), lockedRoots...), + FinalIDs: append([]uint64(nil), lockedRoots...), + LinkedByProvider: map[account.Provider]int{}, + RootGroups: map[uint64][]uint64{}, + PeerProviders: map[uint64]account.Provider{}, + } + } else { + if !providerValue.IsValid() { + return outcome, fmt.Errorf("账号来源无效") + } + resolution, err = resolveLinkedDeleteIDs(tx, providerValue, lockedRoots, targets) + if err != nil { + return outcome, err + } + } + outcome.Resolution = resolution + if len(resolution.FinalIDs) == 0 { + return outcome, nil + } + + // 2) Lock roots and linked peers in the final set. + var lockedFinal []uint64 + if err := tx.Model(&accountModel{}).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id IN ?", resolution.FinalIDs).Order("id ASC").Pluck("id", &lockedFinal).Error; err != nil { + return outcome, err + } + if len(lockedFinal) == 0 { + return outcome, nil + } + outcome.Resolution.FinalIDs = append([]uint64(nil), lockedFinal...) + + // 3) Apply active-media protection. + deletable := lockedFinal + if skipMedia { + var blocked []uint64 + if err := tx.Model(&mediaJobModel{}). + Where("account_id IN ? AND status IN ?", lockedFinal, activeMediaJobStatuses()). + Pluck("account_id", &blocked).Error; err != nil { + return outcome, err + } + if len(blocked) > 0 { + // Map blocked peers back to roots and remove each complete group. + peerRoot := make(map[uint64]uint64, len(resolution.PeerProviders)) + for root, peers := range resolution.RootGroups { + for _, peer := range peers { + peerRoot[peer] = root + } + } + skippedRootSet := map[uint64]struct{}{} + for _, id := range blocked { + if root, ok := peerRoot[id]; ok { + skippedRootSet[root] = struct{}{} + } else { + skippedRootSet[id] = struct{}{} + } + } + removed := map[uint64]struct{}{} + for root := range skippedRootSet { + removed[root] = struct{}{} + for _, peer := range resolution.RootGroups[root] { + removed[peer] = struct{}{} + } + outcome.SkippedRoots = append(outcome.SkippedRoots, root) + } + sort.Slice(outcome.SkippedRoots, func(i, j int) bool { return outcome.SkippedRoots[i] < outcome.SkippedRoots[j] }) + filtered := make([]uint64, 0, len(lockedFinal)) + for _, id := range lockedFinal { + if _, skip := removed[id]; !skip { + filtered = append(filtered, id) + } + } + deletable = filtered + } + } else { + if err := rejectAccountsWithMediaJobs(tx, lockedFinal); err != nil { + return outcome, err + } + } + if len(deletable) == 0 { + return outcome, nil + } + + // 4) Delete remaining rows and count them by provider. + result := tx.Where("id IN ?", deletable).Delete(&accountModel{}) + if result.Error != nil { + return outcome, result.Error + } + outcome.Deleted = result.RowsAffected + outcome.DeletedIDs = append([]uint64(nil), deletable...) + rootSet := make(map[uint64]struct{}, len(resolution.RootIDs)) + for _, id := range resolution.RootIDs { + rootSet[id] = struct{}{} + } + for _, id := range deletable { + if _, isRoot := rootSet[id]; isRoot { + outcome.RootsDeleted++ + } else if provider, ok := resolution.PeerProviders[id]; ok { + outcome.LinkedDeletedByProvider[provider]++ + } + } + return outcome, nil +} + +// DeleteManyWithLinked locks existing roots (optionally filtered by provider), expands linked +// peers from binding tables, re-locks the final set, handles media jobs, and deletes +// everything in one transaction so concurrent re-link/unlink cannot race the snapshot. +func (r *AccountRepository) DeleteManyWithLinked(ctx context.Context, providerValue account.Provider, rootIDs []uint64, targets []account.Provider, skipMedia bool) (repository.LinkedDeleteOutcome, error) { + outcome := repository.LinkedDeleteOutcome{ + Resolution: repository.LinkedDeleteResolution{LinkedByProvider: map[account.Provider]int{}}, + LinkedDeletedByProvider: map[account.Provider]int64{}, + } + roots := uniqueSortedIDs(rootIDs) + if len(roots) == 0 { + return outcome, nil + } + + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + + // Lock roots in one stable order, then validate their provider without masking missing IDs. + var lockedRows []struct { + ID uint64 + Provider string + } + if err := tx.Model(&accountModel{}).Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "provider").Where("id IN ?", roots).Order("id ASC").Find(&lockedRows).Error; err != nil { + return err + } + if len(lockedRows) == 0 { + return nil + } + lockedRoots := make([]uint64, 0, len(lockedRows)) + for _, row := range lockedRows { + if providerValue.IsValid() && account.Provider(row.Provider) != providerValue { + return fmt.Errorf("%w: 账号不属于指定号池", repository.ErrConflict) + } + lockedRoots = append(lockedRoots, row.ID) + } + inner, err := deleteLinkedGroupsTx(tx, providerValue, lockedRoots, targets, skipMedia) + if err != nil { + return err + } + outcome = inner + return nil + }) + if err != nil { + return repository.LinkedDeleteOutcome{}, err + } + if outcome.Deleted > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + } + return outcome, nil +} + +// DeleteAccountStatusBatchWithLinked selects at most limit roots by state and ID cursor, +// expands links, skips active-media groups, and returns the candidate count and next cursor. +func (r *AccountRepository) DeleteAccountStatusBatchWithLinked(ctx context.Context, providerValue account.Provider, status string, now time.Time, afterID uint64, limit int, targets []account.Provider) (repository.LinkedDeleteOutcome, int, uint64, error) { + outcome := repository.LinkedDeleteOutcome{ + Resolution: repository.LinkedDeleteResolution{LinkedByProvider: map[account.Provider]int{}}, + LinkedDeletedByProvider: map[account.Provider]int64{}, + } + if limit < 1 { + return outcome, 0, afterID, nil + } + if status != "disabled" && status != "reauthRequired" && status != "cooldown" { + return outcome, 0, afterID, fmt.Errorf("不支持清理账号状态 %q", status) + } + candidateCount := 0 + maxCandidateID := afterID + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + // Select, filter, and lock roots in one query to revalidate state inside the batch. + selection := applyAccountStatusFilter( + tx.Model(&accountModel{}).Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ?", providerValue), + status, now, + ) + if afterID > 0 { + selection = selection.Where("id > ?", afterID) + } + var candidates []uint64 + if err := selection.Order("id ASC").Limit(limit).Pluck("id", &candidates).Error; err != nil { + return err + } + if len(candidates) == 0 { + return nil + } + candidateCount = len(candidates) + maxCandidateID = candidates[len(candidates)-1] + inner, err := deleteLinkedGroupsTx(tx, providerValue, candidates, targets, true) + if err != nil { + return err + } + outcome = inner + return nil + }) + if err != nil { + return repository.LinkedDeleteOutcome{}, 0, afterID, err + } + if outcome.Deleted > 0 { + // Linked deletion crosses providers, so invalidate all candidate caches. + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + } + return outcome, candidateCount, maxCandidateID, nil +} + +// CountCleanupWithLinked computes preview counts without materializing account IDs. +// Cleanup states are disjoint; linked counts use indexed subqueries and joins for two-hop paths. +func (r *AccountRepository) CountCleanupWithLinked(ctx context.Context, providerValue account.Provider, statuses []string, now time.Time, targets []account.Provider) (repository.CleanupPreview, error) { + preview := repository.CleanupPreview{ + RootsByStatus: map[string]int64{}, + LinkedByProvider: map[account.Provider]int64{}, + } + if !providerValue.IsValid() { + return preview, fmt.Errorf("账号来源无效") + } + db := r.db.db.WithContext(ctx) + for _, status := range statuses { + if status != "disabled" && status != "reauthRequired" && status != "cooldown" { + return preview, fmt.Errorf("不支持清理账号状态 %q", status) + } + var rootCount int64 + if err := applyAccountStatusFilter( + db.Session(&gorm.Session{NewDB: true}).Model(&accountModel{}).Where("provider = ?", providerValue), + status, now, + ).Count(&rootCount).Error; err != nil { + return preview, err + } + preview.RootsByStatus[status] = rootCount + preview.RootCount += rootCount + + rootsSub := func() *gorm.DB { + return applyAccountStatusFilter( + db.Session(&gorm.Session{NewDB: true}).Model(&accountModel{}).Select("id").Where("provider = ?", providerValue), + status, now, + ) + } + for _, target := range targets { + if !target.IsValid() || target == providerValue { + return preview, fmt.Errorf("关联删除目标无效") + } + var linked int64 + var err error + switch { + case providerValue == account.ProviderWeb && target == account.ProviderBuild: + err = db.Session(&gorm.Session{NewDB: true}).Table("account_provider_links"). + Where("web_account_id IN (?)", rootsSub()).Count(&linked).Error + case providerValue == account.ProviderWeb && target == account.ProviderConsole: + err = db.Session(&gorm.Session{NewDB: true}).Table("web_console_account_links"). + Where("web_account_id IN (?)", rootsSub()).Count(&linked).Error + case providerValue == account.ProviderBuild && target == account.ProviderWeb: + err = db.Session(&gorm.Session{NewDB: true}).Table("account_provider_links"). + Where("build_account_id IN (?)", rootsSub()).Count(&linked).Error + case providerValue == account.ProviderBuild && target == account.ProviderConsole: + err = db.Session(&gorm.Session{NewDB: true}).Table("account_provider_links apl"). + Joins("JOIN web_console_account_links wcal ON wcal.web_account_id = apl.web_account_id"). + Where("apl.build_account_id IN (?)", rootsSub()).Count(&linked).Error + case providerValue == account.ProviderConsole && target == account.ProviderWeb: + err = db.Session(&gorm.Session{NewDB: true}).Table("web_console_account_links"). + Where("console_account_id IN (?)", rootsSub()).Count(&linked).Error + case providerValue == account.ProviderConsole && target == account.ProviderBuild: + err = db.Session(&gorm.Session{NewDB: true}).Table("web_console_account_links wcal"). + Joins("JOIN account_provider_links apl ON apl.web_account_id = wcal.web_account_id"). + Where("wcal.console_account_id IN (?)", rootsSub()).Count(&linked).Error + default: + return preview, fmt.Errorf("关联删除目标无效") + } + if err != nil { + return preview, err + } + preview.LinkedByProvider[target] += linked + } + } + preview.Total = preview.RootCount + for _, linked := range preview.LinkedByProvider { + preview.Total += linked + } + return preview, nil +} + +func uniqueSortedIDs(ids []uint64) []uint64 { + if len(ids) == 0 { + return nil + } + seen := make(map[uint64]struct{}, len(ids)) + out := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// accountLinkPair maps a root account to its final peer, including two-hop paths. +type accountLinkPair struct { + RootID uint64 + PeerID uint64 +} + +func listWebBuildPairs(db *gorm.DB, webIDs []uint64) ([]accountLinkPair, error) { + if len(webIDs) == 0 { + return nil, nil + } + var pairs []accountLinkPair + err := db.Table("account_provider_links"). + Select("web_account_id AS root_id, build_account_id AS peer_id"). + Where("web_account_id IN ?", webIDs).Scan(&pairs).Error + return pairs, err +} + +func listWebConsolePairs(db *gorm.DB, webIDs []uint64) ([]accountLinkPair, error) { + if len(webIDs) == 0 { + return nil, nil + } + var pairs []accountLinkPair + err := db.Table("web_console_account_links"). + Select("web_account_id AS root_id, console_account_id AS peer_id"). + Where("web_account_id IN ?", webIDs).Scan(&pairs).Error + return pairs, err +} + +func listBuildWebPairs(db *gorm.DB, buildIDs []uint64) ([]accountLinkPair, error) { + if len(buildIDs) == 0 { + return nil, nil + } + var pairs []accountLinkPair + err := db.Table("account_provider_links"). + Select("build_account_id AS root_id, web_account_id AS peer_id"). + Where("build_account_id IN ?", buildIDs).Scan(&pairs).Error + return pairs, err +} + +// listBuildConsolePairs follows Build to Web to Console without returning the intermediate Web. +func listBuildConsolePairs(db *gorm.DB, buildIDs []uint64) ([]accountLinkPair, error) { + if len(buildIDs) == 0 { + return nil, nil + } + var pairs []accountLinkPair + err := db.Table("account_provider_links apl"). + Select("apl.build_account_id AS root_id, wcal.console_account_id AS peer_id"). + Joins("JOIN web_console_account_links wcal ON wcal.web_account_id = apl.web_account_id"). + Where("apl.build_account_id IN ?", buildIDs).Scan(&pairs).Error + return pairs, err +} + +func listConsoleWebPairs(db *gorm.DB, consoleIDs []uint64) ([]accountLinkPair, error) { + if len(consoleIDs) == 0 { + return nil, nil + } + var pairs []accountLinkPair + err := db.Table("web_console_account_links"). + Select("console_account_id AS root_id, web_account_id AS peer_id"). + Where("console_account_id IN ?", consoleIDs).Scan(&pairs).Error + return pairs, err +} + +// listConsoleBuildPairs follows Console to Web to Build without returning the intermediate Web. +func listConsoleBuildPairs(db *gorm.DB, consoleIDs []uint64) ([]accountLinkPair, error) { + if len(consoleIDs) == 0 { + return nil, nil + } + var pairs []accountLinkPair + err := db.Table("web_console_account_links wcal"). + Select("wcal.console_account_id AS root_id, apl.build_account_id AS peer_id"). + Joins("JOIN account_provider_links apl ON apl.web_account_id = wcal.web_account_id"). + Where("wcal.console_account_id IN ?", consoleIDs).Scan(&pairs).Error + return pairs, err +} diff --git a/backend/internal/infra/persistence/relational/account_links_test.go b/backend/internal/infra/persistence/relational/account_links_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7a2716612c1b7ad140f436e4ffd22c7e6227d292 --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_links_test.go @@ -0,0 +1,753 @@ +package relational + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestReconcileProviderLinksUsesOnlyHighConfidenceIdentity(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "account-links.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + digest := strings.Repeat("a", 64) + identity := "sso_" + digest[:32] + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "sso:" + digest, + UserID: "user-1", EgressIdentity: identity, + }) + nsfwEnabledAt := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + if err := repo.MarkWebNSFWEnabled(ctx, web.ID, nsfwEnabledAt); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebTermsAccepted(ctx, web.ID, account.CurrentWebTermsVersion, nsfwEnabledAt); err != nil { + t.Fatal(err) + } + console := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", SourceKey: "console-sso:" + digest, + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build", SourceKey: "build-1", UserID: "user-1", + }) + for _, id := range []uint64{console.ID, build.ID, web.ID} { + if err := repo.ReconcileProviderLinks(ctx, id); err != nil { + t.Fatal(err) + } + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + if len(web.LinkedAccounts) != 2 || web.LinkedAccounts[0].Provider != account.ProviderBuild || web.LinkedAccounts[1].Provider != account.ProviderConsole { + t.Fatalf("web links = %#v", web.LinkedAccounts) + } + build, err = repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + console, err = repo.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if build.EgressIdentity != identity || console.EgressIdentity != identity || web.EgressIdentity != identity { + t.Fatalf("egress identities web=%q build=%q console=%q", web.EgressIdentity, build.EgressIdentity, console.EgressIdentity) + } + if web.WebNSFWEnabledAt == nil || build.WebNSFWEnabledAt == nil || console.WebNSFWEnabledAt == nil || !web.WebNSFWEnabledAt.Equal(nsfwEnabledAt) || !build.WebNSFWEnabledAt.Equal(nsfwEnabledAt) || !console.WebNSFWEnabledAt.Equal(nsfwEnabledAt) { + t.Fatalf("shared NSFW markers web=%v build=%v console=%v", web.WebNSFWEnabledAt, build.WebNSFWEnabledAt, console.WebNSFWEnabledAt) + } + if web.WebTermsAcceptedAt == nil || build.WebTermsAcceptedAt == nil || console.WebTermsAcceptedAt == nil || !web.WebTermsAcceptedAt.Equal(nsfwEnabledAt) || !build.WebTermsAcceptedAt.Equal(nsfwEnabledAt) || !console.WebTermsAcceptedAt.Equal(nsfwEnabledAt) { + t.Fatalf("shared terms markers web=%v build=%v console=%v", web.WebTermsAcceptedAt, build.WebTermsAcceptedAt, console.WebTermsAcceptedAt) + } + if web.WebTermsAcceptedVersion != account.CurrentWebTermsVersion || build.WebTermsAcceptedVersion != account.CurrentWebTermsVersion || console.WebTermsAcceptedVersion != account.CurrentWebTermsVersion { + t.Fatalf("shared terms versions web=%d build=%d console=%d", web.WebTermsAcceptedVersion, build.WebTermsAcceptedVersion, console.WebTermsAcceptedVersion) + } + if build.LinkedAccountID != web.ID || build.LinkedProvider != account.ProviderWeb || len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID { + t.Fatalf("reverse links build=%#v console=%#v", build.LinkedAccounts, console.LinkedAccounts) + } + for _, provider := range []account.Provider{account.ProviderWeb, account.ProviderBuild, account.ProviderConsole} { + values, listErr := repo.ListEnabled(ctx, provider) + if listErr != nil { + t.Fatal(listErr) + } + if len(values) != 1 || values[0].EgressIdentity != identity { + t.Fatalf("routing identities for %s = %#v", provider, values) + } + } + if _, err := repo.UpdateTokens(ctx, web.ID, "rotated-encrypted-token", "", time.Time{}); err != nil { + t.Fatal(err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil || web.EgressIdentity != identity { + t.Fatalf("token update changed egress identity: %q err=%v", web.EgressIdentity, err) + } + + emailWeb := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "email-web", SourceKey: "sso:" + strings.Repeat("b", 64), Email: "same@example.com", + }) + _ = createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "email-build", SourceKey: "email-build", Email: "same@example.com", + }) + if err := repo.ReconcileProviderLinks(ctx, emailWeb.ID); err != nil { + t.Fatal(err) + } + emailWeb, err = repo.Get(ctx, emailWeb.ID) + if err != nil || len(emailWeb.LinkedAccounts) != 0 { + t.Fatalf("email-only account was linked: %#v err=%v", emailWeb.LinkedAccounts, err) + } + + multiWeb := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "multi-web", SourceKey: "sso:" + strings.Repeat("d", 64), UserID: "shared-user", + }) + for _, teamID := range []string{"team-a", "team-b"} { + _ = createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "multi-" + teamID, SourceKey: "multi-" + teamID, UserID: "shared-user", TeamID: teamID, + }) + } + if err := repo.ReconcileProviderLinks(ctx, multiWeb.ID); err != nil { + t.Fatal(err) + } + multiWeb, err = repo.Get(ctx, multiWeb.ID) + if err != nil || len(multiWeb.LinkedAccounts) != 0 { + t.Fatalf("ambiguous user was linked: %#v err=%v", multiWeb.LinkedAccounts, err) + } + + if err := repo.Delete(ctx, web.ID); err != nil { + t.Fatal(err) + } + build, buildErr := repo.Get(ctx, build.ID) + console, consoleErr := repo.Get(ctx, console.ID) + if buildErr != nil || consoleErr != nil || len(build.LinkedAccounts) != 0 || len(console.LinkedAccounts) != 0 { + t.Fatalf("deleting Web affected linked accounts: build=%#v/%v console=%#v/%v", build, buildErr, console, consoleErr) + } +} + +func TestInitializeSchemaBackfillsStableWebEgressIdentity(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-account-links.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + digest := strings.Repeat("c", 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "legacy-web", SourceKey: "sso:" + digest, + FailureCount: 3, LastError: "preserve-me", + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "legacy-build", SourceKey: "legacy-build", + }) + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := repo.SaveQuotaWindows(ctx, web.ID, account.WebTierAuto, now, []account.QuotaWindow{{ + AccountID: web.ID, Mode: "weekly", Remaining: 7, Total: 10, WindowSeconds: 3600, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }}); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropConstraint(&webAccountProfileModel{}, "chk_web_account_profiles_egress_identity"); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "EgressIdentity"); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("migration is not idempotent: %v", err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + build, err = repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + windows, err := repo.GetQuotaWindows(ctx, []uint64{web.ID}) + if err != nil { + t.Fatal(err) + } + wantIdentity := "sso_" + digest[:32] + if web.EgressIdentity != wantIdentity || build.EgressIdentity != wantIdentity || web.FailureCount != 3 || web.LastError != "preserve-me" || len(windows[web.ID]) != 1 || windows[web.ID][0].Remaining != 7 { + t.Fatalf("migration result web=%#v buildIdentity=%q windows=%#v", web, build.EgressIdentity, windows[web.ID]) + } +} + +func TestReconcileWebConsoleUsesUniqueUserIDAcrossDifferentSSOTokens(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-console-user-link.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + webDigest := strings.Repeat("e", 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "sso:" + webDigest, + UserID: "same-user", Email: "same@example.com", EgressIdentity: "sso_" + webDigest[:32], + }) + console := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", + SourceKey: "console-sso:" + strings.Repeat("f", 64), UserID: "same-user", Email: "same@example.com", + }) + if err := repo.ReconcileProviderLinks(ctx, console.ID); err != nil { + t.Fatal(err) + } + console, err = repo.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID || console.LinkedAccounts[0].Email != "same@example.com" || console.LinkedAccounts[0].UserID != "same-user" || console.EgressIdentity != web.EgressIdentity { + t.Fatalf("console link = %#v identity=%q, web identity=%q", console.LinkedAccounts, console.EgressIdentity, web.EgressIdentity) + } +} + +func createLinkedAccountTestCredential(t *testing.T, ctx context.Context, repo *AccountRepository, value account.Credential) account.Credential { + t.Helper() + value.EncryptedAccessToken = "encrypted" + value.Enabled = true + value.AuthStatus = account.AuthStatusActive + value.Priority = account.DefaultPriority + value.MaxConcurrent = account.DefaultMaxConcurrent + stored, _, err := repo.UpsertByIdentity(ctx, value) + if err != nil { + t.Fatal(err) + } + return stored +} + +func TestResolveLinkedDeleteIDsWebAndTwoHop(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "linked-delete-resolve.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + + webLinked := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-linked", SourceKey: "sso:" + strings.Repeat("e", 64), UserID: "u-link", + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build-linked", SourceKey: "build-link", UserID: "u-link", + }) + console := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console-linked", SourceKey: "console-sso:" + strings.Repeat("e", 64), UserID: "u-link", + }) + if err := repo.LinkWebToBuild(ctx, webLinked.ID, build.ID); err != nil { + t.Fatal(err) + } + if err := repo.ReconcileProviderLinks(ctx, webLinked.ID); err != nil { + t.Fatal(err) + } + webOnly := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-only", SourceKey: "sso:" + strings.Repeat("f", 64), + }) + // Same email as webLinked's potential peers but no link rows for this web. + _ = createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "email-build", SourceKey: "email-build-only", Email: "same@example.com", + }) + + // Web no targets → roots only + res, err := repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webLinked.ID, webOnly.ID}, nil) + if err != nil { + t.Fatal(err) + } + if len(res.FinalIDs) != 2 || res.LinkedByProvider[account.ProviderBuild] != 0 { + t.Fatalf("no targets resolution = %#v", res) + } + + // Web + build only + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webLinked.ID, webOnly.ID}, []account.Provider{account.ProviderBuild}) + if err != nil { + t.Fatal(err) + } + if res.LinkedByProvider[account.ProviderBuild] != 1 || res.LinkedByProvider[account.ProviderConsole] != 0 || !containsID(res.FinalIDs, build.ID) || containsID(res.FinalIDs, console.ID) { + t.Fatalf("web+build = %#v", res) + } + + // Web + build + console + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webLinked.ID}, []account.Provider{account.ProviderBuild, account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if res.LinkedByProvider[account.ProviderBuild] != 1 || res.LinkedByProvider[account.ProviderConsole] != 1 || len(res.FinalIDs) != 3 { + t.Fatalf("web+both = %#v", res) + } + + // Build → console two-hop, keep web + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderBuild, []uint64{build.ID}, []account.Provider{account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if !containsID(res.FinalIDs, build.ID) || !containsID(res.FinalIDs, console.ID) || containsID(res.FinalIDs, webLinked.ID) { + t.Fatalf("build+console keep web = %#v", res) + } + + // Build → web + console + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderBuild, []uint64{build.ID}, []account.Provider{account.ProviderWeb, account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if len(res.FinalIDs) != 3 || !containsID(res.FinalIDs, webLinked.ID) { + t.Fatalf("build+web+console = %#v", res) + } + + // Target includes current provider → error + if _, err := repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webOnly.ID}, []account.Provider{account.ProviderWeb}); err == nil { + t.Fatal("expected error when target includes current provider") + } + + // Console → build two-hop, keep web + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderConsole, []uint64{console.ID}, []account.Provider{account.ProviderBuild}) + if err != nil { + t.Fatal(err) + } + if !containsID(res.FinalIDs, console.ID) || !containsID(res.FinalIDs, build.ID) || containsID(res.FinalIDs, webLinked.ID) { + t.Fatalf("console+build keep web = %#v", res) + } + + // Duplicate root IDs collapse + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webLinked.ID, webLinked.ID}, []account.Provider{account.ProviderBuild}) + if err != nil { + t.Fatal(err) + } + if len(res.RootIDs) != 1 || res.RootIDs[0] != webLinked.ID { + t.Fatalf("dedup roots = %#v", res.RootIDs) + } + + // Email-only peer must not be pulled in + res, err = repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webOnly.ID}, []account.Provider{account.ProviderBuild, account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if len(res.FinalIDs) != 1 || res.FinalIDs[0] != webOnly.ID || res.LinkedByProvider[account.ProviderBuild] != 0 { + t.Fatalf("email-only must not expand = %#v", res) + } + + // Invalid target provider + if _, err := repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{webOnly.ID}, []account.Provider{account.Provider("nope")}); err == nil { + t.Fatal("expected invalid target error") + } +} + +func TestResolveLinkedDeleteIDsBuildWithoutWeb(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "linked-delete-orphan-build.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "orphan-build", SourceKey: "orphan-build", + }) + res, err := repo.ResolveLinkedDeleteIDs(ctx, account.ProviderBuild, []uint64{build.ID}, []account.Provider{account.ProviderWeb, account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if len(res.FinalIDs) != 1 || res.FinalIDs[0] != build.ID { + t.Fatalf("unlinked build expand = %#v", res) + } +} + +func TestDeleteManyRejectsWhenLinkedPeerHasActiveMediaJob(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "linked-delete-media.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + digest := strings.Repeat("m", 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-media", SourceKey: "sso:" + digest, UserID: "u-media", + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build-media", SourceKey: "build-media", UserID: "u-media", + }) + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + resolution, err := repo.ResolveLinkedDeleteIDs(ctx, account.ProviderWeb, []uint64{web.ID}, []account.Provider{account.ProviderBuild}) + if err != nil { + t.Fatal(err) + } + if len(resolution.FinalIDs) != 2 { + t.Fatalf("resolution = %#v", resolution) + } + + key := clientKeyModel{Name: "linked-media-key", Prefix: "linked-media-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + accountID := build.ID + job := mediaJobModel{ + ID: "media_job_linked_peer", RequestID: "req_linked_peer", + ClientKeyID: key.ID, ClientKeyName: "key", AccountID: &accountID, AccountName: "build-media", + EgressScope: "grok_build", EgressMode: "direct", Provider: string(account.ProviderBuild), + Model: "video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "x", Seconds: 1, Size: "16:9", + Quality: "720p", Status: string(media.StatusInProgress), Progress: 10, InputJSON: "{}", + CreatedAt: now, UpdatedAt: now, + } + if err := database.db.WithContext(ctx).Create(&job).Error; err != nil { + t.Fatal(err) + } + if _, err := repo.DeleteMany(ctx, resolution.FinalIDs); err == nil { + t.Fatal("expected active media job on peer to block delete") + } + if _, err := repo.Get(ctx, web.ID); err != nil { + t.Fatalf("web should remain after blocked linked delete: %v", err) + } + if _, err := repo.Get(ctx, build.ID); err != nil { + t.Fatalf("build should remain after blocked linked delete: %v", err) + } +} + +func TestDeleteManyWithLinkedAtomicWebBothAndMediaBlock(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "linked-delete-atomic.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + digest := strings.Repeat("z", 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-atomic", SourceKey: "sso:" + digest, UserID: "u-atomic", + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build-atomic", SourceKey: "build-atomic", UserID: "u-atomic", + }) + console := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console-atomic", SourceKey: "console-sso:" + digest, UserID: "u-atomic", + }) + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + if err := repo.ReconcileProviderLinks(ctx, web.ID); err != nil { + t.Fatal(err) + } + + outcome, err := repo.DeleteManyWithLinked(ctx, account.ProviderWeb, []uint64{web.ID}, []account.Provider{account.ProviderBuild, account.ProviderConsole}, false) + if err != nil { + t.Fatal(err) + } + if outcome.Deleted != 3 || len(outcome.Resolution.FinalIDs) != 3 || outcome.RootsDeleted != 1 { + t.Fatalf("outcome=%#v", outcome) + } + if outcome.LinkedDeletedByProvider[account.ProviderBuild] != 1 || outcome.LinkedDeletedByProvider[account.ProviderConsole] != 1 { + t.Fatalf("linked by provider = %#v", outcome.LinkedDeletedByProvider) + } + if _, err := repo.Get(ctx, web.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("web should be gone: %v", err) + } + if _, err := repo.Get(ctx, build.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("build should be gone: %v", err) + } + if _, err := repo.Get(ctx, console.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("console should be gone: %v", err) + } + + // Media block path + digest2 := strings.Repeat("y", 64) + web2 := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-media2", SourceKey: "sso:" + digest2, UserID: "u-media2", + }) + build2 := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build-media2", SourceKey: "build-media2", UserID: "u-media2", + }) + if err := repo.LinkWebToBuild(ctx, web2.ID, build2.ID); err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "atomic-media-key", Prefix: "atomic-media-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + accountID := build2.ID + job := mediaJobModel{ + ID: "media_job_atomic", RequestID: "req_atomic", + ClientKeyID: key.ID, ClientKeyName: "key", AccountID: &accountID, AccountName: "build-media2", + EgressScope: "grok_build", EgressMode: "direct", Provider: string(account.ProviderBuild), + Model: "video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "x", Seconds: 1, Size: "16:9", + Quality: "720p", Status: string(media.StatusInProgress), Progress: 10, InputJSON: "{}", + CreatedAt: now, UpdatedAt: now, + } + if err := database.db.WithContext(ctx).Create(&job).Error; err != nil { + t.Fatal(err) + } + // Single-delete mode rejects the entire operation when a peer has an active media job. + if _, err := repo.DeleteManyWithLinked(ctx, account.ProviderWeb, []uint64{web2.ID}, []account.Provider{account.ProviderBuild}, false); err == nil { + t.Fatal("expected media job to block DeleteManyWithLinked") + } + if _, err := repo.Get(ctx, web2.ID); err != nil { + t.Fatalf("web2 should remain: %v", err) + } + if _, err := repo.Get(ctx, build2.ID); err != nil { + t.Fatalf("build2 should remain: %v", err) + } + + // Batch mode skips the complete blocked root group and deletes all other groups. + digest3 := strings.Repeat("x", 64) + web3 := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-clean3", SourceKey: "sso:" + digest3, UserID: "u-clean3", + }) + build3 := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build-clean3", SourceKey: "build-clean3", UserID: "u-clean3", + }) + if err := repo.LinkWebToBuild(ctx, web3.ID, build3.ID); err != nil { + t.Fatal(err) + } + skipOutcome, err := repo.DeleteManyWithLinked(ctx, account.ProviderWeb, []uint64{web2.ID, web3.ID}, []account.Provider{account.ProviderBuild}, true) + if err != nil { + t.Fatal(err) + } + if skipOutcome.Deleted != 2 || skipOutcome.RootsDeleted != 1 || len(skipOutcome.SkippedRoots) != 1 || skipOutcome.SkippedRoots[0] != web2.ID { + t.Fatalf("skip outcome = %#v", skipOutcome) + } + if _, err := repo.Get(ctx, web2.ID); err != nil { + t.Fatalf("media group web2 should be skipped: %v", err) + } + if _, err := repo.Get(ctx, build2.ID); err != nil { + t.Fatalf("media group build2 should be skipped: %v", err) + } + if _, err := repo.Get(ctx, web3.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("web3 should be deleted: %v", err) + } + if _, err := repo.Get(ctx, build3.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("build3 should be deleted: %v", err) + } +} + +// Cleanup batches select roots by state, expand links, skip media groups, and advance the cursor. +func TestDeleteAccountStatusBatchWithLinkedCursorAndSkip(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "cleanup-linked-batch.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + now := time.Now().UTC() + + disable := func(id uint64) { + if err := database.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + } + + // Seed three disabled Web-Build groups and one active Web account that must not be selected. + type trio struct{ web, build uint64 } + var trios []trio + for i := 0; i < 3; i++ { + digest := strings.Repeat(string(rune('a'+i)), 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: fmt.Sprintf("cl-web-%d", i), SourceKey: "sso:" + digest, UserID: fmt.Sprintf("cl-%d", i), + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: fmt.Sprintf("cl-build-%d", i), SourceKey: fmt.Sprintf("cl-build-%d", i), UserID: fmt.Sprintf("cl-%d", i), + }) + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + disable(web.ID) + trios = append(trios, trio{web.ID, build.ID}) + } + activeWeb := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "cl-active", SourceKey: "sso:" + strings.Repeat("d", 64), + }) + + // Attach an active media job to the first Build account so its group is skipped. + key := clientKeyModel{Name: "cl-media-key", Prefix: "cl-media-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + blockedBuild := trios[0].build + job := mediaJobModel{ + ID: "media_job_cleanup", RequestID: "req_cleanup", + ClientKeyID: key.ID, ClientKeyName: "key", AccountID: &blockedBuild, AccountName: "cl-build-0", + EgressScope: "grok_build", EgressMode: "direct", Provider: string(account.ProviderBuild), + Model: "video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "x", Seconds: 1, Size: "16:9", + Quality: "720p", Status: string(media.StatusQueued), Progress: 0, InputJSON: "{}", + CreatedAt: now, UpdatedAt: now, + } + if err := database.db.WithContext(ctx).Create(&job).Error; err != nil { + t.Fatal(err) + } + + // With limit=2, the first batch skips group 0 and deletes group 1; the second deletes group 2. + targets := []account.Provider{account.ProviderBuild} + outcome1, candidates1, maxID1, err := repo.DeleteAccountStatusBatchWithLinked(ctx, account.ProviderWeb, "disabled", now, 0, 2, targets) + if err != nil { + t.Fatal(err) + } + if candidates1 != 2 || outcome1.Deleted != 2 || outcome1.RootsDeleted != 1 || len(outcome1.SkippedRoots) != 1 { + t.Fatalf("batch1 outcome=%#v candidates=%d", outcome1, candidates1) + } + outcome2, candidates2, _, err := repo.DeleteAccountStatusBatchWithLinked(ctx, account.ProviderWeb, "disabled", now, maxID1, 2, targets) + if err != nil { + t.Fatal(err) + } + if candidates2 != 1 || outcome2.Deleted != 2 || outcome2.RootsDeleted != 1 || len(outcome2.SkippedRoots) != 0 { + t.Fatalf("batch2 outcome=%#v candidates=%d", outcome2, candidates2) + } + // The skipped group and active account remain; both accounts in the other groups are gone. + if _, err := repo.Get(ctx, trios[0].web); err != nil { + t.Fatalf("skipped web should remain: %v", err) + } + if _, err := repo.Get(ctx, trios[0].build); err != nil { + t.Fatalf("skipped build should remain: %v", err) + } + for _, index := range []int{1, 2} { + if _, err := repo.Get(ctx, trios[index].web); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("web %d should be deleted: %v", index, err) + } + if _, err := repo.Get(ctx, trios[index].build); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("build %d should be deleted: %v", index, err) + } + } + if _, err := repo.Get(ctx, activeWeb.ID); err != nil { + t.Fatalf("active web should remain: %v", err) + } +} + +// Cleanup preview counts roots by state and linked peers across one-hop and two-hop paths. +func TestCountCleanupWithLinked(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "cleanup-preview.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + now := time.Now().UTC() + + setStatus := func(id uint64, updates map[string]any) { + if err := database.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Updates(updates).Error; err != nil { + t.Fatal(err) + } + } + + // Seed a disabled Web with both peers, an unlinked reauth Web, a cooldown Web-Console pair, and an active Web-Build pair. + digestA := strings.Repeat("p", 64) + webA := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "pv-a", SourceKey: "sso:" + digestA, UserID: "pv-a"}) + buildA := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "pv-a-build", SourceKey: "pv-a-build", UserID: "pv-a"}) + consoleA := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "pv-a-console", SourceKey: "console-sso:" + digestA, UserID: "pv-a"}) + if err := repo.LinkWebToBuild(ctx, webA.ID, buildA.ID); err != nil { + t.Fatal(err) + } + if err := repo.ReconcileProviderLinks(ctx, webA.ID); err != nil { + t.Fatal(err) + } + setStatus(webA.ID, map[string]any{"enabled": false}) + + webB := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "pv-b", SourceKey: "sso:" + strings.Repeat("q", 64)}) + setStatus(webB.ID, map[string]any{"auth_status": "reauthRequired"}) + + digestC := strings.Repeat("r", 64) + webC := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "pv-c", SourceKey: "sso:" + digestC, UserID: "pv-c"}) + consoleC := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "pv-c-console", SourceKey: "console-sso:" + digestC, UserID: "pv-c"}) + if err := repo.ReconcileProviderLinks(ctx, webC.ID); err != nil { + t.Fatal(err) + } + setStatus(webC.ID, map[string]any{"cooldown_until": now.Add(time.Hour)}) + + digestD := strings.Repeat("s", 64) + webD := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "pv-d", SourceKey: "sso:" + digestD, UserID: "pv-d"}) + buildD := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "pv-d-build", SourceKey: "pv-d-build", UserID: "pv-d"}) + if err := repo.LinkWebToBuild(ctx, webD.ID, buildD.ID); err != nil { + t.Fatal(err) + } + _ = consoleA + _ = consoleC + + preview, err := repo.CountCleanupWithLinked(ctx, account.ProviderWeb, []string{"disabled", "reauthRequired", "cooldown"}, now, []account.Provider{account.ProviderBuild, account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if preview.RootsByStatus["disabled"] != 1 || preview.RootsByStatus["reauthRequired"] != 1 || preview.RootsByStatus["cooldown"] != 1 { + t.Fatalf("roots by status = %#v", preview.RootsByStatus) + } + // The selected roots expand to one Build peer and two Console peers. + if preview.RootCount != 3 || preview.LinkedByProvider[account.ProviderBuild] != 1 || preview.LinkedByProvider[account.ProviderConsole] != 2 || preview.Total != 6 { + t.Fatalf("preview = %#v", preview) + } + + // Verify the two-hop preview from a disabled Build root to Console. + setStatus(buildD.ID, map[string]any{"enabled": false}) + digestE := strings.Repeat("t", 64) + consoleD := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "pv-d-console", SourceKey: "console-sso:" + digestD, UserID: "pv-d"}) + if err := repo.ReconcileProviderLinks(ctx, webD.ID); err != nil { + t.Fatal(err) + } + _ = digestE + _ = consoleD + buildPreview, err := repo.CountCleanupWithLinked(ctx, account.ProviderBuild, []string{"disabled"}, now, []account.Provider{account.ProviderWeb, account.ProviderConsole}) + if err != nil { + t.Fatal(err) + } + if buildPreview.RootCount != 1 || buildPreview.LinkedByProvider[account.ProviderWeb] != 1 || buildPreview.LinkedByProvider[account.ProviderConsole] != 1 || buildPreview.Total != 3 { + t.Fatalf("build preview = %#v", buildPreview) + } +} + +func containsID(ids []uint64, want uint64) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} diff --git a/backend/internal/infra/persistence/relational/account_repository.go b/backend/internal/infra/persistence/relational/account_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..80eaf2840a2a6200078d9cc2f4cc1d44434baf93 --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_repository.go @@ -0,0 +1,2206 @@ +package relational + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AccountRepository struct { + db *Database + observer repository.InvalidationObserver +} + +func NewAccountRepository(db *Database) *AccountRepository { return &AccountRepository{db: db} } + +func (r *AccountRepository) SetInvalidationObserver(observer repository.InvalidationObserver) { + r.observer = observer +} + +func (r *AccountRepository) notifyInvalidation(ctx context.Context, event repository.InvalidationEvent) { + if r.observer != nil { + r.observer(ctx, event) + } +} + +type quotaBreakdownJSON struct { + ProductCode int `json:"productCode"` + UsagePercent float64 `json:"usagePercent"` +} + +const ( + accountUpdateBatchSize = 500 + accountPaidPlanSignal = `(LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_code), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('super', 'supergrok', 'supergrokpro', 'supergrokheavy', 'supergroklite', 'grokpro', 'xpremium', 'xpremiumplus', 'apikey') OR LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_name), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('super', 'supergrok', 'supergrokpro', 'supergrokheavy', 'supergroklite', 'grokpro', 'xpremium', 'xpremiumplus', 'apikey'))` + accountFreePlanSignal = `(LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_code), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('free', 'grokfree', 'freetier', 'basic', 'grokbasic', 'xbasic') OR LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_name), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('free', 'grokfree', 'freetier', 'basic', 'grokbasic', 'xbasic'))` + accountPaidBillingSignals = `(` + accountPaidPlanSignal + ` OR billing.monthly_limit > 0 OR billing.on_demand_cap > 0 OR billing.on_demand_used > 0 OR billing.prepaid_balance > 0)` + accountPaidBillingPredicate = `EXISTS (SELECT 1 FROM account_billing_snapshots billing WHERE billing.account_id = provider_accounts.id AND ` + accountPaidBillingSignals + `)` + // 仅 grok_build 的管理员确认 Super entitlement;与 domain.IsBuildSuper 对齐。 + accountBuildSuperEntitledPredicate = `(provider_accounts.provider = 'grok_build' AND provider_accounts.build_super_entitled = TRUE)` + accountBuildSuperPredicate = `(` + accountPaidBillingPredicate + ` OR ` + accountBuildSuperEntitledPredicate + `)` + accountInferredFreeBillingSignal = `(TRIM(billing.plan_code) = '' AND TRIM(billing.plan_name) = '' AND billing.synced_at IS NOT NULL AND billing.monthly_limit = 0 AND billing.used = 0 AND billing.on_demand_cap = 0 AND billing.on_demand_used = 0 AND billing.prepaid_balance = 0 AND billing.credit_usage_percent = 0)` + accountFreeBillingSignal = `(` + accountFreePlanSignal + ` OR ` + accountInferredFreeBillingSignal + `)` + accountFreeSignalPredicate = `(provider_accounts.provider = 'grok_build' AND (LOWER(TRIM(provider_accounts.observed_model)) LIKE '%-build-free' OR EXISTS (SELECT 1 FROM account_billing_snapshots billing WHERE billing.account_id = provider_accounts.id AND ` + accountFreeBillingSignal + `)))` + accountRecoveryPredicate = `EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status IN ('exhausted', 'probing'))` + providerQuotaExhaustedPredicate = `((provider_accounts.provider = 'grok_web' AND ((EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id AND quota.mode = 'weekly') AND NOT EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id AND quota.mode = 'weekly' AND quota.remaining > 0)) OR (NOT EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id AND quota.mode = 'weekly') AND EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id) AND NOT EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id AND quota.remaining > 0)))) OR (provider_accounts.provider = 'grok_console' AND EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id) AND NOT EXISTS (SELECT 1 FROM account_quota_windows quota WHERE quota.account_id = provider_accounts.id AND quota.remaining > 0)))` + accountTypeSortExpression = `CASE WHEN provider_accounts.provider = 'grok_web' THEN COALESCE((SELECT profile.tier FROM web_account_profiles profile WHERE profile.account_id = provider_accounts.id), 'auto') WHEN ` + accountBuildSuperPredicate + ` THEN 'paid' WHEN ` + accountFreeSignalPredicate + ` THEN 'free' ELSE 'unknown' END` + accountStatusSortExpression = `CASE WHEN provider_accounts.enabled = FALSE THEN 4 WHEN provider_accounts.auth_status = 'reauthRequired' THEN 5 WHEN EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status = 'probing') THEN 3 WHEN EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status = 'exhausted') OR ` + providerQuotaExhaustedPredicate + ` THEN 2 WHEN provider_accounts.cooldown_until > CURRENT_TIMESTAMP THEN 1 ELSE 0 END` + missingConsoleAccountPredicate = `NOT EXISTS (SELECT 1 FROM provider_accounts AS console_account WHERE console_account.provider = ? AND console_account.source_key = ('console-' || provider_accounts.source_key))` +) + +func (r *AccountRepository) List(ctx context.Context, input repository.AccountListQuery) ([]account.Credential, int64, error) { + var total int64 + query := r.db.db.WithContext(ctx).Model(&accountModel{}) + if input.Filter.Provider != "" { + query = query.Where("provider = ?", input.Filter.Provider) + } + if search := strings.TrimSpace(input.Page.Search); search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(user_id) LIKE ? OR LOWER(team_id) LIKE ?", pattern, pattern, pattern, pattern) + } + switch input.Filter.QuotaType { + case "free": + // Super(Billing paid 或 BuildSuperEntitled)不得落入 free;与 IsKnownFreeBuild / QuotaView 一致。 + query = query.Where("NOT " + accountBuildSuperPredicate + " AND (EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.kind = 'free') OR " + accountFreeSignalPredicate + ")") + case "paid": + query = query.Where(accountBuildSuperPredicate) + case "unknown": + query = query.Where("NOT " + accountRecoveryPredicate + " AND NOT " + accountBuildSuperPredicate + " AND NOT " + accountFreeSignalPredicate) + case "auto", "basic", "super", "heavy": + query = query.Where("EXISTS (SELECT 1 FROM web_account_profiles profile WHERE profile.account_id = provider_accounts.id AND profile.tier = ?)", input.Filter.QuotaType) + } + query = applyAccountStatusFilter(query, input.Filter.Status, input.Filter.Now) + switch input.Filter.Egress { + case "bound": + query = query.Where("egress_node_id IS NOT NULL") + case "unbound": + query = query.Where("egress_node_id IS NULL") + } + if input.Filter.Refreshable != nil { + if *input.Filter.Refreshable { + query = query.Where("EXISTS (SELECT 1 FROM account_credentials credential WHERE credential.account_id = provider_accounts.id AND credential.encrypted_refresh <> '')") + } else { + query = query.Where("NOT EXISTS (SELECT 1 FROM account_credentials credential WHERE credential.account_id = provider_accounts.id AND credential.encrypted_refresh <> '')") + } + } + query = applyWebAgreementFilter(query, input.Filter.Agreement) + query = applyAssociationFilter(query, input.Filter.Provider, input.Filter.Association) + if input.Filter.RestrictIDs { + if len(input.Filter.AccountIDs) == 0 { + query = query.Where("1 = 0") + } else { + query = query.Where("provider_accounts.id IN ?", input.Filter.AccountIDs) + } + } + if len(input.Filter.ExcludeIDs) > 0 { + query = query.Where("provider_accounts.id NOT IN ?", input.Filter.ExcludeIDs) + } + if input.Filter.AfterID > 0 { + query = query.Where("provider_accounts.id > ?", input.Filter.AfterID) + } + if input.Filter.ThroughID > 0 { + query = query.Where("provider_accounts.id <= ?", input.Filter.ThroughID) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []accountModel + query = applyStableSort(query, input.Page.Sort, map[string]sortSpec{ + "id": {expression: "provider_accounts.id"}, + "name": {expression: "LOWER(provider_accounts.name)"}, + "type": {expression: accountTypeSortExpression}, + "status": {expression: accountStatusSortExpression}, + "createdAt": {expression: "provider_accounts.created_at", defaultDirection: repository.SortDescending}, + }, sortSpec{expression: "provider_accounts.created_at", defaultDirection: repository.SortDescending}, "provider_accounts.id") + if err := query.Preload("Credential").Preload("WebProfile").Offset(input.Page.Offset).Limit(input.Page.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + out := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + out = append(out, toAccountDomain(row)) + } + if err := r.attachAccountLinks(ctx, out); err != nil { + return nil, 0, err + } + return out, total, nil +} + +func (r *AccountRepository) ListProviderAccountBatch(ctx context.Context, providerValue account.Provider, afterID uint64, limit int) ([]account.Credential, int64, error) { + if limit < 1 { + return []account.Credential{}, 0, nil + } + var total int64 + if afterID == 0 { + if err := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("provider = ?", providerValue).Count(&total).Error; err != nil { + return nil, 0, err + } + } + var rows []accountModel + if err := r.db.db.WithContext(ctx). + Preload("Credential").Preload("WebProfile"). + Where("provider = ? AND id > ?", providerValue, afterID). + Order("id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + out := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + out = append(out, toAccountDomain(row)) + } + if err := r.attachAccountLinks(ctx, out); err != nil { + return nil, 0, err + } + return out, total, nil +} + +// CountProviderAccountsByIDs 只校验账号主表归属,不加载额度、关联或审计数据。 +func (r *AccountRepository) CountProviderAccountsByIDs(ctx context.Context, providerValue account.Provider, ids []uint64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var count int64 + err := r.db.db.WithContext(ctx).Model(&accountModel{}). + Where("provider = ? AND id IN ?", providerValue, ids). + Count(&count).Error + return count, err +} + +func (r *AccountRepository) Summarize(ctx context.Context, now time.Time) ([]repository.AccountSummary, error) { + var rows []repository.AccountSummary + selectFields := ` + provider, + COUNT(*) AS total, + SUM(CASE WHEN enabled = ? AND auth_status = ? AND NOT ` + accountRecoveryPredicate + ` AND NOT ` + providerQuotaExhaustedPredicate + ` AND (cooldown_until IS NULL OR cooldown_until <= ?) THEN 1 ELSE 0 END) AS available, + SUM(CASE WHEN enabled = ? AND auth_status = ? AND NOT ` + accountRecoveryPredicate + ` AND NOT ` + providerQuotaExhaustedPredicate + ` AND cooldown_until > ? THEN 1 ELSE 0 END) AS cooldown, + SUM(CASE WHEN enabled = ? AND auth_status = ? AND (EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status = 'exhausted') OR ` + providerQuotaExhaustedPredicate + `) THEN 1 ELSE 0 END) AS waiting_reset, + SUM(CASE WHEN enabled = ? AND auth_status = ? AND EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status = 'probing') THEN 1 ELSE 0 END) AS probing, + SUM(CASE WHEN enabled = ? THEN 1 ELSE 0 END) AS disabled, + SUM(CASE WHEN enabled = ? AND auth_status = ? THEN 1 ELSE 0 END) AS reauth_required` + err := r.db.db.WithContext(ctx).Model(&accountModel{}).Select( + selectFields, + true, account.AuthStatusActive, now, + true, account.AuthStatusActive, now, + true, account.AuthStatusActive, + true, account.AuthStatusActive, + false, + true, account.AuthStatusReauthRequired, + ).Group("provider").Scan(&rows).Error + return rows, err +} + +// ListRoutingCandidates 批量加载账号、额度、恢复状态和目标模型能力,避免推理热路径按账号逐条查询。 +func (r *AccountRepository) ListRoutingCandidates(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode string) ([]account.RoutingCandidate, error) { + values, err := r.listRoutingCredentials(ctx, provider) + if err != nil { + return nil, err + } + bound := make(map[uint64]bool) + if strings.TrimSpace(upstreamModel) != "" { + boundIDs, loadErr := r.listRoutingBoundAccountIDs(ctx, provider, modelRouteID, upstreamModel) + if loadErr != nil { + return nil, loadErr + } + if len(boundIDs) > 0 { + for _, id := range boundIDs { + bound[id] = true + } + filtered := values[:0] + for _, value := range values { + if bound[value.ID] { + filtered = append(filtered, value) + } + } + values = filtered + } + } + ids := make([]uint64, 0, len(values)) + for _, value := range values { + ids = append(ids, value.ID) + } + billings, err := r.getRoutingBillings(ctx, ids) + if err != nil { + return nil, err + } + recoveries, err := r.GetQuotaRecoveries(ctx, ids) + if err != nil { + return nil, err + } + quotaWindows, err := r.getRoutingQuotaWindows(ctx, ids, provider, quotaMode) + if err != nil { + return nil, err + } + known := make(map[uint64]bool, len(ids)) + supported := make(map[uint64]bool, len(ids)) + modelQuotaBlocks := make(map[uint64]account.ModelQuotaBlock, len(ids)) + if strings.TrimSpace(upstreamModel) != "" && len(ids) > 0 { + var states []accountModelSyncStateModel + if err := r.db.db.WithContext(ctx).Where("account_id IN ? AND last_success_at IS NOT NULL", ids).Find(&states).Error; err != nil { + return nil, err + } + for _, state := range states { + known[state.AccountID] = true + } + var capabilities []accountModelCapabilityModel + if err := r.db.db.WithContext(ctx).Where("account_id IN ? AND upstream_model = ?", ids, upstreamModel).Find(&capabilities).Error; err != nil { + return nil, err + } + for _, capability := range capabilities { + supported[capability.AccountID] = true + } + var blockRows []accountModelQuotaBlockModel + if err := r.db.db.WithContext(ctx).Where("account_id IN ? AND upstream_model = ? AND cooldown_until > ?", ids, upstreamModel, time.Now().UTC()).Find(&blockRows).Error; err != nil { + return nil, err + } + for _, row := range blockRows { + modelQuotaBlocks[row.AccountID] = account.ModelQuotaBlock{AccountID: row.AccountID, UpstreamModel: row.UpstreamModel, Reason: row.Reason, CooldownUntil: row.CooldownUntil.UTC(), UpdatedAt: row.UpdatedAt.UTC()} + } + } + sharedSuperBuildModel := false + if provider == account.ProviderBuild && len(bound) == 0 { + for _, value := range values { + if !supported[value.ID] { + continue + } + var billing *account.Billing + if snapshot, exists := billings[value.ID]; exists { + billing = &snapshot + } + if account.IsBuildSuper(value, billing) { + sharedSuperBuildModel = true + break + } + } + } + result := make([]account.RoutingCandidate, 0, len(values)) + for _, value := range values { + capabilityKnown, supportsModel := known[value.ID], supported[value.ID] + if len(bound) > 0 { + capabilityKnown, supportsModel = true, true + } else if sharedSuperBuildModel { + var billing *account.Billing + if snapshot, exists := billings[value.ID]; exists { + billing = &snapshot + } + if account.IsBuildSuper(value, billing) { + capabilityKnown, supportsModel = true, true + } + } + candidate := account.RoutingCandidate{Credential: value, ModelCapabilityKnown: capabilityKnown, SupportsModel: supportsModel} + if billing, ok := billings[value.ID]; ok { + candidate.Billing = &billing + } + if recovery, ok := recoveries[value.ID]; ok { + candidate.QuotaRecovery = &recovery + } + if window, ok := quotaWindows[value.ID]; ok { + candidate.QuotaWindow = &window + } + if block, ok := modelQuotaBlocks[value.ID]; ok { + candidate.ModelQuotaBlock = &block + } + result = append(result, candidate) + } + return result, nil +} + +func (r *AccountRepository) ListRoutingAccountBases(ctx context.Context, provider account.Provider, quotaMode string) ([]account.RoutingAccountBase, error) { + values, err := r.listRoutingCredentials(ctx, provider) + if err != nil { + return nil, err + } + ids := make([]uint64, 0, len(values)) + for _, value := range values { + ids = append(ids, value.ID) + } + billings, err := r.getRoutingBillings(ctx, ids) + if err != nil { + return nil, err + } + recoveries, err := r.GetQuotaRecoveries(ctx, ids) + if err != nil { + return nil, err + } + quotaWindows, err := r.getRoutingQuotaWindows(ctx, ids, provider, quotaMode) + if err != nil { + return nil, err + } + result := make([]account.RoutingAccountBase, 0, len(values)) + for _, value := range values { + base := account.RoutingAccountBase{Credential: value} + if billing, ok := billings[value.ID]; ok { + base.Billing = &billing + } + if recovery, ok := recoveries[value.ID]; ok { + base.QuotaRecovery = &recovery + } + if window, ok := quotaWindows[value.ID]; ok { + base.QuotaWindow = &window + } + result = append(result, base) + } + return result, nil +} + +// listRoutingCredentials loads only the account state required to decide which +// account to use. Provider secrets deliberately stay in account_credentials +// until a selected account is hydrated for the upstream call. +func (r *AccountRepository) listRoutingCredentials(ctx context.Context, provider account.Provider) ([]account.Credential, error) { + var rows []accountModel + err := r.db.db.WithContext(ctx). + Preload("Credential", func(query *gorm.DB) *gorm.DB { + return query.Select(routingCredentialMetadataColumns) + }). + Preload("WebProfile"). + Where("provider = ? AND enabled = ? AND auth_status = ?", provider, true, account.AuthStatusActive). + Order("priority DESC, id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + values := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + values = append(values, toAccountDomain(row)) + } + if err := r.attachRoutingEgressIdentities(ctx, provider, values); err != nil { + return nil, err + } + return values, nil +} + +// routingCredentialMetadataColumns contains all credential fields used for +// routing and execution decisions, but deliberately excludes the encrypted +// access token, refresh token, and Cloudflare cookie. +var routingCredentialMetadataColumns = []string{ + "account_id", "auth_type", "client_id", "expires_at", "refresh_due_at", "last_refresh_at", + "refresh_failures", "last_refresh_error", "refresh_permanent", "updated_at", +} + +var routingBillingColumns = []string{ + "account_id", "plan_code", "plan_name", "monthly_limit", "used", "on_demand_cap", "on_demand_used", "prepaid_balance", + "credit_usage_percent", "is_unified_billing_user", "on_demand_enabled", "top_up_method", "usage_period_type", + "usage_period_start", "usage_period_end", "billing_period_start", "billing_period_end", "synced_at", +} + +func (r *AccountRepository) getRoutingBillings(ctx context.Context, accountIDs []uint64) (map[uint64]account.Billing, error) { + result := make(map[uint64]account.Billing, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var rows []billingModel + if err := r.db.db.WithContext(ctx).Select(routingBillingColumns).Where("account_id IN ?", accountIDs).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + result[row.AccountID] = toRoutingBillingDomain(row) + } + return result, nil +} + +var routingQuotaWindowColumns = []string{ + "account_id", "mode", "remaining", "total", "usage_percent", "window_seconds", "reset_at", "synced_at", "source", "updated_at", +} + +func (r *AccountRepository) getRoutingQuotaWindows(ctx context.Context, accountIDs []uint64, provider account.Provider, quotaMode string) (map[uint64]account.QuotaWindow, error) { + result := make(map[uint64]account.QuotaWindow, len(accountIDs)) + if len(accountIDs) == 0 || (provider != account.ProviderWeb && quotaMode == "") { + return result, nil + } + modes := make([]string, 0, 2) + if provider == account.ProviderWeb { + modes = append(modes, "weekly") + } + if quotaMode != "" { + modes = append(modes, quotaMode) + } + var rows []quotaWindowModel + if err := r.db.db.WithContext(ctx). + Select(routingQuotaWindowColumns). + Where("account_id IN ? AND mode IN ?", accountIDs, modes). + Order("CASE WHEN mode = 'weekly' THEN 0 ELSE 1 END"). + Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + if _, exists := result[row.AccountID]; !exists { + result[row.AccountID] = toRoutingQuotaWindowDomain(row) + } + } + return result, nil +} + +func (r *AccountRepository) ListRoutingAccountOverlays(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel string) (account.RoutingOverlaySnapshot, error) { + upstreamModel = strings.TrimSpace(upstreamModel) + if upstreamModel == "" { + return account.RoutingOverlaySnapshot{}, nil + } + boundIDs, err := r.listRoutingBoundAccountIDs(ctx, provider, modelRouteID, upstreamModel) + if err != nil { + return account.RoutingOverlaySnapshot{}, err + } + values := make(map[uint64]account.RoutingAccountOverlay) + for _, id := range boundIDs { + values[id] = account.RoutingAccountOverlay{AccountID: id, Bound: true, ModelCapabilityKnown: true, SupportsModel: true} + } + var states []accountModelSyncStateModel + if err := r.db.db.WithContext(ctx). + Table("account_model_sync_states AS state"). + Select("state.*"). + Joins("JOIN provider_accounts AS account ON account.id = state.account_id"). + Where("account.provider = ? AND account.enabled = TRUE AND state.last_success_at IS NOT NULL", provider). + Find(&states).Error; err != nil { + return account.RoutingOverlaySnapshot{}, err + } + for _, state := range states { + overlay := values[state.AccountID] + overlay.AccountID = state.AccountID + overlay.ModelCapabilityKnown = true + values[state.AccountID] = overlay + } + var capabilities []accountModelCapabilityModel + if err := r.db.db.WithContext(ctx). + Table("account_model_capabilities AS capability"). + Select("capability.*"). + Joins("JOIN provider_accounts AS account ON account.id = capability.account_id"). + Where("account.provider = ? AND account.enabled = TRUE AND capability.upstream_model = ?", provider, upstreamModel). + Find(&capabilities).Error; err != nil { + return account.RoutingOverlaySnapshot{}, err + } + for _, capability := range capabilities { + overlay := values[capability.AccountID] + overlay.AccountID = capability.AccountID + overlay.SupportsModel = true + values[capability.AccountID] = overlay + } + var blockRows []accountModelQuotaBlockModel + if err := r.db.db.WithContext(ctx). + Table("account_model_quota_blocks AS block"). + Select("block.*"). + Joins("JOIN provider_accounts AS account ON account.id = block.account_id"). + Where("account.provider = ? AND account.enabled = TRUE AND block.upstream_model = ? AND block.cooldown_until > ?", provider, upstreamModel, time.Now().UTC()). + Find(&blockRows).Error; err != nil { + return account.RoutingOverlaySnapshot{}, err + } + for _, row := range blockRows { + overlay := values[row.AccountID] + overlay.AccountID = row.AccountID + overlay.ModelQuotaBlock = &account.ModelQuotaBlock{AccountID: row.AccountID, UpstreamModel: row.UpstreamModel, Reason: row.Reason, CooldownUntil: row.CooldownUntil.UTC(), UpdatedAt: row.UpdatedAt.UTC()} + values[row.AccountID] = overlay + } + result := account.RoutingOverlaySnapshot{HasBindings: len(boundIDs) > 0, Values: make([]account.RoutingAccountOverlay, 0, len(values))} + for _, value := range values { + result.Values = append(result.Values, value) + } + return result, nil +} + +func (r *AccountRepository) listRoutingBoundAccountIDs(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel string) ([]uint64, error) { + query := r.db.db.WithContext(ctx). + Table("model_route_accounts AS binding"). + Select("binding.account_id"). + Joins("JOIN model_routes AS route ON route.id = binding.model_route_id") + if modelRouteID > 0 { + query = query.Where("route.id = ? AND route.provider = ? AND route.upstream_model = ?", modelRouteID, provider, upstreamModel) + } else { + query = query.Where("route.provider = ? AND route.upstream_model = ?", provider, upstreamModel) + } + var accountIDs []uint64 + if err := query.Scan(&accountIDs).Error; err != nil { + return nil, err + } + return accountIDs, nil +} + +func (r *AccountRepository) ListEnabled(ctx context.Context, provider account.Provider) ([]account.Credential, error) { + var rows []accountModel + err := r.db.db.WithContext(ctx).Preload("Credential").Preload("WebProfile").Where("provider = ? AND enabled = ? AND auth_status = ?", provider, true, account.AuthStatusActive).Order("priority DESC, id ASC").Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + out = append(out, toAccountDomain(row)) + } + if err := r.attachRoutingEgressIdentities(ctx, provider, out); err != nil { + return nil, err + } + return out, nil +} + +func (r *AccountRepository) ListEnabledAccountIDs(ctx context.Context, provider account.Provider, refreshableOnly bool) ([]uint64, error) { + query := r.db.db.WithContext(ctx). + Table("provider_accounts AS account"). + Select("account.id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", provider, true, account.AuthStatusActive) + if refreshableOnly { + query = query. + Joins("JOIN account_credentials AS credential ON credential.account_id = account.id"). + Where("credential.encrypted_refresh <> ''") + } + var ids []uint64 + err := query.Order("account.priority DESC, account.id ASC").Scan(&ids).Error + return ids, err +} + +func (r *AccountRepository) ListEnabledCredentialRefreshAccountIDs(ctx context.Context, provider account.Provider, refreshableOnly bool) ([]uint64, error) { + query := r.db.db.WithContext(ctx). + Table("provider_accounts AS account"). + Select("account.id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status IN ?", provider, true, []account.AuthStatus{account.AuthStatusActive, account.AuthStatusReauthRequired}) + if refreshableOnly { + query = query. + Joins("JOIN account_credentials AS credential ON credential.account_id = account.id"). + Where("credential.encrypted_refresh <> ''") + } + var ids []uint64 + err := query.Order("account.id ASC").Scan(&ids).Error + return ids, err +} + +func (r *AccountRepository) FilterMissingBuildConversionIDs(ctx context.Context, ids []uint64) ([]uint64, error) { + if len(ids) == 0 { + return []uint64{}, nil + } + var linkedIDs []uint64 + if err := r.db.db.WithContext(ctx).Model(&accountProviderLinkModel{}). + Where("web_account_id IN ?", ids).Pluck("web_account_id", &linkedIDs).Error; err != nil { + return nil, err + } + linked := make(map[uint64]struct{}, len(linkedIDs)) + for _, id := range linkedIDs { + linked[id] = struct{}{} + } + values := make([]uint64, 0, len(ids)-len(linked)) + for _, id := range ids { + if _, exists := linked[id]; !exists { + values = append(values, id) + } + } + return values, nil +} + +func (r *AccountRepository) ListUnlinkedWebAccountIDs(ctx context.Context, afterID uint64, limit int) ([]uint64, int64, error) { + if limit < 1 { + return []uint64{}, 0, nil + } + query := func() *gorm.DB { + return r.db.db.WithContext(ctx). + Table("provider_accounts AS account"). + Joins("LEFT JOIN account_provider_links AS link ON link.web_account_id = account.id"). + Where("account.provider = ? AND link.web_account_id IS NULL", account.ProviderWeb) + } + var total int64 + if afterID == 0 { + if err := query().Count(&total).Error; err != nil { + return nil, 0, err + } + } + var ids []uint64 + err := query(). + Select("account.id"). + Where("account.id > ?", afterID). + Order("account.id ASC"). + Limit(limit). + Scan(&ids).Error + return ids, total, err +} + +func (r *AccountRepository) ListMissingConsoleSyncAccounts(ctx context.Context, ids []uint64) ([]account.Credential, error) { + if len(ids) == 0 { + return []account.Credential{}, nil + } + var existing int64 + if err := r.db.db.WithContext(ctx).Model(&accountModel{}). + Where("id IN ? AND provider = ?", ids, account.ProviderWeb).Count(&existing).Error; err != nil { + return nil, err + } + if existing != int64(len(ids)) { + return nil, repository.ErrNotFound + } + var rows []accountModel + if err := r.db.db.WithContext(ctx). + Preload("Credential").Preload("WebProfile"). + Where("id IN ? AND provider = ?", ids, account.ProviderWeb). + Where(missingConsoleAccountPredicate, account.ProviderConsole). + Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + values := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + values = append(values, toAccountDomain(row)) + } + return values, nil +} + +func (r *AccountRepository) ListMissingConsoleSyncBatch(ctx context.Context, afterID uint64, limit int) ([]account.Credential, int64, int64, error) { + if limit < 1 { + return []account.Credential{}, 0, 0, nil + } + query := func() *gorm.DB { + return r.db.db.WithContext(ctx).Model(&accountModel{}). + Where("provider = ?", account.ProviderWeb). + Where(missingConsoleAccountPredicate, account.ProviderConsole) + } + var total, skipped int64 + if afterID == 0 { + if err := query().Count(&total).Error; err != nil { + return nil, 0, 0, err + } + var all int64 + if err := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("provider = ?", account.ProviderWeb).Count(&all).Error; err != nil { + return nil, 0, 0, err + } + skipped = max(0, all-total) + } + var rows []accountModel + if err := query().Preload("Credential").Preload("WebProfile"). + Where("id > ?", afterID).Order("id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, 0, 0, err + } + values := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + values = append(values, toAccountDomain(row)) + } + return values, total, skipped, nil +} + +func (r *AccountRepository) HasActive(ctx context.Context, provider account.Provider) (bool, error) { + var row struct{ ID uint64 } + err := r.db.db.WithContext(ctx).Model(&accountModel{}).Select("id").Where("provider = ? AND enabled = ? AND auth_status = ?", provider, true, account.AuthStatusActive).Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return row.ID > 0, err +} + +func (r *AccountRepository) Get(ctx context.Context, id uint64) (account.Credential, error) { + var row accountModel + if err := r.db.db.WithContext(ctx).Preload("Credential").Preload("WebProfile").First(&row, id).Error; err != nil { + return account.Credential{}, mapError(err) + } + value := toAccountDomain(row) + values := []account.Credential{value} + if err := r.attachAccountLinks(ctx, values); err != nil { + return account.Credential{}, err + } + return values[0], nil +} + +// GetCredentialMaterial hydrates the encrypted provider data for one account +// after routing has selected it. Routing candidate queries intentionally never +// load these encrypted columns. +func (r *AccountRepository) GetCredentialMaterial(ctx context.Context, accountID uint64, provider account.Provider) (account.CredentialMaterial, error) { + var row accountCredentialModel + if err := r.db.db.WithContext(ctx). + Table("account_credentials AS credential"). + Select("credential.*"). + Joins("JOIN provider_accounts AS account ON account.id = credential.account_id"). + Where("credential.account_id = ? AND account.provider = ? AND account.enabled = TRUE AND account.auth_status = ?", accountID, provider, account.AuthStatusActive). + Take(&row).Error; err != nil { + return account.CredentialMaterial{}, mapError(err) + } + return toCredentialMaterialDomain(row, provider), nil +} + +func (r *AccountRepository) LinkWebToBuild(ctx context.Context, webAccountID, buildAccountID uint64) error { + if webAccountID == 0 || buildAccountID == 0 || webAccountID == buildAccountID { + return repository.ErrConflict + } + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + var webAccount, buildAccount accountModel + if err := tx.Select("id", "provider").First(&webAccount, webAccountID).Error; err != nil { + return err + } + if err := tx.Select("id", "provider").First(&buildAccount, buildAccountID).Error; err != nil { + return err + } + if webAccount.Provider != string(account.ProviderWeb) || buildAccount.Provider != string(account.ProviderBuild) { + return repository.ErrConflict + } + var existing accountProviderLinkModel + err := tx.Where("web_account_id = ? OR build_account_id = ?", webAccountID, buildAccountID).First(&existing).Error + if err == nil { + if existing.WebAccountID == webAccountID && existing.BuildAccountID == buildAccountID { + return nil + } + return repository.ErrConflict + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&accountProviderLinkModel{WebAccountID: webAccountID, BuildAccountID: buildAccountID, CreatedAt: time.Now().UTC()}).Error + }) + err = mapError(err) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountCredentialChanged}) + } + return err +} + +func (r *AccountRepository) attachAccountLinks(ctx context.Context, values []account.Credential) error { + if len(values) == 0 { + return nil + } + ids := make([]uint64, 0, len(values)) + positions := make(map[uint64]int, len(values)) + for index := range values { + ids = append(ids, values[index].ID) + positions[values[index].ID] = index + } + var buildRows []struct { + WebAccountID uint64 + BuildAccountID uint64 + WebName string + BuildName string + WebEmail string + BuildEmail string + WebUserID string + BuildUserID string + WebSourceKey string + EgressIdentity string + WebNSFWEnabledAt *time.Time + WebTermsAcceptedAt *time.Time + WebTermsAcceptedVersion int + } + err := r.db.db.WithContext(ctx).Table("account_provider_links AS link"). + Select("link.web_account_id, link.build_account_id, web.name AS web_name, build.name AS build_name, web.email AS web_email, build.email AS build_email, web.user_id AS web_user_id, build.user_id AS build_user_id, web.source_key AS web_source_key, profile.egress_identity, profile.nsfw_enabled_at AS web_nsfw_enabled_at, profile.terms_accepted_at AS web_terms_accepted_at, profile.terms_accepted_version AS web_terms_accepted_version"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("JOIN provider_accounts AS build ON build.id = link.build_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.web_account_id IN ? OR link.build_account_id IN ?", ids, ids). + Scan(&buildRows).Error + if err != nil { + return err + } + for _, row := range buildRows { + egressIdentity := linkedWebEgressIdentity(row.EgressIdentity, row.WebSourceKey) + if index, ok := positions[row.WebAccountID]; ok { + values[index].LinkedAccountID = row.BuildAccountID + values[index].LinkedAccountName = row.BuildName + values[index].LinkedProvider = account.ProviderBuild + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.BuildAccountID, Provider: account.ProviderBuild, Name: row.BuildName, Email: row.BuildEmail, UserID: row.BuildUserID}) + if values[index].EgressIdentity == "" { + values[index].EgressIdentity = egressIdentity + } + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedVersion = row.WebTermsAcceptedVersion + values[index].WebTermsAcceptedAt = currentWebTermsAcceptedAt(row.WebTermsAcceptedAt, row.WebTermsAcceptedVersion) + } + if index, ok := positions[row.BuildAccountID]; ok { + values[index].LinkedAccountID = row.WebAccountID + values[index].LinkedAccountName = row.WebName + values[index].LinkedProvider = account.ProviderWeb + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.WebAccountID, Provider: account.ProviderWeb, Name: row.WebName, Email: row.WebEmail, UserID: row.WebUserID}) + values[index].EgressIdentity = egressIdentity + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedVersion = row.WebTermsAcceptedVersion + values[index].WebTermsAcceptedAt = currentWebTermsAcceptedAt(row.WebTermsAcceptedAt, row.WebTermsAcceptedVersion) + } + } + var consoleRows []struct { + WebAccountID uint64 + ConsoleAccountID uint64 + WebName string + ConsoleName string + WebEmail string + ConsoleEmail string + WebUserID string + ConsoleUserID string + WebSourceKey string + EgressIdentity string + WebNSFWEnabledAt *time.Time + WebTermsAcceptedAt *time.Time + WebTermsAcceptedVersion int + } + if err := r.db.db.WithContext(ctx).Table("web_console_account_links AS link"). + Select("link.web_account_id, link.console_account_id, web.name AS web_name, console.name AS console_name, web.email AS web_email, console.email AS console_email, web.user_id AS web_user_id, console.user_id AS console_user_id, web.source_key AS web_source_key, profile.egress_identity, profile.nsfw_enabled_at AS web_nsfw_enabled_at, profile.terms_accepted_at AS web_terms_accepted_at, profile.terms_accepted_version AS web_terms_accepted_version"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("JOIN provider_accounts AS console ON console.id = link.console_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.web_account_id IN ? OR link.console_account_id IN ?", ids, ids). + Scan(&consoleRows).Error; err != nil { + return err + } + for _, row := range consoleRows { + egressIdentity := linkedWebEgressIdentity(row.EgressIdentity, row.WebSourceKey) + if index, ok := positions[row.WebAccountID]; ok { + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.ConsoleAccountID, Provider: account.ProviderConsole, Name: row.ConsoleName, Email: row.ConsoleEmail, UserID: row.ConsoleUserID}) + if values[index].EgressIdentity == "" { + values[index].EgressIdentity = egressIdentity + } + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedVersion = row.WebTermsAcceptedVersion + values[index].WebTermsAcceptedAt = currentWebTermsAcceptedAt(row.WebTermsAcceptedAt, row.WebTermsAcceptedVersion) + } + if index, ok := positions[row.ConsoleAccountID]; ok { + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.WebAccountID, Provider: account.ProviderWeb, Name: row.WebName, Email: row.WebEmail, UserID: row.WebUserID}) + values[index].EgressIdentity = egressIdentity + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedVersion = row.WebTermsAcceptedVersion + values[index].WebTermsAcceptedAt = currentWebTermsAcceptedAt(row.WebTermsAcceptedAt, row.WebTermsAcceptedVersion) + } + } + return nil +} + +func currentWebTermsAcceptedAt(value *time.Time, version int) *time.Time { + if version < account.CurrentWebTermsVersion { + return nil + } + return value +} + +// attachRoutingEgressIdentities 只补充推理路由需要的稳定出口身份。 +// 管理端展示所需的账号名称和 linkedAccounts 仍由 attachAccountLinks 加载, +// 避免路由候选缓存刷新时额外查询两类完整关系。 +func (r *AccountRepository) attachRoutingEgressIdentities(ctx context.Context, provider account.Provider, values []account.Credential) error { + if len(values) == 0 || provider == account.ProviderWeb { + return nil + } + ids := make([]uint64, 0, len(values)) + positions := make(map[uint64]int, len(values)) + for index := range values { + ids = append(ids, values[index].ID) + positions[values[index].ID] = index + } + type identityRow struct { + AccountID uint64 + WebSourceKey string + EgressIdentity string + } + var rows []identityRow + query := r.db.db.WithContext(ctx) + switch provider { + case account.ProviderBuild: + query = query.Table("account_provider_links AS link"). + Select("link.build_account_id AS account_id, web.source_key AS web_source_key, profile.egress_identity"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.build_account_id IN ?", ids) + case account.ProviderConsole: + query = query.Table("web_console_account_links AS link"). + Select("link.console_account_id AS account_id, web.source_key AS web_source_key, profile.egress_identity"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.console_account_id IN ?", ids) + default: + return nil + } + if err := query.Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + if index, ok := positions[row.AccountID]; ok { + values[index].EgressIdentity = linkedWebEgressIdentity(row.EgressIdentity, row.WebSourceKey) + } + } + return nil +} + +func linkedWebEgressIdentity(stored, sourceKey string) string { + if value := strings.TrimSpace(stored); value != "" { + return value + } + value, _ := egressIdentityFromWebSourceKey(sourceKey) + return value +} + +func (r *AccountRepository) UpsertByIdentity(ctx context.Context, value account.Credential) (account.Credential, bool, error) { + var result repository.AccountUpsertResult + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + result, err = upsertAccountByIdentity(tx, value) + return err + }) + if err != nil { + return account.Credential{}, false, mapError(err) + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: value.Provider, AccountID: result.ID}) + stored, err := r.Get(ctx, result.ID) + return stored, result.Created, err +} + +func (r *AccountRepository) UpsertManyByIdentity(ctx context.Context, values []account.Credential) ([]repository.AccountUpsertResult, error) { + if len(values) == 0 { + return []repository.AccountUpsertResult{}, nil + } + results := make([]repository.AccountUpsertResult, len(values)) + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + identityKeys := make([]string, 0, len(values)) + sourceKeysByProvider := make(map[account.Provider][]string) + for _, value := range values { + identityKeys = append(identityKeys, fromAccountDomain(value).IdentityKey) + if strings.TrimSpace(value.SourceKey) != "" { + sourceKeysByProvider[value.Provider] = append(sourceKeysByProvider[value.Provider], value.SourceKey) + } + } + var existingRows []accountModel + if err := tx.Where("identity_key IN ?", identityKeys).Find(&existingRows).Error; err != nil { + return err + } + existingByIdentity := make(map[string]accountModel, len(values)) + for _, row := range existingRows { + existingByIdentity[row.IdentityKey] = row + } + existingBySource := make(map[string]accountModel, len(values)) + for providerValue, sourceKeys := range sourceKeysByProvider { + var sourceRows []accountModel + if err := tx.Where("provider = ? AND source_key IN ?", providerValue, sourceKeys).Find(&sourceRows).Error; err != nil { + return err + } + for _, row := range sourceRows { + key := providerSourceLookupKey(row.Provider, row.SourceKey) + if existing, duplicate := existingBySource[key]; duplicate && existing.ID != row.ID { + return fmt.Errorf("Provider %s 的来源凭据匹配多个账号", row.Provider) + } + existingBySource[key] = row + } + } + for index, value := range values { + identityKey := fromAccountDomain(value).IdentityKey + existing, foundByIdentity := existingByIdentity[identityKey] + bySource, foundBySource := existingBySource[providerSourceLookupKey(string(value.Provider), value.SourceKey)] + if foundByIdentity && foundBySource && existing.ID != bySource.ID { + return fmt.Errorf("账号身份与来源凭据指向不同账号") + } + if !foundByIdentity && foundBySource { + existing = bySource + } + var current *accountModel + if foundByIdentity || foundBySource { + current = &existing + } + result, stored, err := upsertKnownAccountByIdentity(tx, value, current) + if err != nil { + return err + } + results[index] = result + existingByIdentity[stored.IdentityKey] = stored + existingBySource[providerSourceLookupKey(stored.Provider, stored.SourceKey)] = stored + } + return nil + }) + if err != nil { + return nil, mapError(err) + } + providers := make(map[account.Provider]struct{}) + for _, value := range values { + providers[value.Provider] = struct{}{} + } + for providerValue := range providers { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: providerValue}) + } + return results, nil +} + +func upsertAccountByIdentity(tx *gorm.DB, value account.Credential) (repository.AccountUpsertResult, error) { + row := fromAccountDomain(value) + var byIdentity accountModel + identityErr := tx.Where("identity_key = ?", row.IdentityKey).First(&byIdentity).Error + if identityErr != nil && !errors.Is(identityErr, gorm.ErrRecordNotFound) { + return repository.AccountUpsertResult{}, identityErr + } + var sourceRows []accountModel + if strings.TrimSpace(row.SourceKey) != "" { + if err := tx.Where("provider = ? AND source_key = ?", row.Provider, row.SourceKey).Limit(2).Find(&sourceRows).Error; err != nil { + return repository.AccountUpsertResult{}, err + } + if len(sourceRows) > 1 { + return repository.AccountUpsertResult{}, fmt.Errorf("Provider %s 的来源凭据匹配多个账号", row.Provider) + } + } + if identityErr == nil && len(sourceRows) == 1 && byIdentity.ID != sourceRows[0].ID { + return repository.AccountUpsertResult{}, fmt.Errorf("账号身份与来源凭据指向不同账号") + } + if identityErr == nil { + result, _, err := upsertKnownAccountByIdentity(tx, value, &byIdentity) + return result, err + } + if len(sourceRows) == 1 { + result, _, err := upsertKnownAccountByIdentity(tx, value, &sourceRows[0]) + return result, err + } + result, _, err := upsertKnownAccountByIdentity(tx, value, nil) + return result, err +} + +func providerSourceLookupKey(providerValue, sourceKey string) string { + return providerValue + "\x00" + sourceKey +} + +func upsertKnownAccountByIdentity(tx *gorm.DB, value account.Credential, existing *accountModel) (repository.AccountUpsertResult, accountModel, error) { + row := fromAccountDomain(value) + if existing != nil { + if value.EncryptedCloudflareCookie == "" { + var storedCredential accountCredentialModel + if err := tx.Where("account_id = ?", existing.ID).First(&storedCredential).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return repository.AccountUpsertResult{}, accountModel{}, err + } + value.EncryptedCloudflareCookie = storedCredential.EncryptedCloudflareCookie + } + row.ID = existing.ID + row.CreatedAt = existing.CreatedAt + row.Enabled = existing.Enabled + row.Priority = existing.Priority + row.MaxConcurrent = existing.MaxConcurrent + row.MinimumRemaining = existing.MinimumRemaining + row.FailureCount = existing.FailureCount + row.CooldownUntil = existing.CooldownUntil + row.LastError = existing.LastError + row.LastUsedAt = existing.LastUsedAt + row.ObservedModel = existing.ObservedModel + row.ObservedModelAt = existing.ObservedModelAt + // 账号级 Build 路由、XAI 回退记录与 Super entitlement 在 upsert/转换/刷新路径中保留。 + row.BuildAPIFallback = existing.BuildAPIFallback + row.BuildRouteMode = existing.BuildRouteMode + row.BuildSuperEntitled = existing.BuildSuperEntitled + row.EgressNodeID = existing.EgressNodeID + row.EgressAssignmentMode = existing.EgressAssignmentMode + row.EgressAssignedAt = existing.EgressAssignedAt + // reauth_marked_at 与 Update 路径一致:保持 reauth 时永不被普通 upsert 改写。 + applyReauthMarkedAtTransition(&row, *existing) + if err := tx.Save(&row).Error; err != nil { + return repository.AccountUpsertResult{}, accountModel{}, err + } + if err := saveAccountRelations(tx, value, row.ID); err != nil { + return repository.AccountUpsertResult{}, accountModel{}, err + } + return repository.AccountUpsertResult{ID: row.ID}, row, nil + } + if row.AuthStatus == "" { + row.AuthStatus = string(account.AuthStatusActive) + } + if row.AuthStatus == string(account.AuthStatusReauthRequired) && row.ReauthMarkedAt == nil { + now := time.Now().UTC() + row.ReauthMarkedAt = &now + } + if row.AuthStatus != string(account.AuthStatusReauthRequired) { + row.ReauthMarkedAt = nil + } + if row.Priority == 0 { + row.Priority = account.DefaultPriority + } + if row.MaxConcurrent == 0 { + row.MaxConcurrent = account.DefaultMaxConcurrent + } + row.Enabled = true + if err := tx.Create(&row).Error; err != nil { + return repository.AccountUpsertResult{}, accountModel{}, err + } + if err := saveAccountRelations(tx, value, row.ID); err != nil { + return repository.AccountUpsertResult{}, accountModel{}, err + } + return repository.AccountUpsertResult{ID: row.ID, Created: true}, row, nil +} + +func (r *AccountRepository) Update(ctx context.Context, value account.Credential) (account.Credential, error) { + var row accountModel + var storedProvider account.Provider + if err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing accountModel + if err := tx.Select("id", "identity_key", "created_at", "provider", "auth_status", "reauth_marked_at").First(&existing, value.ID).Error; err != nil { + return err + } + storedProvider = account.Provider(existing.Provider) + value.Provider = storedProvider + row = fromAccountDomain(value) + row.ID = existing.ID + // 身份同步补充的 user_id/email 不得让普通编辑重写持久化身份键。 + row.IdentityKey = existing.IdentityKey + row.CreatedAt = existing.CreatedAt + applyReauthMarkedAtTransition(&row, existing) + if err := tx.Save(&row).Error; err != nil { + return err + } + return saveAccountRelations(tx, value, row.ID) + }); err != nil { + return account.Credential{}, mapError(err) + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: storedProvider, AccountID: row.ID}) + return r.Get(ctx, row.ID) +} + +// applyReauthMarkedAtTransition 仅在状态切入 reauthRequired 时打锚点;保持 reauth 时保留原锚点;离开 reauth 时清空。 +func applyReauthMarkedAtTransition(row *accountModel, existing accountModel) { + if row.AuthStatus == string(account.AuthStatusReauthRequired) { + if existing.AuthStatus == string(account.AuthStatusReauthRequired) && existing.ReauthMarkedAt != nil { + row.ReauthMarkedAt = existing.ReauthMarkedAt + return + } + if row.ReauthMarkedAt == nil { + now := time.Now().UTC() + row.ReauthMarkedAt = &now + } + return + } + row.ReauthMarkedAt = nil +} + +func saveAccountRelations(tx *gorm.DB, value account.Credential, accountID uint64) error { + value.ID = accountID + credential := fromAccountCredentialDomain(value) + if err := tx.Save(&credential).Error; err != nil { + return err + } + if profile := fromWebProfileDomain(value); profile != nil { + updates := []string{"tier", "synced_at"} + if profile.NSFWEnabledAt != nil { + updates = append(updates, "nsfw_enabled_at") + } + if profile.TermsAcceptedAt != nil { + updates = append(updates, "terms_accepted_at") + } + if profile.TermsAcceptedVersion > 0 { + updates = append(updates, "terms_accepted_version") + } + if profile.BirthDateSetAt != nil { + updates = append(updates, "birth_date_set_at") + } + if strings.TrimSpace(profile.EgressIdentity) != "" { + updates = append(updates, "egress_identity") + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "account_id"}}, + DoUpdates: clause.AssignmentColumns(updates), + }).Create(profile).Error + } + return tx.Where("account_id = ?", accountID).Delete(&webAccountProfileModel{}).Error +} + +// MarkWebNSFWEnabled 幂等保存首次成功开启时间;重复执行不会覆盖已有标记。 +func (r *AccountRepository) MarkWebNSFWEnabled(ctx context.Context, id uint64, enabledAt time.Time) error { + if id == 0 || enabledAt.IsZero() { + return fmt.Errorf("Web NSFW 标记参数无效") + } + err := r.markWebProfileTimestamp(ctx, id, "nsfw_enabled_at", enabledAt) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderWeb, AccountID: id}) + } + return err +} + +// MarkWebTermsAccepted 幂等保存已完整接受的产品协议版本。 +// 协议升级时会同步更新完成时间;相同或更高版本不会被覆盖。 +func (r *AccountRepository) MarkWebTermsAccepted(ctx context.Context, id uint64, version int, acceptedAt time.Time) error { + if id == 0 || version <= 0 || acceptedAt.IsZero() { + return fmt.Errorf("Web 服务协议标记参数无效") + } + acceptedAt = acceptedAt.UTC() + err := mapError(r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var accountRow accountModel + if err := tx.Select("id", "provider").First(&accountRow, id).Error; err != nil { + return err + } + if account.Provider(accountRow.Provider) != account.ProviderWeb { + return fmt.Errorf("仅 Grok Web 账号支持资料状态标记") + } + profile := webAccountProfileModel{ + AccountID: id, Tier: string(account.WebTierAuto), + TermsAcceptedAt: &acceptedAt, TermsAcceptedVersion: version, + } + created := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&profile) + if created.Error != nil || created.RowsAffected > 0 { + return created.Error + } + return tx.Model(&webAccountProfileModel{}). + Where("account_id = ? AND (terms_accepted_version < ? OR terms_accepted_at IS NULL)", id, version). + Updates(map[string]any{"terms_accepted_at": acceptedAt, "terms_accepted_version": version}).Error + })) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderWeb, AccountID: id}) + } + return err +} + +// MarkWebBirthDateSet 幂等保存首次成功设置或确认已有生日的时间。 +func (r *AccountRepository) MarkWebBirthDateSet(ctx context.Context, id uint64, setAt time.Time) error { + if id == 0 || setAt.IsZero() { + return fmt.Errorf("Web 生日标记参数无效") + } + err := r.markWebProfileTimestamp(ctx, id, "birth_date_set_at", setAt) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderWeb, AccountID: id}) + } + return err +} + +func (r *AccountRepository) markWebProfileTimestamp(ctx context.Context, id uint64, column string, value time.Time) error { + value = value.UTC() + return mapError(r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var accountRow accountModel + if err := tx.Select("id", "provider").First(&accountRow, id).Error; err != nil { + return err + } + if account.Provider(accountRow.Provider) != account.ProviderWeb { + return fmt.Errorf("仅 Grok Web 账号支持资料状态标记") + } + profile := webAccountProfileModel{AccountID: id, Tier: string(account.WebTierAuto)} + switch column { + case "nsfw_enabled_at": + profile.NSFWEnabledAt = &value + case "birth_date_set_at": + profile.BirthDateSetAt = &value + default: + return fmt.Errorf("Web 资料状态字段无效") + } + created := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&profile) + if created.Error != nil || created.RowsAffected > 0 { + return created.Error + } + switch column { + case "nsfw_enabled_at": + return tx.Model(&webAccountProfileModel{}). + Where("account_id = ? AND nsfw_enabled_at IS NULL", id). + Update("nsfw_enabled_at", value).Error + case "birth_date_set_at": + return tx.Model(&webAccountProfileModel{}). + Where("account_id = ? AND birth_date_set_at IS NULL", id). + Update("birth_date_set_at", value).Error + default: + return fmt.Errorf("Web 资料状态字段无效") + } + })) +} + +func (r *AccountRepository) UpdateMany(ctx context.Context, providerValue account.Provider, ids []uint64, updates repository.AccountUpdates) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + values := make(map[string]any, 4) + if updates.Enabled != nil { + values["enabled"] = *updates.Enabled + } + if updates.Priority != nil { + values["priority"] = *updates.Priority + } + if updates.MaxConcurrent != nil { + values["max_concurrent"] = *updates.MaxConcurrent + } + if updates.MinimumRemaining != nil { + values["minimum_remaining"] = *updates.MinimumRemaining + } + if len(values) == 0 { + return 0, nil + } + var updated int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for start := 0; start < len(ids); start += accountUpdateBatchSize { + end := min(start+accountUpdateBatchSize, len(ids)) + var rows []accountModel + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "provider").Where("id IN ?", ids[start:end]).Order("id ASC").Find(&rows).Error; err != nil { + return err + } + if len(rows) != end-start { + return repository.ErrAccountPoolMismatch + } + for _, row := range rows { + if account.Provider(row.Provider) != providerValue { + return repository.ErrAccountPoolMismatch + } + } + } + for start := 0; start < len(ids); start += accountUpdateBatchSize { + end := min(start+accountUpdateBatchSize, len(ids)) + result := tx.Model(&accountModel{}).Where("provider = ? AND id IN ?", providerValue, ids[start:end]).Updates(values) + if result.Error != nil { + return result.Error + } + updated += result.RowsAffected + } + return nil + }) + if err != nil { + return 0, err + } + if updated > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + } + return updated, nil +} + +// UpdateEgressBindings assigns one egress node to multiple accounts of one +// provider. A nil node clears the binding and restores normal pool selection. +func (r *AccountRepository) UpdateEgressBindings(ctx context.Context, providerValue account.Provider, ids []uint64, nodeID *uint64, mode account.EgressAssignmentMode, assignedAt time.Time) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + values := map[string]any{ + "egress_node_id": nodeID, + } + if nodeID == nil { + values["egress_assignment_mode"] = "" + values["egress_assigned_at"] = nil + } else { + values["egress_assignment_mode"] = string(mode) + values["egress_assigned_at"] = assignedAt.UTC() + } + result := r.db.db.WithContext(ctx).Model(&accountModel{}). + Where("provider = ? AND id IN ?", providerValue, ids). + Updates(values) + return result.RowsAffected, mapError(result.Error) +} + +// ListEgressAssignments returns all accounts for one provider with their +// binding metadata. It deliberately includes disabled accounts so capacity +// reporting reflects every account that reserves a proxy slot. +func (r *AccountRepository) ListEgressAssignments(ctx context.Context, providerValue account.Provider) ([]account.Credential, error) { + var rows []accountModel + if err := r.db.db.WithContext(ctx).Preload("Credential").Preload("WebProfile"). + Where("provider = ?", providerValue).Order("id ASC").Find(&rows).Error; err != nil { + return nil, mapError(err) + } + values := make([]account.Credential, 0, len(rows)) + for _, row := range rows { + values = append(values, toAccountDomain(row)) + } + return values, nil +} + +func (r *AccountRepository) ListEgressBindingProviders(ctx context.Context, nodeID uint64) ([]account.Provider, error) { + if nodeID == 0 { + return []account.Provider{}, nil + } + return r.listEgressBindingProviders(r.db.db.WithContext(ctx).Model(&accountModel{}).Where("egress_node_id = ?", nodeID)) +} + +func (r *AccountRepository) ListEgressSourceBindingProviders(ctx context.Context, sourceID uint64) ([]account.Provider, error) { + if sourceID == 0 { + return []account.Provider{}, nil + } + query := r.db.db.WithContext(ctx).Model(&accountModel{}). + Joins("JOIN egress_nodes ON egress_nodes.id = provider_accounts.egress_node_id"). + Where("egress_nodes.source_id = ?", sourceID) + return r.listEgressBindingProviders(query) +} + +func (r *AccountRepository) listEgressBindingProviders(query *gorm.DB) ([]account.Provider, error) { + var raw []string + if err := query.Distinct("provider_accounts.provider").Order("provider_accounts.provider ASC").Pluck("provider_accounts.provider", &raw).Error; err != nil { + return nil, mapError(err) + } + result := make([]account.Provider, 0, len(raw)) + for _, value := range raw { + provider := account.Provider(value) + if provider.IsValid() { + result = append(result, provider) + } + } + return result, nil +} + +func (r *AccountRepository) Delete(ctx context.Context, id uint64) error { + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + var lockedID uint64 + if err := tx.Model(&accountModel{}).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Pluck("id", &lockedID).Error; err != nil { + return err + } + if lockedID == 0 { + return repository.ErrNotFound + } + if err := rejectAccountsWithMediaJobs(tx, []uint64{id}); err != nil { + return err + } + return mapError(tx.Delete(&accountModel{}, id).Error) + }) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + } + return err +} + +func (r *AccountRepository) DeleteMany(ctx context.Context, ids []uint64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var deleted int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + var lockedIDs []uint64 + if err := tx.Model(&accountModel{}).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Pluck("id", &lockedIDs).Error; err != nil { + return err + } + if err := rejectAccountsWithMediaJobs(tx, lockedIDs); err != nil { + return err + } + result := tx.Where("id IN ?", lockedIDs).Delete(&accountModel{}) + deleted = result.RowsAffected + return result.Error + }) + if err == nil && deleted > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + } + return deleted, err +} + +func (r *AccountRepository) ListAutoCleanReauthCandidates(ctx context.Context, markedBefore time.Time, includeDisabled bool, afterID uint64, limit int) ([]uint64, error) { + if limit < 1 { + limit = 100 + } + query := r.db.db.WithContext(ctx).Model(&accountModel{}). + Select("id"). + Where("auth_status = ? AND reauth_marked_at IS NOT NULL AND reauth_marked_at < ?", account.AuthStatusReauthRequired, markedBefore.UTC()). + Where("NOT EXISTS (SELECT 1 FROM media_jobs job WHERE job.account_id = provider_accounts.id AND job.status IN ?)", []string{string(media.StatusQueued), string(media.StatusInProgress)}) + if afterID > 0 { + query = query.Where("id > ?", afterID) + } + if !includeDisabled { + query = query.Where("enabled = ?", true) + } + var candidates []uint64 + err := query.Order("id ASC").Limit(limit).Pluck("id", &candidates).Error + return candidates, err +} + +func (r *AccountRepository) DeleteAutoCleanReauthCandidates(ctx context.Context, markedBefore time.Time, includeDisabled bool, candidateIDs []uint64) ([]uint64, error) { + if len(candidateIDs) == 0 { + return []uint64{}, nil + } + deletedIDs := make([]uint64, 0, len(candidateIDs)) + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAccountLinkMutation(tx); err != nil { + return err + } + deletable, err := excludeAccountsWithActiveMediaJobs(tx, candidateIDs) + if err != nil { + return err + } + if len(deletable) == 0 { + return nil + } + + var lockedIDs []uint64 + lockQuery := tx.Model(&accountModel{}).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id IN ? AND auth_status = ? AND reauth_marked_at IS NOT NULL AND reauth_marked_at < ?", deletable, account.AuthStatusReauthRequired, markedBefore.UTC()) + if !includeDisabled { + lockQuery = lockQuery.Where("enabled = ?", true) + } + if err := lockQuery.Pluck("id", &lockedIDs).Error; err != nil { + return err + } + // lock 后再过滤活动视频任务,避免 list 与 delete 之间的 TOCTOU。 + lockedIDs, err = excludeAccountsWithActiveMediaJobs(tx, lockedIDs) + if err != nil { + return err + } + if len(lockedIDs) == 0 { + return nil + } + deletion := tx.Where("id IN ? AND auth_status = ? AND reauth_marked_at IS NOT NULL AND reauth_marked_at < ?", lockedIDs, account.AuthStatusReauthRequired, markedBefore.UTC()) + if !includeDisabled { + deletion = deletion.Where("enabled = ?", true) + } + result := deletion.Delete(&accountModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == int64(len(lockedIDs)) { + deletedIDs = append(deletedIDs, lockedIDs...) + return nil + } + var remaining []uint64 + if err := tx.Model(&accountModel{}).Where("id IN ?", lockedIDs).Pluck("id", &remaining).Error; err != nil { + return err + } + remainingSet := make(map[uint64]struct{}, len(remaining)) + for _, id := range remaining { + remainingSet[id] = struct{}{} + } + for _, id := range lockedIDs { + if _, exists := remainingSet[id]; !exists { + deletedIDs = append(deletedIDs, id) + } + } + return nil + }) + if err == nil && len(deletedIDs) > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged}) + } + return deletedIDs, err +} + +// excludeAccountsWithActiveMediaJobs 返回无 queued/in_progress 视频任务的账号 ID(顺序保持输入顺序)。 +func excludeAccountsWithActiveMediaJobs(db *gorm.DB, ids []uint64) ([]uint64, error) { + if len(ids) == 0 { + return nil, nil + } + var blocked []uint64 + if err := db.Model(&mediaJobModel{}). + Distinct("account_id"). + Where("account_id IN ? AND status IN ?", ids, []string{string(media.StatusQueued), string(media.StatusInProgress)}). + Pluck("account_id", &blocked).Error; err != nil { + return nil, err + } + if len(blocked) == 0 { + out := make([]uint64, len(ids)) + copy(out, ids) + return out, nil + } + blockedSet := make(map[uint64]struct{}, len(blocked)) + for _, id := range blocked { + blockedSet[id] = struct{}{} + } + out := make([]uint64, 0, len(ids)-len(blocked)) + for _, id := range ids { + if _, skip := blockedSet[id]; skip { + continue + } + out = append(out, id) + } + return out, nil +} + +// activeMediaJobStatuses lists video states that still require the account and block deletion. +func activeMediaJobStatuses() []string { + return []string{string(media.StatusQueued), string(media.StatusInProgress)} +} + +// rejectAccountsWithMediaJobs 仅保护仍需账号继续执行的活动视频任务。 +// completed/failed 已保存账号名称等快照,删除账号后由外键 SET NULL 保留历史。 +func rejectAccountsWithMediaJobs(db *gorm.DB, ids []uint64) error { + var count int64 + if err := db.Model(&mediaJobModel{}). + Where("account_id IN ? AND status IN ?", ids, activeMediaJobStatuses()). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return fmt.Errorf("%w: 账号仍关联 %d 条排队中或进行中的视频任务,请等待任务结束后重试", repository.ErrConflict, count) + } + return nil +} + +func applyAccountStatusFilter(query *gorm.DB, status string, now time.Time) *gorm.DB { + switch status { + case "active": + return query.Where("enabled = ? AND auth_status = ? AND NOT "+accountRecoveryPredicate+" AND NOT "+providerQuotaExhaustedPredicate+" AND (cooldown_until IS NULL OR cooldown_until <= ?)", true, account.AuthStatusActive, now) + case "disabled": + return query.Where("enabled = ?", false) + case "reauthRequired": + return query.Where("enabled = ? AND auth_status = ?", true, account.AuthStatusReauthRequired) + case "cooldown": + return query.Where("enabled = ? AND auth_status = ? AND NOT "+accountRecoveryPredicate+" AND cooldown_until > ?", true, account.AuthStatusActive, now) + case "waitingReset": + return query.Where("enabled = ? AND auth_status = ? AND (EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status = 'exhausted') OR "+providerQuotaExhaustedPredicate+")", true, account.AuthStatusActive) + case "probing": + return query.Where("enabled = ? AND auth_status = ? AND EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = provider_accounts.id AND recovery.status = 'probing')", true, account.AuthStatusActive) + default: + return query + } +} + +// Web agreement predicates match the effective state exposed by the admin API. +// Terms are current only when the recorded version reaches CurrentWebTermsVersion. +const ( + webNSFWEnabledPredicate = "EXISTS (SELECT 1 FROM web_account_profiles profile WHERE profile.account_id = provider_accounts.id AND profile.nsfw_enabled_at IS NOT NULL)" + webTermsAcceptedPredicate = "EXISTS (SELECT 1 FROM web_account_profiles profile WHERE profile.account_id = provider_accounts.id AND profile.terms_accepted_at IS NOT NULL AND profile.terms_accepted_version >= ?)" + webBuildLinkedPredicate = "EXISTS (SELECT 1 FROM account_provider_links link WHERE link.web_account_id = provider_accounts.id)" + webConsoleLinkedPredicate = "EXISTS (SELECT 1 FROM web_console_account_links link WHERE link.web_account_id = provider_accounts.id)" + // Build and Console filter by whether a Web link exists. + buildWebLinkedPredicate = "EXISTS (SELECT 1 FROM account_provider_links link WHERE link.build_account_id = provider_accounts.id)" + consoleWebLinkedPredicate = "EXISTS (SELECT 1 FROM web_console_account_links link WHERE link.console_account_id = provider_accounts.id)" +) + +func applyWebAgreementFilter(query *gorm.DB, agreement string) *gorm.DB { + switch agreement { + case "nsfwEnabled": + return query.Where(webNSFWEnabledPredicate) + case "nsfwDisabled": + return query.Where("NOT " + webNSFWEnabledPredicate) + case "termsAccepted": + return query.Where(webTermsAcceptedPredicate, account.CurrentWebTermsVersion) + case "termsNotAccepted": + return query.Where("NOT "+webTermsAcceptedPredicate, account.CurrentWebTermsVersion) + case "allAccepted": + return query.Where(webNSFWEnabledPredicate).Where(webTermsAcceptedPredicate, account.CurrentWebTermsVersion) + case "allNotAccepted": + return query.Where("NOT "+webNSFWEnabledPredicate).Where("NOT "+webTermsAcceptedPredicate, account.CurrentWebTermsVersion) + default: + return query + } +} + +// applyAssociationFilter applies provider-specific association predicates. +// Web supports Build, Console, and combined filters; Build and Console use +// provider-specific foreign keys for webLinked and webUnlinked. +func applyAssociationFilter(query *gorm.DB, providerValue, association string) *gorm.DB { + switch association { + case "buildLinked": + return query.Where(webBuildLinkedPredicate) + case "buildUnlinked": + return query.Where("NOT " + webBuildLinkedPredicate) + case "consoleLinked": + return query.Where(webConsoleLinkedPredicate) + case "consoleUnlinked": + return query.Where("NOT " + webConsoleLinkedPredicate) + case "allLinked": + return query.Where(webBuildLinkedPredicate).Where(webConsoleLinkedPredicate) + case "allUnlinked": + return query.Where("NOT " + webBuildLinkedPredicate).Where("NOT " + webConsoleLinkedPredicate) + case "webLinked": + if providerValue == string(account.ProviderConsole) { + return query.Where(consoleWebLinkedPredicate) + } + return query.Where(buildWebLinkedPredicate) + case "webUnlinked": + if providerValue == string(account.ProviderConsole) { + return query.Where("NOT " + consoleWebLinkedPredicate) + } + return query.Where("NOT " + buildWebLinkedPredicate) + default: + return query + } +} + +func (r *AccountRepository) UpdateTokens(ctx context.Context, id uint64, accessToken, refreshToken string, expiresAt time.Time) (account.Credential, error) { + now := time.Now().UTC() + refreshDueAt := account.CredentialRefreshDueAt(id, expiresAt) + updates := map[string]any{ + "encrypted_primary": accessToken, "expires_at": expiresAt, "refresh_due_at": refreshDueAt, + "last_refresh_at": now, "refresh_failures": 0, "last_refresh_error_status": 0, "last_refresh_error": "", "last_refresh_error_message": "", "last_refresh_error_response": "", "refresh_permanent": false, "updated_at": now, + } + if refreshToken != "" { + updates["encrypted_refresh"] = refreshToken + } + if err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&accountCredentialModel{}).Where("account_id = ?", id).Updates(updates).Error; err != nil { + return err + } + return tx.Model(&accountModel{}).Where("id = ?", id).Updates(map[string]any{"auth_status": string(account.AuthStatusActive), "last_error": "", "reauth_marked_at": nil}).Error + }); err != nil { + return account.Credential{}, err + } + stored, err := r.Get(ctx, id) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountCredentialChanged, Provider: stored.Provider, AccountID: id}) + } else { + // The database write already committed; retain a broad fallback if the read-back fails. + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountCredentialChanged, AccountID: id}) + } + return stored, err +} + +// BackfillCredentialRefreshSchedules 为升级前凭据分批补齐调度时间,不解密 Token,也不发起 OAuth 请求。 +func (r *AccountRepository) BackfillCredentialRefreshSchedules(ctx context.Context, now time.Time, limit int) (int, error) { + if limit < 1 { + return 0, nil + } + var rows []struct { + AccountID uint64 + ExpiresAt *time.Time + EncryptedPrimary string + } + err := r.db.db.WithContext(ctx). + Table("account_credentials AS credential"). + Select("credential.account_id, credential.expires_at, credential.encrypted_primary"). + Joins("JOIN provider_accounts AS account ON account.id = credential.account_id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", account.ProviderBuild, true, account.AuthStatusActive). + Where("credential.auth_type = ? AND credential.encrypted_refresh <> '' AND credential.refresh_due_at IS NULL", account.AuthTypeOAuth). + Where("credential.expires_at IS NOT NULL OR credential.encrypted_primary = ''"). + Order("credential.account_id ASC").Limit(limit).Scan(&rows).Error + if err != nil || len(rows) == 0 { + return 0, err + } + err = r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, row := range rows { + dueAt := now + if row.EncryptedPrimary != "" && row.ExpiresAt != nil && !row.ExpiresAt.IsZero() { + dueAt = account.CredentialRefreshDueAt(row.AccountID, *row.ExpiresAt) + } + if err := tx.Model(&accountCredentialModel{}).Where("account_id = ? AND refresh_due_at IS NULL", row.AccountID).Update("refresh_due_at", dueAt).Error; err != nil { + return err + } + } + return nil + }) + return len(rows), err +} + +// ListCriticalCredentialRefreshIDs 只返回重启后必须优先恢复的凭据,避免启动时刷新整个账号池。 +func (r *AccountRepository) ListCriticalCredentialRefreshIDs(ctx context.Context, now, expiresBefore time.Time, limit int) ([]uint64, error) { + if limit < 1 { + return []uint64{}, nil + } + var ids []uint64 + err := r.db.db.WithContext(ctx). + Table("account_credentials AS credential"). + Select("credential.account_id"). + Joins("JOIN provider_accounts AS account ON account.id = credential.account_id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", account.ProviderBuild, true, account.AuthStatusActive). + Where("credential.auth_type = ? AND credential.encrypted_refresh <> ''", account.AuthTypeOAuth). + Where("credential.encrypted_primary = '' OR credential.expires_at <= ? OR (credential.refresh_failures > 0 AND credential.refresh_due_at IS NOT NULL AND credential.refresh_due_at <= ?)", expiresBefore.UTC(), now.UTC()). + Order(gorm.Expr("CASE WHEN credential.encrypted_primary = '' THEN 0 WHEN credential.expires_at <= ? THEN 1 ELSE 2 END, credential.expires_at ASC, credential.account_id ASC", now.UTC())). + Limit(limit). + Scan(&ids).Error + return ids, err +} + +func (r *AccountRepository) ListDueCredentialRefreshIDs(ctx context.Context, now time.Time, limit int) ([]uint64, error) { + if limit < 1 { + return []uint64{}, nil + } + var ids []uint64 + err := r.db.db.WithContext(ctx). + Table("account_credentials AS credential"). + Select("credential.account_id"). + Joins("JOIN provider_accounts AS account ON account.id = credential.account_id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", account.ProviderBuild, true, account.AuthStatusActive). + Where("credential.auth_type = ? AND credential.encrypted_refresh <> '' AND credential.refresh_due_at IS NOT NULL AND credential.refresh_due_at <= ?", account.AuthTypeOAuth, now). + Order("credential.refresh_due_at ASC, credential.account_id ASC").Limit(limit).Scan(&ids).Error + return ids, err +} + +func (r *AccountRepository) NextCredentialRefreshDueAt(ctx context.Context) (*time.Time, error) { + var rows []struct{ RefreshDueAt time.Time } + err := r.db.db.WithContext(ctx). + Table("account_credentials AS credential"). + Select("credential.refresh_due_at"). + Joins("JOIN provider_accounts AS account ON account.id = credential.account_id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", account.ProviderBuild, true, account.AuthStatusActive). + Where("credential.auth_type = ? AND credential.encrypted_refresh <> '' AND credential.refresh_due_at IS NOT NULL", account.AuthTypeOAuth). + Order("credential.refresh_due_at ASC, credential.account_id ASC").Limit(1).Scan(&rows).Error + if err != nil || len(rows) == 0 { + return nil, err + } + value := rows[0].RefreshDueAt.UTC() + return &value, nil +} + +func (r *AccountRepository) UpdateCredentialRefreshFailure(ctx context.Context, id uint64, failure repository.CredentialRefreshFailure) error { + err := r.db.db.WithContext(ctx).Model(&accountCredentialModel{}).Where("account_id = ?", id).Updates(map[string]any{ + "refresh_due_at": failure.RetryAt.UTC(), "refresh_failures": max(0, failure.Count), + "last_refresh_error_status": max(0, failure.Status), "last_refresh_error": truncate(failure.Code, 100), + "last_refresh_error_message": truncate(failure.Message, 512), "last_refresh_error_response": truncate(failure.Response, 4096), + "refresh_permanent": failure.Permanent, "updated_at": time.Now().UTC(), + }).Error + if err == nil && failure.Permanent { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountCredentialChanged, AccountID: id}) + } + return err +} + +func (r *AccountRepository) UpdateObservedModel(ctx context.Context, id uint64, model string, observedAt time.Time) error { + _, err := r.UpdateObservedModelIfNewer(ctx, id, model, observedAt) + return err +} + +func (r *AccountRepository) UpdateObservedModelIfNewer(ctx context.Context, id uint64, model string, observedAt time.Time) (bool, error) { + model = truncate(model, 255) + result := r.db.db.WithContext(ctx).Model(&accountModel{}). + Where("id = ? AND (observed_model_at IS NULL OR observed_model_at <= ?) AND (COALESCE(observed_model, '') <> ? OR observed_model_at <= ?)", id, observedAt, model, observedAt.Add(-30*time.Minute)). + Updates(map[string]any{"observed_model": model, "observed_model_at": observedAt}) + if result.Error == nil && result.RowsAffected > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, AccountID: id}) + } + return result.RowsAffected > 0, result.Error +} + +// MarkBuildAPIFallback idempotently updates the XAI inference fallback marker for Grok Build accounts. +func (r *AccountRepository) MarkBuildAPIFallback(ctx context.Context, id uint64, enabled bool) error { + result := r.db.db.WithContext(ctx).Model(&accountModel{}). + Where("id = ? AND provider = ?", id, account.ProviderBuild). + Update("build_api_fallback", enabled) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + var count int64 + if err := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return repository.ErrNotFound + } + return fmt.Errorf("仅 grok_build 账号支持 Build API 降级标记") + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild, AccountID: id}) + return nil +} + +func (r *AccountRepository) UpdateHealth(ctx context.Context, id uint64, failureCount int, cooldownUntil *time.Time, lastError string, success bool) error { + updates := map[string]any{"failure_count": failureCount, "cooldown_until": cooldownUntil, "last_error": truncate(lastError, 512)} + if success { + now := time.Now().UTC() + updates["last_used_at"] = &now + } + err := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Updates(updates).Error + if err == nil && !success { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, AccountID: id}) + } + return err +} + +func (r *AccountRepository) UpsertModelQuotaBlock(ctx context.Context, value account.ModelQuotaBlock) error { + value.UpstreamModel = strings.TrimSpace(value.UpstreamModel) + value.Reason = strings.TrimSpace(value.Reason) + if value.AccountID == 0 || value.UpstreamModel == "" || value.Reason == "" || value.CooldownUntil.IsZero() { + return repository.ErrConflict + } + now := time.Now().UTC() + row := accountModelQuotaBlockModel{ + AccountID: value.AccountID, UpstreamModel: truncate(value.UpstreamModel, 255), Reason: truncate(value.Reason, 100), + CooldownUntil: value.CooldownUntil.UTC(), UpdatedAt: now, + } + err := r.db.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "account_id"}, {Name: "upstream_model"}}, + DoUpdates: clause.Assignments(map[string]any{ + "reason": gorm.Expr("CASE WHEN cooldown_until > ? THEN reason ELSE ? END", row.CooldownUntil, row.Reason), + "cooldown_until": gorm.Expr("CASE WHEN cooldown_until > ? THEN cooldown_until ELSE ? END", row.CooldownUntil, row.CooldownUntil), "updated_at": now, + }), + }).Create(&row).Error + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountModelQuotaChanged, AccountID: value.AccountID, UpstreamModel: value.UpstreamModel}) + } + return err +} + +func (r *AccountRepository) PruneExpiredModelQuotaBlocks(ctx context.Context, now time.Time, limit int) (int64, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + var rows []accountModelQuotaBlockModel + if err := r.db.db.WithContext(ctx).Select("account_id", "upstream_model").Where("cooldown_until <= ?", now.UTC()).Order("cooldown_until ASC").Limit(limit).Find(&rows).Error; err != nil || len(rows) == 0 { + return 0, err + } + var deleted int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, row := range rows { + result := tx.Where("account_id = ? AND upstream_model = ? AND cooldown_until <= ?", row.AccountID, row.UpstreamModel, now.UTC()).Delete(&accountModelQuotaBlockModel{}) + if result.Error != nil { + return result.Error + } + deleted += result.RowsAffected + } + return nil + }) + if err == nil && deleted > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountModelQuotaChanged}) + } + return deleted, err +} + +func (r *AccountRepository) SaveBilling(ctx context.Context, value account.Billing) error { + history, err := json.Marshal(value.History) + if err != nil { + return err + } + row := billingModel{AccountID: value.AccountID, PlanCode: truncate(value.PlanCode, 100), PlanName: truncate(value.PlanName, 160), MonthlyLimit: value.MonthlyLimit, Used: value.Used, OnDemandCap: value.OnDemandCap, OnDemandUsed: value.OnDemandUsed, PrepaidBalance: value.PrepaidBalance, CreditUsagePercent: value.CreditUsagePercent, IsUnifiedBillingUser: value.IsUnifiedBillingUser, OnDemandEnabled: value.OnDemandEnabled, TopUpMethod: truncate(value.TopUpMethod, 100), UsagePeriodType: truncate(value.UsagePeriodType, 100), UsagePeriodStart: truncate(value.UsagePeriodStart, 64), UsagePeriodEnd: truncate(value.UsagePeriodEnd, 64), BillingPeriodStart: truncate(value.BillingPeriodStart, 64), BillingPeriodEnd: truncate(value.BillingPeriodEnd, 64), HistoryJSON: string(history), SyncedAt: value.SyncedAt} + err = r.db.db.WithContext(ctx).Save(&row).Error + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountBillingChanged, AccountID: value.AccountID}) + } + return err +} + +func (r *AccountRepository) GetBilling(ctx context.Context, accountID uint64) (account.Billing, error) { + var row billingModel + if err := r.db.db.WithContext(ctx).First(&row, "account_id = ?", accountID).Error; err != nil { + return account.Billing{}, mapError(err) + } + return toBillingDomain(row), nil +} + +func (r *AccountRepository) GetBillings(ctx context.Context, accountIDs []uint64) (map[uint64]account.Billing, error) { + result := make(map[uint64]account.Billing, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var rows []billingModel + if err := r.db.db.WithContext(ctx).Where("account_id IN ?", accountIDs).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + result[row.AccountID] = toBillingDomain(row) + } + return result, nil +} + +func (r *AccountRepository) GetQuotaRecovery(ctx context.Context, accountID uint64) (account.QuotaRecovery, error) { + var row quotaRecoveryModel + if err := r.db.db.WithContext(ctx).First(&row, "account_id = ?", accountID).Error; err != nil { + return account.QuotaRecovery{}, mapError(err) + } + return account.QuotaRecovery{ + AccountID: row.AccountID, Kind: account.QuotaRecoveryKind(row.Kind), Status: account.QuotaRecoveryStatus(row.Status), ConfirmedUsed: row.ConfirmedUsed, + ConfirmedLimit: row.ConfirmedLimit, ExhaustedAt: row.ExhaustedAt, NextProbeAt: row.NextProbeAt, + LastConfirmedAt: row.LastConfirmedAt, UpdatedAt: row.UpdatedAt, + }, nil +} + +func (r *AccountRepository) GetQuotaRecoveries(ctx context.Context, accountIDs []uint64) (map[uint64]account.QuotaRecovery, error) { + result := make(map[uint64]account.QuotaRecovery, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var rows []quotaRecoveryModel + if err := r.db.db.WithContext(ctx).Where("account_id IN ?", accountIDs).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + result[row.AccountID] = account.QuotaRecovery{ + AccountID: row.AccountID, Kind: account.QuotaRecoveryKind(row.Kind), Status: account.QuotaRecoveryStatus(row.Status), ConfirmedUsed: row.ConfirmedUsed, + ConfirmedLimit: row.ConfirmedLimit, ExhaustedAt: row.ExhaustedAt, NextProbeAt: row.NextProbeAt, + LastConfirmedAt: row.LastConfirmedAt, UpdatedAt: row.UpdatedAt, + } + } + return result, nil +} + +func (r *AccountRepository) SaveQuotaRecovery(ctx context.Context, value account.QuotaRecovery) error { + row := quotaRecoveryModel{ + AccountID: value.AccountID, Kind: string(value.Kind), Status: string(value.Status), ConfirmedUsed: value.ConfirmedUsed, + ConfirmedLimit: value.ConfirmedLimit, ExhaustedAt: value.ExhaustedAt, NextProbeAt: value.NextProbeAt, + LastConfirmedAt: value.LastConfirmedAt, UpdatedAt: value.UpdatedAt, + } + err := r.db.db.WithContext(ctx).Save(&row).Error + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountRecoveryChanged, AccountID: value.AccountID}) + } + return err +} + +func (r *AccountRepository) ClaimQuotaProbe(ctx context.Context, accountID uint64, now, leaseUntil time.Time) (bool, error) { + result := r.db.db.WithContext(ctx).Model("aRecoveryModel{}). + Where("account_id = ? AND status IN ? AND next_probe_at IS NOT NULL AND next_probe_at <= ?", accountID, []string{string(account.QuotaRecoveryStatusExhausted), string(account.QuotaRecoveryStatusProbing)}, now). + Updates(map[string]any{"status": string(account.QuotaRecoveryStatusProbing), "next_probe_at": leaseUntil, "updated_at": now}) + return result.RowsAffected == 1, result.Error +} + +func (r *AccountRepository) ClearQuotaRecovery(ctx context.Context, accountID uint64) error { + err := r.db.db.WithContext(ctx).Delete("aRecoveryModel{}, "account_id = ?", accountID).Error + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountRecoveryChanged, AccountID: accountID}) + } + return err +} + +func (r *AccountRepository) ResetQuotaState(ctx context.Context, provider account.Provider, accountIDs []uint64) error { + if len(accountIDs) == 0 { + return nil + } + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("account_id IN ?", accountIDs).Delete("aRecoveryModel{}).Error; err != nil { + return err + } + return tx.Where("account_id IN ? AND reason = ?", accountIDs, "model_quota_depleted").Delete(&accountModelQuotaBlockModel{}).Error + }) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountRecoveryChanged, Provider: provider}) + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountModelQuotaChanged, Provider: provider}) + } + return err +} + +func (r *AccountRepository) ResetProviderQuotaState(ctx context.Context, provider account.Provider, activeOnly bool) (int64, error) { + var accountCount int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + accountQuery := func() *gorm.DB { + query := tx.Model(&accountModel{}).Where("provider = ?", provider) + if activeOnly { + query = query.Where("enabled = ? AND auth_status = ?", true, account.AuthStatusActive) + } + return query + } + if err := accountQuery().Count(&accountCount).Error; err != nil { + return err + } + if err := tx.Where("account_id IN (?)", accountQuery().Select("id")).Delete("aRecoveryModel{}).Error; err != nil { + return err + } + return tx.Where("account_id IN (?) AND reason = ?", accountQuery().Select("id"), "model_quota_depleted").Delete(&accountModelQuotaBlockModel{}).Error + }) + if err == nil && accountCount > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountRecoveryChanged, Provider: provider}) + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountModelQuotaChanged, Provider: provider}) + } + return accountCount, err +} + +func (r *AccountRepository) HasQuotaWindows(ctx context.Context, accountID uint64) (bool, error) { + var count int64 + err := r.db.db.WithContext(ctx).Model("aWindowModel{}).Where("account_id = ? AND synced_at IS NOT NULL", accountID).Count(&count).Error + return count > 0, err +} + +func (r *AccountRepository) GetQuotaWindows(ctx context.Context, accountIDs []uint64) (map[uint64][]account.QuotaWindow, error) { + result := make(map[uint64][]account.QuotaWindow, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var rows []quotaWindowModel + if err := r.db.db.WithContext(ctx).Where("account_id IN ?", accountIDs).Order("account_id ASC, mode ASC").Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + result[row.AccountID] = append(result[row.AccountID], toQuotaWindowDomain(row)) + } + return result, nil +} + +func (r *AccountRepository) SaveQuotaWindows(ctx context.Context, accountID uint64, tier account.WebTier, syncedAt time.Time, values []account.QuotaWindow) error { + err := r.saveQuotaWindows(ctx, accountID, tier, syncedAt, values, false) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountQuotaChanged, AccountID: accountID}) + } + return err +} + +func (r *AccountRepository) ReplaceQuotaWindows(ctx context.Context, accountID uint64, tier account.WebTier, syncedAt time.Time, values []account.QuotaWindow) error { + err := r.saveQuotaWindows(ctx, accountID, tier, syncedAt, values, true) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountQuotaChanged, AccountID: accountID}) + } + return err +} + +func (r *AccountRepository) saveQuotaWindows(ctx context.Context, accountID uint64, tier account.WebTier, syncedAt time.Time, values []account.QuotaWindow, replace bool) error { + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if tier != "" { + profile := webAccountProfileModel{AccountID: accountID, Tier: string(tier), SyncedAt: &syncedAt} + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "account_id"}}, DoUpdates: clause.AssignmentColumns([]string{"tier", "synced_at"})}).Create(&profile).Error; err != nil { + return err + } + } + if replace { + if err := tx.Where("account_id = ?", accountID).Delete("aWindowModel{}).Error; err != nil { + return err + } + } + for _, value := range values { + serializedBreakdown := make([]quotaBreakdownJSON, 0, len(value.Breakdown)) + for _, item := range value.Breakdown { + serializedBreakdown = append(serializedBreakdown, quotaBreakdownJSON{ProductCode: item.ProductCode, UsagePercent: item.UsagePercent}) + } + breakdownJSON, err := json.Marshal(serializedBreakdown) + if err != nil { + return err + } + row := quotaWindowModel{ + AccountID: accountID, Mode: truncate(strings.TrimSpace(value.Mode), 64), Remaining: max(0, value.Remaining), Total: max(0, value.Total), + UsagePercent: min(100, max(0, value.UsagePercent)), BreakdownJSON: string(breakdownJSON), + WindowSeconds: max(0, value.WindowSeconds), ResetAt: value.ResetAt, SyncedAt: value.SyncedAt, Source: string(value.Source), UpdatedAt: syncedAt, + } + if row.Source == "" { + row.Source = string(account.QuotaSourceUpstream) + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "account_id"}, {Name: "mode"}}, + DoUpdates: clause.AssignmentColumns([]string{"remaining", "total", "usage_percent", "breakdown_json", "window_seconds", "reset_at", "synced_at", "source", "updated_at"}), + }).Create(&row).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *AccountRepository) DecrementQuotaWindow(ctx context.Context, accountID uint64, mode string, now time.Time) (bool, error) { + result := r.db.db.WithContext(ctx).Model("aWindowModel{}). + Where("account_id = ? AND mode = ? AND remaining > 0", accountID, mode). + Updates(map[string]any{"remaining": gorm.Expr("remaining - 1"), "updated_at": now}) + return result.RowsAffected == 1, result.Error +} + +func (r *AccountRepository) DecrementQuotaWindowBy(ctx context.Context, accountID uint64, mode string, amount int, now time.Time) (bool, error) { + if amount <= 0 { + amount = 1 + } + result := r.db.db.WithContext(ctx).Model("aWindowModel{}). + Where("account_id = ? AND mode = ? AND remaining > 0", accountID, mode). + Updates(map[string]any{ + "remaining": gorm.Expr("CASE WHEN remaining <= ? THEN 0 ELSE remaining - ? END", amount, amount), + "updated_at": now, + }) + return result.RowsAffected == 1, result.Error +} + +func (r *AccountRepository) ExhaustQuotaWindow(ctx context.Context, accountID uint64, mode string, resetAt *time.Time, now time.Time) error { + err := r.db.db.WithContext(ctx).Model("aWindowModel{}).Where("account_id = ? AND mode = ?", accountID, mode). + Updates(map[string]any{"remaining": 0, "reset_at": resetAt, "updated_at": now}).Error + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountQuotaChanged, AccountID: accountID}) + } + return err +} + +func (r *AccountRepository) ListDueQuotaWindows(ctx context.Context, now time.Time, limit int) ([]account.QuotaWindow, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + var rows []quotaWindowModel + if err := r.db.db.WithContext(ctx).Where("remaining = 0 AND reset_at IS NOT NULL AND reset_at <= ?", now).Order("reset_at ASC, account_id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + values := make([]account.QuotaWindow, 0, len(rows)) + for _, row := range rows { + values = append(values, toQuotaWindowDomain(row)) + } + return values, nil +} + +func (r *AccountRepository) ListQuotaRecoveryWindows(ctx context.Context, limit int) ([]account.QuotaWindow, error) { + if limit <= 0 || limit > 100000 { + limit = 100000 + } + var rows []quotaWindowModel + if err := r.db.db.WithContext(ctx).Where("remaining = 0 AND reset_at IS NOT NULL").Order("reset_at ASC, account_id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + values := make([]account.QuotaWindow, 0, len(rows)) + for _, row := range rows { + values = append(values, toQuotaWindowDomain(row)) + } + return values, nil +} + +// ListStaleWebQuotaAccountIDs 返回缺失或长期未同步额度的 Web 账号,供重启后的低优先级追赶任务使用。 +func (r *AccountRepository) ListStaleWebQuotaAccountIDs(ctx context.Context, before time.Time, limit int) ([]uint64, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + var ids []uint64 + err := r.db.db.WithContext(ctx). + Table("provider_accounts AS account"). + Select("account.id"). + Joins("LEFT JOIN account_quota_windows AS quota ON quota.account_id = account.id"). + Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", account.ProviderWeb, true, account.AuthStatusActive). + Group("account.id"). + Having("MAX(quota.synced_at) IS NULL OR MAX(quota.synced_at) < ?", before.UTC()). + Order("MIN(quota.synced_at) ASC, account.id ASC"). + Limit(limit). + Scan(&ids).Error + return ids, err +} + +func toQuotaWindowDomain(row quotaWindowModel) account.QuotaWindow { + var serializedBreakdown []quotaBreakdownJSON + _ = json.Unmarshal([]byte(row.BreakdownJSON), &serializedBreakdown) + result := toRoutingQuotaWindowDomain(row) + breakdown := make([]account.QuotaBreakdown, 0, len(serializedBreakdown)) + for _, item := range serializedBreakdown { + breakdown = append(breakdown, account.QuotaBreakdown{ProductCode: item.ProductCode, UsagePercent: item.UsagePercent}) + } + result.Breakdown = breakdown + return result +} + +func toRoutingQuotaWindowDomain(row quotaWindowModel) account.QuotaWindow { + return account.QuotaWindow{ + AccountID: row.AccountID, Mode: row.Mode, Remaining: row.Remaining, Total: row.Total, + UsagePercent: row.UsagePercent, WindowSeconds: row.WindowSeconds, + ResetAt: row.ResetAt, SyncedAt: row.SyncedAt, Source: account.QuotaSource(row.Source), UpdatedAt: row.UpdatedAt, + } +} diff --git a/backend/internal/infra/persistence/relational/admin_repository.go b/backend/internal/infra/persistence/relational/admin_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..ec731dba27025880177ef656486d303ac91c4aaf --- /dev/null +++ b/backend/internal/infra/persistence/relational/admin_repository.go @@ -0,0 +1,132 @@ +package relational + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/admin" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +type AdminRepository struct{ db *Database } + +func NewAdminRepository(db *Database) *AdminRepository { return &AdminRepository{db: db} } + +func (r *AdminRepository) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.db.db.WithContext(ctx).Model(&adminModel{}).Count(&count).Error + return count, err +} + +func (r *AdminRepository) Create(ctx context.Context, value admin.Admin) (admin.Admin, error) { + row := adminModel{Username: value.Username, PasswordHash: value.PasswordHash} + if err := r.db.db.WithContext(ctx).Create(&row).Error; err != nil { + return admin.Admin{}, mapError(err) + } + return toAdminDomain(row), nil +} + +func (r *AdminRepository) GetByUsername(ctx context.Context, username string) (admin.Admin, error) { + var row adminModel + if err := r.db.db.WithContext(ctx).Where("username = ?", username).First(&row).Error; err != nil { + return admin.Admin{}, mapError(err) + } + return toAdminDomain(row), nil +} + +func (r *AdminRepository) GetByID(ctx context.Context, id uint64) (admin.Admin, error) { + var row adminModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return admin.Admin{}, mapError(err) + } + return toAdminDomain(row), nil +} + +func (r *AdminRepository) UpdatePasswordAndRevokeSessions(ctx context.Context, id uint64, passwordHash string) error { + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&adminModel{}).Where("id = ?", id).Updates(map[string]any{"password_hash": passwordHash, "updated_at": time.Now().UTC()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return tx.Where("admin_id = ?", id).Delete(&adminSessionModel{}).Error + }) +} + +type AdminSessionRepository struct{ db *Database } + +const maxAdminSessions = 100 + +func NewAdminSessionRepository(db *Database) *AdminSessionRepository { + return &AdminSessionRepository{db: db} +} + +func (r *AdminSessionRepository) Create(ctx context.Context, value admin.Session) (admin.Session, error) { + row := adminSessionModel{AdminID: value.AdminID, RefreshTokenHash: value.RefreshTokenHash, ExpiresAt: value.ExpiresAt, CreatedAt: time.Now().UTC()} + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now().UTC() + if err := tx.Where("expires_at <= ?", now).Delete(&adminSessionModel{}).Error; err != nil { + return err + } + var staleIDs []uint64 + if err := tx.Model(&adminSessionModel{}).Where("admin_id = ?", value.AdminID).Order("created_at DESC, id DESC").Offset(maxAdminSessions-1).Pluck("id", &staleIDs).Error; err != nil { + return err + } + if len(staleIDs) > 0 { + if err := tx.Where("id IN ?", staleIDs).Delete(&adminSessionModel{}).Error; err != nil { + return err + } + } + return tx.Create(&row).Error + }) + if err != nil { + return admin.Session{}, mapError(err) + } + return toSessionDomain(row), nil +} + +func (r *AdminSessionRepository) GetByTokenHash(ctx context.Context, tokenHash string) (admin.Session, error) { + var row adminSessionModel + if err := r.db.db.WithContext(ctx).Where("refresh_token_hash = ?", tokenHash).First(&row).Error; err != nil { + return admin.Session{}, mapError(err) + } + return toSessionDomain(row), nil +} + +func (r *AdminSessionRepository) GetByID(ctx context.Context, id uint64) (admin.Session, error) { + var row adminSessionModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return admin.Session{}, mapError(err) + } + return toSessionDomain(row), nil +} + +func (r *AdminSessionRepository) Rotate(ctx context.Context, id uint64, expectedTokenHash, newTokenHash string, expiresAt time.Time) error { + now := time.Now().UTC() + result := r.db.db.WithContext(ctx). + Model(&adminSessionModel{}). + Where("id = ? AND refresh_token_hash = ? AND expires_at > ?", id, expectedTokenHash, now). + Updates(map[string]any{ + "refresh_token_hash": newTokenHash, + "expires_at": expiresAt, + "last_used_at": &now, + }) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected != 1 { + return repository.ErrConflict + } + return nil +} + +func (r *AdminSessionRepository) Revoke(ctx context.Context, id uint64) error { + return r.db.db.WithContext(ctx).Delete(&adminSessionModel{}, id).Error +} + +func (r *AdminSessionRepository) RevokeAllByAdmin(ctx context.Context, adminID uint64) error { + return r.db.db.WithContext(ctx).Where("admin_id = ?", adminID).Delete(&adminSessionModel{}).Error +} diff --git a/backend/internal/infra/persistence/relational/audit_repository.go b/backend/internal/infra/persistence/relational/audit_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..53a15f373d982531d0af40c69709eab56756e62b --- /dev/null +++ b/backend/internal/infra/persistence/relational/audit_repository.go @@ -0,0 +1,723 @@ +package relational + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AuditRepository struct{ db *Database } + +func NewAuditRepository(db *Database) *AuditRepository { return &AuditRepository{db: db} } + +const ( + auditInsertBatchSize = 20 + auditLookupBatchSize = 500 + attemptInsertBatchSize = 40 +) + +var errAuditBatchRequiresFallback = errors.New("audit batch requires idempotent fallback") + +type preparedAudit struct { + row requestAuditModel + attempts []requestAuditAttemptModel +} + +func (r *AuditRepository) Create(ctx context.Context, value audit.Record) error { + prepared, err := prepareAudits([]audit.Record{value}) + if err != nil { + return err + } + row := prepared[0].row + attempts := prepared[0].attempts + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return createAuditAndBill(tx, &row, attempts) + }) +} + +func (r *AuditRepository) CreateBatch(ctx context.Context, values []audit.Record) error { + if len(values) == 0 { + return nil + } + prepared, err := prepareAudits(values) + if err != nil { + return err + } + err = r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return createPreparedAuditBatchFast(tx, prepared) + }) + if !errors.Is(err, errAuditBatchRequiresFallback) { + return err + } + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return createPreparedAuditBatchSafe(tx, prepared) + }) +} + +func prepareAudits(values []audit.Record) ([]preparedAudit, error) { + prepared := make([]preparedAudit, 0, len(values)) + for index, value := range values { + row, attempts, err := toAuditModels(value) + if err != nil { + return nil, &repository.InvalidBatchRecordError{Index: index, Err: err} + } + candidate := preparedAudit{row: row, attempts: attempts} + if err := validatePreparedAudit(candidate); err != nil { + return nil, &repository.InvalidBatchRecordError{Index: index, Err: err} + } + prepared = append(prepared, candidate) + } + return prepared, nil +} + +func validatePreparedAudit(value preparedAudit) error { + row := value.row + if length := utf8.RuneCountInString(row.EventID); length < 16 || length > 64 { + return errors.New("event_id length must be between 16 and 64") + } + if length := utf8.RuneCountInString(row.RequestID); length < 1 || length > 64 { + return errors.New("request_id length must be between 1 and 64") + } + if row.ClientKeyID == 0 { + return errors.New("client_key_id must be positive") + } + if row.ModelRouteID == 0 { + return errors.New("model_route_id must be positive") + } + if !auditStringAllowed(row.Provider, "grok_build", "grok_web", "grok_console") { + return errors.New("provider is invalid") + } + if !auditStringAllowed(row.Operation, "responses", "compaction", "chat", "messages", "image", "image_edit", "video") { + return errors.New("operation is invalid") + } + if !auditStringAllowed(row.UsageSource, "upstream", "estimated", "none") { + return errors.New("usage_source is invalid") + } + if row.AccountID != nil && *row.AccountID == 0 { + return errors.New("account_id must be positive when present") + } + if row.EgressNodeID != nil && *row.EgressNodeID == 0 { + return errors.New("egress_node_id must be positive when present") + } + if !auditStringAllowed(row.EgressScope, "", "grok_build", "grok_web", "grok_console", "grok_web_asset") { + return errors.New("egress_scope is invalid") + } + if !auditStringAllowed(row.EgressMode, "", "direct", "proxy") { + return errors.New("egress_mode is invalid") + } + if row.StatusCode < 100 || row.StatusCode > 599 { + return errors.New("status_code must be between 100 and 599") + } + attemptNumbers := make(map[int]struct{}, len(value.attempts)) + for index, attempt := range value.attempts { + if err := validatePreparedAuditAttempt(attempt); err != nil { + return fmt.Errorf("attempt %d: %w", index+1, err) + } + if _, exists := attemptNumbers[attempt.Number]; exists { + return fmt.Errorf("attempt %d: number %d is duplicated", index+1, attempt.Number) + } + attemptNumbers[attempt.Number] = struct{}{} + } + return nil +} + +func validatePreparedAuditAttempt(value requestAuditAttemptModel) error { + if value.Number <= 0 { + return errors.New("number must be positive") + } + if !auditStringAllowed(value.Source, "upstream_http", "gateway_transport", "credential") { + return errors.New("source is invalid") + } + if length := utf8.RuneCountInString(strings.TrimSpace(value.Stage)); length < 1 || length > 64 { + return errors.New("stage length must be between 1 and 64") + } + if value.AccountID != nil && *value.AccountID == 0 { + return errors.New("account_id must be positive when present") + } + if value.UpstreamStatusCode != nil && (*value.UpstreamStatusCode < 100 || *value.UpstreamStatusCode > 599) { + return errors.New("upstream_status_code must be between 100 and 599 when present") + } + if utf8.RuneCountInString(value.ResponseHeadersJSON) > 32768 { + return errors.New("response headers exceed the storage limit") + } + if len(value.ResponseBody) > 65536 { + return errors.New("response body exceeds the storage limit") + } + if utf8.RuneCountInString(value.ErrorChainJSON) > 32768 { + return errors.New("error chain exceeds the storage limit") + } + return nil +} + +func auditStringAllowed(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func createPreparedAuditBatchFast(tx *gorm.DB, prepared []preparedAudit) error { + rows := make([]requestAuditModel, len(prepared)) + for index := range prepared { + rows[index] = prepared[index].row + } + result := tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(&rows, auditInsertBatchSize) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + // A mixed new/duplicate result cannot be mapped safely across database RETURNING implementations. + if result.RowsAffected != int64(len(rows)) { + return errAuditBatchRequiresFallback + } + idsByEvent, err := loadInsertedAuditIDs(tx, prepared) + if err != nil { + return err + } + inserted := make([]preparedAudit, len(prepared)) + for index := range prepared { + auditID := idsByEvent[prepared[index].row.EventID] + if auditID == 0 { + return errAuditBatchRequiresFallback + } + inserted[index] = prepared[index] + inserted[index].row.ID = auditID + } + if err := insertPreparedAuditAttempts(tx, inserted); err != nil { + return err + } + return settleInsertedAudits(tx, inserted) +} + +func loadInsertedAuditIDs(tx *gorm.DB, prepared []preparedAudit) (map[string]uint64, error) { + eventIDs := make([]string, len(prepared)) + for index := range prepared { + eventIDs[index] = prepared[index].row.EventID + } + idsByEvent := make(map[string]uint64, len(eventIDs)) + for start := 0; start < len(eventIDs); start += auditLookupBatchSize { + end := min(start+auditLookupBatchSize, len(eventIDs)) + var rows []struct { + ID uint64 + EventID string + } + if err := tx.Model(&requestAuditModel{}).Select("id", "event_id").Where("event_id IN ?", eventIDs[start:end]).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + idsByEvent[row.EventID] = row.ID + } + } + return idsByEvent, nil +} + +func createPreparedAuditBatchSafe(tx *gorm.DB, prepared []preparedAudit) error { + inserted := make([]preparedAudit, 0, len(prepared)) + for index := range prepared { + row := prepared[index].row + row.ID = 0 + wasInserted, err := insertAudit(tx, &row) + if err != nil { + return err + } + if !wasInserted { + continue + } + attempts := append([]requestAuditAttemptModel(nil), prepared[index].attempts...) + if err := insertAuditAttempts(tx, row.ID, attempts); err != nil { + return err + } + inserted = append(inserted, preparedAudit{row: row}) + } + return settleInsertedAudits(tx, inserted) +} + +func insertPreparedAuditAttempts(tx *gorm.DB, inserted []preparedAudit) error { + attemptCount := 0 + for _, value := range inserted { + attemptCount += len(value.attempts) + } + if attemptCount == 0 { + return nil + } + attempts := make([]requestAuditAttemptModel, 0, attemptCount) + for _, value := range inserted { + for _, attempt := range value.attempts { + attempt.AuditID = value.row.ID + attempts = append(attempts, attempt) + } + } + return tx.CreateInBatches(&attempts, attemptInsertBatchSize).Error +} + +func settleInsertedAudits(tx *gorm.DB, inserted []preparedAudit) error { + if len(inserted) == 0 { + return nil + } + keySet := make(map[uint64]struct{}, len(inserted)) + for _, value := range inserted { + keySet[value.row.ClientKeyID] = struct{}{} + } + keyIDs := make([]uint64, 0, len(keySet)) + for keyID := range keySet { + keyIDs = append(keyIDs, keyID) + } + sort.Slice(keyIDs, func(i, j int) bool { return keyIDs[i] < keyIDs[j] }) + // Lock every referenced key before reading reservations so zero-cost settlements cannot race reservation creation. + missingKeys := make(map[uint64]struct{}) + for _, keyID := range keyIDs { + if err := lockClientKey(tx, keyID); err != nil { + if !errors.Is(err, repository.ErrNotFound) { + return err + } + missingKeys[keyID] = struct{}{} + } + } + + eventIDs := make([]string, 0, len(inserted)) + for _, value := range inserted { + eventIDs = append(eventIDs, value.row.EventID) + } + var reservations []billingReservationModel + if err := tx.Where("event_id IN ?", eventIDs).Find(&reservations).Error; err != nil { + return err + } + reservationByEvent := make(map[string]billingReservationModel, len(reservations)) + for _, reservation := range reservations { + if _, missing := missingKeys[reservation.ClientKeyID]; missing { + return repository.ErrNotFound + } + reservationByEvent[reservation.EventID] = reservation + } + + sort.Slice(inserted, func(i, j int) bool { + if inserted[i].row.ClientKeyID == inserted[j].row.ClientKeyID { + return inserted[i].row.EventID < inserted[j].row.EventID + } + return inserted[i].row.ClientKeyID < inserted[j].row.ClientKeyID + }) + settledEventIDs := make([]string, 0, len(reservations)) + for _, value := range inserted { + reservation, hasReservation := reservationByEvent[value.row.EventID] + settled, err := applyInsertedAuditBilling(tx, value.row, reservation, hasReservation) + if err != nil { + return err + } + if settled { + settledEventIDs = append(settledEventIDs, value.row.EventID) + } + } + return deleteBillingReservations(tx, settledEventIDs) +} + +func toAuditModels(value audit.Record) (requestAuditModel, []requestAuditAttemptModel, error) { + provider := value.Provider + if provider == "" { + provider = "grok_build" + } + operation := value.Operation + if operation == "" { + operation = audit.OperationResponses + } + usageSource := value.UsageSource + if usageSource == "" { + usageSource = audit.UsageSourceUpstream + } + eventID := strings.TrimSpace(value.EventID) + if eventID == "" { + digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d\x00%d\x00%d", value.RequestID, value.ClientKeyID, value.ModelRouteID, value.CreatedAt.UnixNano()))) + eventID = fmt.Sprintf("evt_%x", digest[:18]) + } + row := requestAuditModel{ + EventID: truncate(eventID, 64), RequestID: truncate(value.RequestID, 64), ClientKeyID: value.ClientKeyID, ClientKeyName: truncate(value.ClientKeyName, 160), + ModelRouteID: value.ModelRouteID, ModelPublicID: truncate(value.ModelPublicID, 255), ModelUpstreamModel: truncate(value.ModelUpstreamModel, 255), + Provider: truncate(provider, 32), Operation: string(operation), UsageSource: string(usageSource), + AccountID: value.AccountID, AccountName: truncate(value.AccountName, 160), + EgressNodeID: value.EgressNodeID, EgressNodeName: truncate(value.EgressNodeName, 160), EgressScope: truncate(value.EgressScope, 32), EgressMode: string(value.EgressMode), + StatusCode: value.StatusCode, Streaming: value.Streaming, + MediaInputImages: nonNegative(value.MediaInputImages), MediaOutputImages: nonNegative(value.MediaOutputImages), MediaOutputSeconds: nonNegative(value.MediaOutputSeconds), + InputTokens: nonNegative(value.InputTokens), CachedInputTokens: nonNegative(value.CachedInputTokens), OutputTokens: nonNegative(value.OutputTokens), + ReasoningTokens: nonNegative(value.ReasoningTokens), TotalTokens: nonNegative(value.TotalTokens), CostInUSDTicks: nonNegative(value.CostInUSDTicks), + EstimatedCostInUSDTicks: nonNegative(value.EstimatedCostInUSDTicks), PricingModel: truncate(value.PricingModel, 100), PricingVersion: truncate(value.PricingVersion, 20), + NumSourcesUsed: nonNegative(value.NumSourcesUsed), NumServerSideToolsUsed: nonNegative(value.NumServerSideToolsUsed), + ContextInputTokens: nonNegative(value.ContextInputTokens), ContextOutputTokens: nonNegative(value.ContextOutputTokens), FirstTokenMS: normalizedFirstToken(value), DurationMS: nonNegative(value.DurationMS), + ErrorCode: truncate(value.ErrorCode, 100), AttemptCount: len(value.Attempts), CreatedAt: value.CreatedAt, + } + attempts := make([]requestAuditAttemptModel, 0, len(value.Attempts)) + for _, attempt := range value.Attempts { + responseHeaders, err := json.Marshal(attempt.ResponseHeaders) + if err != nil { + return requestAuditModel{}, nil, fmt.Errorf("序列化审计响应头: %w", err) + } + if attempt.ResponseHeaders == nil { + responseHeaders = []byte("{}") + } + errorChain, err := json.Marshal(attempt.ErrorChain) + if err != nil { + return requestAuditModel{}, nil, fmt.Errorf("序列化审计错误链: %w", err) + } + if attempt.ErrorChain == nil { + errorChain = []byte("[]") + } + attempts = append(attempts, requestAuditAttemptModel{ + Number: attempt.Number, + Source: string(attempt.Source), + Stage: attempt.Stage, + AccountID: attempt.AccountID, + AccountName: truncate(attempt.AccountName, 160), + Method: truncate(attempt.Method, 16), + RequestPath: truncate(attempt.RequestPath, 2048), + UpstreamURL: truncate(attempt.UpstreamURL, 4096), + StartedAt: attempt.StartedAt, + DurationMS: nonNegative(attempt.DurationMS), + UpstreamStatusCode: attempt.UpstreamStatusCode, + UpstreamStatus: truncate(attempt.UpstreamStatus, 128), + ResponseHeadersJSON: string(responseHeaders), + ResponseBody: truncateBytes(attempt.ResponseBody, 65536), + ResponseBodyTruncated: attempt.ResponseBodyTruncated || len(attempt.ResponseBody) > 65536, + TransportError: truncate(attempt.TransportError, 2048), + ErrorChainJSON: string(errorChain), + }) + } + return row, attempts, nil +} + +func truncateBytes(value []byte, limit int) []byte { + if len(value) <= limit { + return value + } + return value[:limit] +} + +func createAuditAndBill(tx *gorm.DB, row *requestAuditModel, attempts []requestAuditAttemptModel) error { + inserted, err := insertAudit(tx, row) + if err != nil || !inserted { + return err + } + if err := insertAuditAttempts(tx, row.ID, attempts); err != nil { + return err + } + var reservation billingReservationModel + reservationErr := tx.Where("event_id = ?", row.EventID).First(&reservation).Error + if reservationErr != nil && !errors.Is(reservationErr, gorm.ErrRecordNotFound) { + return reservationErr + } + if reservationErr == nil || auditBillingAmount(*row) > 0 { + if err := lockClientKey(tx, row.ClientKeyID); err != nil { + if reservationErr == nil || !errors.Is(err, repository.ErrNotFound) { + return err + } + return billInsertedAudit(tx, *row, billingReservationModel{}, false) + } + reservation = billingReservationModel{} + reservationErr = tx.Where("event_id = ?", row.EventID).First(&reservation).Error + if reservationErr != nil && !errors.Is(reservationErr, gorm.ErrRecordNotFound) { + return reservationErr + } + } + return billInsertedAudit(tx, *row, reservation, reservationErr == nil) +} + +func insertAudit(tx *gorm.DB, row *requestAuditModel) (bool, error) { + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(row) + return result.RowsAffected == 1, result.Error +} + +func insertAuditAttempts(tx *gorm.DB, auditID uint64, attempts []requestAuditAttemptModel) error { + if len(attempts) == 0 { + return nil + } + for index := range attempts { + attempts[index].AuditID = auditID + } + return tx.Create(&attempts).Error +} + +func billInsertedAudit(tx *gorm.DB, row requestAuditModel, reservation billingReservationModel, hasReservation bool) error { + settled, err := applyInsertedAuditBilling(tx, row, reservation, hasReservation) + if err != nil { + return err + } + if !settled { + return nil + } + return deleteBillingReservations(tx, []string{row.EventID}) +} + +func applyInsertedAuditBilling(tx *gorm.DB, row requestAuditModel, reservation billingReservationModel, hasReservation bool) (bool, error) { + amount := auditBillingAmount(row) + if hasReservation { + if err := settleReservedBilling(tx, reservation, amount); err != nil { + return false, err + } + return true, nil + } + if amount <= 0 { + return false, nil + } + result := tx.Model(&clientKeyModel{}).Where("id = ?", row.ClientKeyID).UpdateColumn( + "billed_usage_usd_ticks", + gorm.Expr("CASE WHEN billed_usage_usd_ticks > ? THEN ? ELSE billed_usage_usd_ticks + ? END", math.MaxInt64-amount, int64(math.MaxInt64), amount), + ) + if result.Error != nil { + return false, result.Error + } + return false, nil +} + +func deleteBillingReservations(tx *gorm.DB, eventIDs []string) error { + if len(eventIDs) == 0 { + return nil + } + result := tx.Where("event_id IN ?", eventIDs).Delete(&billingReservationModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != int64(len(eventIDs)) { + return repository.ErrConflict + } + return nil +} + +func auditBillingAmount(row requestAuditModel) int64 { + if row.CostInUSDTicks > 0 { + return row.CostInUSDTicks + } + return row.EstimatedCostInUSDTicks +} + +func settleReservedBilling(tx *gorm.DB, reservation billingReservationModel, amount int64) error { + if amount < 0 { + amount = 0 + } + result := tx.Model(&clientKeyModel{}).Where("id = ?", reservation.ClientKeyID).Updates(map[string]any{ + "reserved_usage_usd_ticks": gorm.Expr("CASE WHEN reserved_usage_usd_ticks <= ? THEN 0 ELSE reserved_usage_usd_ticks - ? END", reservation.Amount, reservation.Amount), + "billed_usage_usd_ticks": gorm.Expr("CASE WHEN billed_usage_usd_ticks > ? THEN ? ELSE billed_usage_usd_ticks + ? END", math.MaxInt64-amount, int64(math.MaxInt64), amount), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func nonNegative(value int64) int64 { + if value < 0 { + return 0 + } + return value +} + +func normalizedFirstToken(value audit.Record) *int64 { + if value.FirstTokenMS == nil || !value.Streaming || value.StatusCode < 200 || value.StatusCode >= 300 || value.ErrorCode != "" { + return nil + } + normalized := nonNegative(*value.FirstTokenMS) + if normalized > nonNegative(value.DurationMS) { + return nil + } + return &normalized +} + +func (r *AuditRepository) SumTokensByAccountsSince(ctx context.Context, accountIDs []uint64, since time.Time) (map[uint64]int64, error) { + result := make(map[uint64]int64, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var rows []struct { + AccountID uint64 + TotalTokens int64 + } + err := r.db.db.WithContext(ctx). + Model(&requestAuditModel{}). + Select("account_id, COALESCE(SUM(total_tokens), 0) AS total_tokens"). + Where("account_id IN ? AND created_at >= ? AND total_tokens > 0", accountIDs, since). + Group("account_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + for _, row := range rows { + result[row.AccountID] = row.TotalTokens + } + return result, nil +} + +func (r *AuditRepository) List(ctx context.Context, offset, limit int) ([]audit.Record, int64, error) { + var total int64 + query := r.db.db.WithContext(ctx).Model(&requestAuditModel{}) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []requestAuditModel + if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + out := make([]audit.Record, 0, len(rows)) + for _, row := range rows { + out = append(out, toAuditDomain(row)) + } + return out, total, nil +} + +func (r *AuditRepository) Get(ctx context.Context, id uint64) (audit.Record, error) { + var row requestAuditModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return audit.Record{}, mapError(err) + } + var attemptRows []requestAuditAttemptModel + if err := r.db.db.WithContext(ctx).Where("audit_id = ?", id).Order("number ASC").Find(&attemptRows).Error; err != nil { + return audit.Record{}, err + } + value := toAuditDomain(row) + value.Attempts = make([]audit.Attempt, 0, len(attemptRows)) + for _, attemptRow := range attemptRows { + attempt, err := toAuditAttemptDomain(attemptRow) + if err != nil { + return audit.Record{}, err + } + value.Attempts = append(value.Attempts, attempt) + } + return value, nil +} + +// ListCursor 使用“排序值 + ID”复合游标读取审计,避免深分页和同值记录漏读。 +func (r *AuditRepository) ListCursor(ctx context.Context, input repository.AuditCursorQuery) ([]audit.Record, bool, error) { + query := r.db.db.WithContext(ctx).Model(&requestAuditModel{}) + query = applyAuditQuery(query, input.Search, input.Start, input.End, input.Filter) + fields := map[string]sortSpec{ + "request": {expression: "request_audits.request_id"}, + "model": {expression: "LOWER(request_audits.model_public_id)"}, + "billing": {expression: "CASE WHEN request_audits.cost_in_usd_ticks > 0 THEN request_audits.cost_in_usd_ticks ELSE request_audits.estimated_cost_in_usd_ticks END", defaultDirection: repository.SortDescending}, + "tokens": {expression: "request_audits.total_tokens", defaultDirection: repository.SortDescending}, + "status": {expression: "request_audits.status_code"}, + "mode": {expression: "CASE WHEN request_audits.streaming = TRUE THEN 1 ELSE 0 END"}, + "duration": {expression: "request_audits.duration_ms", defaultDirection: repository.SortDescending}, + "createdAt": {expression: "request_audits.created_at", defaultDirection: repository.SortDescending}, + } + fallback := sortSpec{expression: "request_audits.created_at", defaultDirection: repository.SortDescending} + spec, direction := stableSortSpec(input.Sort, fields, fallback) + if input.Cursor != nil { + comparison := ">" + if direction == "DESC" { + comparison = "<" + } + query = query.Where("("+spec.expression+" "+comparison+" ? OR ("+spec.expression+" = ? AND request_audits.id "+comparison+" ?))", input.Cursor.Value, input.Cursor.Value, input.Cursor.ID) + } + var rows []requestAuditModel + query = applyStableSort(query, input.Sort, fields, fallback, "request_audits.id") + if err := query.Limit(input.Limit + 1).Find(&rows).Error; err != nil { + return nil, false, err + } + hasMore := len(rows) > input.Limit + if hasMore { + rows = rows[:input.Limit] + } + out := make([]audit.Record, 0, len(rows)) + for _, row := range rows { + out = append(out, toAuditDomain(row)) + } + return out, hasMore, nil +} + +func (r *AuditRepository) Summarize(ctx context.Context, input repository.AuditSummaryQuery) (audit.Summary, error) { + var aggregate struct { + Requests int64 + SuccessfulRequests int64 + FailedRequests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + TotalTokens int64 + DurationMS int64 + EstimatedCostInUSDTicks int64 + PricedRequests int64 + UnpricedRequests int64 + PricedTokens int64 + UnpricedTokens int64 + } + query := applyAuditQuery(r.db.db.WithContext(ctx).Model(&requestAuditModel{}), input.Search, input.Start, input.End, input.Filter) + if err := query.Select(` + COUNT(*) AS requests, + COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) AS successful_requests, + COALESCE(SUM(CASE WHEN status_code < 200 OR status_code >= 300 THEN 1 ELSE 0 END), 0) AS failed_requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(SUM(duration_ms), 0) AS duration_ms, + COALESCE(SUM(estimated_cost_in_usd_ticks), 0) AS estimated_cost_in_usd_ticks, + COALESCE(SUM(CASE WHEN COALESCE(pricing_model, '') <> '' THEN 1 ELSE 0 END), 0) AS priced_requests, + COALESCE(SUM(CASE WHEN COALESCE(pricing_model, '') = '' THEN 1 ELSE 0 END), 0) AS unpriced_requests, + COALESCE(SUM(CASE WHEN COALESCE(pricing_model, '') <> '' THEN total_tokens ELSE 0 END), 0) AS priced_tokens, + COALESCE(SUM(CASE WHEN COALESCE(pricing_model, '') = '' THEN total_tokens ELSE 0 END), 0) AS unpriced_tokens`).Scan(&aggregate).Error; err != nil { + return audit.Summary{}, err + } + result := audit.Summary{ + Requests: aggregate.Requests, SuccessfulRequests: aggregate.SuccessfulRequests, FailedRequests: aggregate.FailedRequests, + InputTokens: aggregate.InputTokens, CachedInputTokens: aggregate.CachedInputTokens, OutputTokens: aggregate.OutputTokens, + ReasoningTokens: aggregate.ReasoningTokens, TotalTokens: aggregate.TotalTokens, DurationMS: aggregate.DurationMS, + EstimatedCostInUSDTicks: aggregate.EstimatedCostInUSDTicks, PricedRequests: aggregate.PricedRequests, + UnpricedRequests: aggregate.UnpricedRequests, PricedTokens: aggregate.PricedTokens, UnpricedTokens: aggregate.UnpricedTokens, + } + return result, nil +} + +func applyAuditQuery(query *gorm.DB, search string, start, end time.Time, filter repository.AuditListFilter) *gorm.DB { + if value := strings.TrimSpace(search); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Where("LOWER(request_id) LIKE ? OR LOWER(model_public_id) LIKE ? OR LOWER(model_upstream_model) LIKE ? OR LOWER(egress_node_name) LIKE ?", pattern, pattern, pattern, pattern) + } + if !start.IsZero() { + query = query.Where("created_at >= ?", start) + } + if !end.IsZero() { + query = query.Where("created_at < ?", end) + } + if value := strings.TrimSpace(filter.Model); value != "" { + query = query.Where("model_public_id = ? OR model_upstream_model = ?", value, value) + } + if value := strings.TrimSpace(filter.Key); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Where("LOWER(client_key_name) LIKE ? OR CAST(client_key_id AS TEXT) LIKE ?", pattern, pattern) + } + if value := strings.TrimSpace(filter.Account); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Where("LOWER(account_name) LIKE ? OR CAST(account_id AS TEXT) LIKE ?", pattern, pattern) + } + switch filter.Status { + case "success", "2xx": + query = query.Where("status_code >= 200 AND status_code < 300") + case "clientError", "4xx": + query = query.Where("status_code >= 400 AND status_code < 500") + case "serverError", "5xx": + query = query.Where("status_code >= 500 AND status_code < 600") + } + switch filter.Mode { + case "stream": + query = query.Where("streaming = ?", true) + case "nonStream": + query = query.Where("streaming = ?", false) + } + return query +} diff --git a/backend/internal/infra/persistence/relational/audit_repository_test.go b/backend/internal/infra/persistence/relational/audit_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..67c29cbf1eb723fc6752cc4b7c95c449db87a43c --- /dev/null +++ b/backend/internal/infra/persistence/relational/audit_repository_test.go @@ -0,0 +1,492 @@ +package relational + +import ( + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/audit" + repositorypkg "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestAuditRepositorySumTokensByAccountsSince(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + values := []audit.Record{ + {RequestID: "recent-1", ClientKeyID: 1, ModelRouteID: 1, AccountID: uint64Pointer(1), TotalTokens: 120, StatusCode: 200, CreatedAt: now.Add(-time.Hour)}, + {RequestID: "recent-2", ClientKeyID: 1, ModelRouteID: 1, AccountID: uint64Pointer(1), TotalTokens: 80, StatusCode: 200, CreatedAt: now.Add(-2 * time.Hour)}, + {RequestID: "old", ClientKeyID: 1, ModelRouteID: 1, AccountID: uint64Pointer(1), TotalTokens: 500, StatusCode: 200, CreatedAt: now.Add(-25 * time.Hour)}, + {RequestID: "other", ClientKeyID: 1, ModelRouteID: 1, AccountID: uint64Pointer(2), TotalTokens: 40, StatusCode: 200, CreatedAt: now.Add(-time.Hour)}, + } + for _, value := range values { + if err := repository.Create(ctx, value); err != nil { + t.Fatal(err) + } + } + totals, err := repository.SumTokensByAccountsSince(ctx, []uint64{1, 2}, now.Add(-24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if totals[1] != 200 || totals[2] != 40 { + t.Fatalf("totals = %#v", totals) + } +} + +func TestAuditRepositoryBatchAndCursor(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-cursor.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + values := []audit.Record{ + {RequestID: "cursor-old", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: now.Add(-48 * time.Hour)}, + {RequestID: "cursor-1", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: now.Add(-3 * time.Minute)}, + {RequestID: "cursor-2", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: now.Add(-2 * time.Minute)}, + {RequestID: "cursor-3", ClientKeyID: 1, ClientKeyName: "production", ModelRouteID: 1, ModelPublicID: "grok-test", ModelUpstreamModel: "grok-test-upstream", AccountName: "primary", EgressNodeID: uint64Pointer(42), EgressNodeName: "proxy-shanghai", EgressScope: "grok_web", EgressMode: audit.EgressModeProxy, StatusCode: 200, CreatedAt: now.Add(-time.Minute)}, + } + if err := repository.CreateBatch(ctx, values); err != nil { + t.Fatal(err) + } + sort := repositorypkg.SortQuery{Field: "createdAt", Direction: repositorypkg.SortDescending} + first, hasMore, err := repository.ListCursor(ctx, repositorypkg.AuditCursorQuery{Limit: 2, Sort: sort}) + if err != nil { + t.Fatal(err) + } + if len(first) != 2 || !hasMore || first[0].ID <= first[1].ID { + t.Fatalf("first page = %#v, hasMore = %v", first, hasMore) + } + if first[0].ClientKeyName != "production" || first[0].ModelPublicID != "grok-test" || first[0].ModelUpstreamModel != "grok-test-upstream" || first[0].AccountName != "primary" || first[0].EgressNodeID == nil || *first[0].EgressNodeID != 42 || first[0].EgressNodeName != "proxy-shanghai" || first[0].EgressMode != audit.EgressModeProxy { + t.Fatalf("audit snapshots = %#v", first[0]) + } + matched, _, err := repository.ListCursor(ctx, repositorypkg.AuditCursorQuery{Limit: 10, Search: "proxy-shanghai", Sort: sort}) + if err != nil || len(matched) != 1 || matched[0].RequestID != "cursor-3" { + t.Fatalf("egress search = %#v, err = %v", matched, err) + } + second, _, err := repository.ListCursor(ctx, repositorypkg.AuditCursorQuery{Cursor: &repositorypkg.SortCursor{ID: first[len(first)-1].ID, Value: first[len(first)-1].CreatedAt}, Limit: 2, Sort: sort}) + if err != nil { + t.Fatal(err) + } + if len(second) != 2 || second[0].ID >= first[len(first)-1].ID { + t.Fatalf("second page = %#v", second) + } +} + +func TestAuditRepositoryBatchSpansMultipleSQLChunks(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-multi-chunk.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + values := make([]audit.Record, 45) + for index := range values { + values[index] = audit.Record{ + EventID: fmt.Sprintf("evt_multi_chunk_audit_%04d", index), RequestID: fmt.Sprintf("multi-chunk-%04d", index), + ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: now.Add(time.Duration(index) * time.Millisecond), + Attempts: []audit.Attempt{{Number: 1, Source: audit.AttemptSourceCredential, Stage: "credential", StartedAt: now}}, + } + } + if err := repository.CreateBatch(ctx, values); err != nil { + t.Fatal(err) + } + if count := tableRowCount(t, database, "request_audits"); count != int64(len(values)) { + t.Fatalf("request audits = %d", count) + } + if count := tableRowCount(t, database, "request_audit_attempts"); count != int64(len(values)) { + t.Fatalf("request audit attempts = %d", count) + } +} + +func TestAuditRepositoryAtomicallyRecordsClientBillingUsage(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-billing.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "billing", Prefix: "billing", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + if err := repository.CreateBatch(ctx, []audit.Record{ + {RequestID: "reported", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 200, CostInUSDTicks: 20, EstimatedCostInUSDTicks: 90, CreatedAt: now}, + {RequestID: "estimated", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 200, EstimatedCostInUSDTicks: 30, CreatedAt: now}, + {RequestID: "unpriced", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 500, CreatedAt: now}, + }); err != nil { + t.Fatal(err) + } + var stored clientKeyModel + if err := database.db.WithContext(ctx).First(&stored, key.ID).Error; err != nil { + t.Fatal(err) + } + if stored.BilledUsageUSDTicks != 50 { + t.Fatalf("billed usage = %d", stored.BilledUsageUSDTicks) + } + if err := repository.Create(ctx, audit.Record{EventID: "evt_idempotent_billing_0001", RequestID: "idempotent", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 200, EstimatedCostInUSDTicks: 40, CreatedAt: now}); err != nil { + t.Fatal(err) + } + if err := repository.Create(ctx, audit.Record{EventID: "evt_idempotent_billing_0001", RequestID: "idempotent-retry", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 200, EstimatedCostInUSDTicks: 40, CreatedAt: now}); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).First(&stored, key.ID).Error; err != nil { + t.Fatal(err) + } + if stored.BilledUsageUSDTicks != 90 { + t.Fatalf("idempotent billed usage = %d", stored.BilledUsageUSDTicks) + } +} + +func TestAuditRepositorySettlesBillingReservationIdempotently(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-reservation.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "reserved", Prefix: "reserved", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, BillingLimitUSDTicks: 100} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + eventID := "evt_billing_reservation_0001" + keys := NewClientKeyRepository(database) + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, eventID, 80, time.Now().UTC().Add(time.Hour)); err != nil || !reserved { + t.Fatal(err) + } + audits := NewAuditRepository(database) + record := audit.Record{EventID: eventID, RequestID: "reserved-request", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 200, EstimatedCostInUSDTicks: 30, CreatedAt: time.Now().UTC()} + if err := audits.Create(ctx, record); err != nil { + t.Fatal(err) + } + if err := audits.Create(ctx, record); err != nil { + t.Fatal(err) + } + var stored clientKeyModel + if err := database.db.WithContext(ctx).First(&stored, key.ID).Error; err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 0 || stored.BilledUsageUSDTicks != 30 { + t.Fatalf("settled key = reserved %d, billed %d", stored.ReservedUsageUSDTicks, stored.BilledUsageUSDTicks) + } + if count := tableRowCount(t, database, "billing_reservations"); count != 0 { + t.Fatalf("billing reservations = %d", count) + } +} + +func TestAuditRepositoryBatchMixedDuplicateUsesIdempotentFallback(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-mixed-batch.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "mixed", Prefix: "mixed", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + existing := audit.Record{ + EventID: "evt_mixed_batch_existing_0001", RequestID: "mixed-existing", ClientKeyID: key.ID, ModelRouteID: 1, + StatusCode: 200, EstimatedCostInUSDTicks: 20, CreatedAt: now, + Attempts: []audit.Attempt{{Number: 1, Source: audit.AttemptSourceCredential, Stage: "credential", StartedAt: now}}, + } + if err := repository.Create(ctx, existing); err != nil { + t.Fatal(err) + } + if err := repository.CreateBatch(ctx, []audit.Record{ + { + EventID: existing.EventID, RequestID: "duplicate-must-not-rebill", ClientKeyID: key.ID, ModelRouteID: 1, + StatusCode: 200, EstimatedCostInUSDTicks: 999, CreatedAt: now.Add(time.Second), + Attempts: []audit.Attempt{{Number: 1, Source: audit.AttemptSourceCredential, Stage: "duplicate", StartedAt: now}}, + }, + { + EventID: "evt_mixed_batch_new_record_0002", RequestID: "mixed-new", ClientKeyID: key.ID, ModelRouteID: 1, + StatusCode: 200, EstimatedCostInUSDTicks: 30, CreatedAt: now.Add(2 * time.Second), + Attempts: []audit.Attempt{ + {Number: 1, Source: audit.AttemptSourceCredential, Stage: "credential", StartedAt: now}, + {Number: 2, Source: audit.AttemptSourceUpstreamHTTP, Stage: "upstream_response", StartedAt: now}, + }, + }, + }); err != nil { + t.Fatal(err) + } + var stored clientKeyModel + if err := database.db.WithContext(ctx).First(&stored, key.ID).Error; err != nil { + t.Fatal(err) + } + if stored.BilledUsageUSDTicks != 50 { + t.Fatalf("billed usage = %d", stored.BilledUsageUSDTicks) + } + if count := tableRowCount(t, database, "request_audits"); count != 2 { + t.Fatalf("request audits = %d", count) + } + if count := tableRowCount(t, database, "request_audit_attempts"); count != 3 { + t.Fatalf("request audit attempts = %d", count) + } +} + +func TestAuditRepositoryBatchSettlesReservationsAndReleasesZeroCost(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-batch-reservations.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "batch-reserved", Prefix: "batch-reserved", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, BillingLimitUSDTicks: 1_000} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + events := []string{"evt_batch_reserved_priced_0001", "evt_batch_reserved_zero_000002"} + keys := NewClientKeyRepository(database) + for index, amount := range []int64{80, 70} { + reserved, err := keys.ReserveBillingUsage(ctx, key.ID, events[index], amount, time.Now().UTC().Add(time.Hour)) + if err != nil || !reserved { + t.Fatalf("reserve %s: reserved=%v err=%v", events[index], reserved, err) + } + } + audits := NewAuditRepository(database) + if err := audits.CreateBatch(ctx, []audit.Record{ + {EventID: events[0], RequestID: "batch-priced", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 200, CostInUSDTicks: 30, CreatedAt: time.Now().UTC()}, + {EventID: events[1], RequestID: "batch-zero", ClientKeyID: key.ID, ModelRouteID: 1, StatusCode: 500, CreatedAt: time.Now().UTC()}, + }); err != nil { + t.Fatal(err) + } + var stored clientKeyModel + if err := database.db.WithContext(ctx).First(&stored, key.ID).Error; err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 0 || stored.BilledUsageUSDTicks != 30 { + t.Fatalf("settled key = reserved %d, billed %d", stored.ReservedUsageUSDTicks, stored.BilledUsageUSDTicks) + } + if count := tableRowCount(t, database, "billing_reservations"); count != 0 { + t.Fatalf("billing reservations = %d", count) + } +} + +func TestAuditRepositoryBatchRollsBackAuditBillingAndReservationTogether(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-batch-rollback.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "batch-rollback", Prefix: "batch-rollback", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, BillingLimitUSDTicks: 1_000} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + eventID := "evt_batch_rollback_record_0001" + keys := NewClientKeyRepository(database) + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, eventID, 80, time.Now().UTC().Add(time.Hour)); err != nil || !reserved { + t.Fatalf("reserve: reserved=%v err=%v", reserved, err) + } + audits := NewAuditRepository(database) + err = audits.CreateBatch(ctx, []audit.Record{{ + EventID: eventID, RequestID: "batch-rollback", ClientKeyID: key.ID, ModelRouteID: 1, + StatusCode: 200, CostInUSDTicks: 30, CreatedAt: time.Now().UTC(), + Attempts: []audit.Attempt{{Number: 0, Source: audit.AttemptSourceCredential, Stage: "invalid", StartedAt: time.Now().UTC()}}, + }}) + if !errors.Is(err, repositorypkg.ErrInvalidRecord) { + t.Fatalf("batch error = %v", err) + } + var invalid *repositorypkg.InvalidBatchRecordError + if !errors.As(err, &invalid) || invalid.Index != 0 { + t.Fatalf("invalid record error = %#v", invalid) + } + if count := tableRowCount(t, database, "request_audits"); count != 0 { + t.Fatalf("request audits = %d", count) + } + if count := tableRowCount(t, database, "request_audit_attempts"); count != 0 { + t.Fatalf("request audit attempts = %d", count) + } + if count := tableRowCount(t, database, "billing_reservations"); count != 1 { + t.Fatalf("billing reservations = %d", count) + } + var stored clientKeyModel + if err := database.db.WithContext(ctx).First(&stored, key.ID).Error; err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 80 || stored.BilledUsageUSDTicks != 0 { + t.Fatalf("rolled back key = reserved %d, billed %d", stored.ReservedUsageUSDTicks, stored.BilledUsageUSDTicks) + } +} + +func TestAuditRepositoryAllowsRepeatedExternalRequestIDs(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-request-id.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + if err := repository.CreateBatch(ctx, []audit.Record{ + {RequestID: "caller-reused-id", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: now}, + {RequestID: "caller-reused-id", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, CreatedAt: now.Add(time.Second)}, + }); err != nil { + t.Fatal(err) + } + _, total, err := repository.List(ctx, 0, 10) + if err != nil || total != 2 { + t.Fatalf("total = %d, err = %v", total, err) + } +} + +func TestAuditRepositoryRoundTripsFailureAttempts(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-attempts.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Now().UTC() + status := http.StatusBadGateway + record := audit.Record{ + EventID: "evt_failure_attempts_0001", RequestID: "failure-attempts", ClientKeyID: 1, ModelRouteID: 1, + StatusCode: http.StatusBadGateway, CreatedAt: now, + Attempts: []audit.Attempt{ + { + Number: 1, Source: audit.AttemptSourceTransport, Stage: "dns_lookup", AccountID: uint64Pointer(7), AccountName: "primary", + Method: http.MethodPost, RequestPath: "/responses", UpstreamURL: "https://api.example.test/v1/responses", StartedAt: now.Add(-2 * time.Second), DurationMS: 125, + TransportError: "lookup api.example.test: no such host", ErrorChain: []audit.ErrorFrame{{Type: "*url.Error", Message: "Post request failed"}, {Type: "*net.DNSError", Message: "no such host"}}, + }, + { + Number: 2, Source: audit.AttemptSourceUpstreamHTTP, Stage: "upstream_response", AccountID: uint64Pointer(8), AccountName: "secondary", + Method: http.MethodPost, RequestPath: "/responses", UpstreamURL: "https://api.example.test/v1/responses", StartedAt: now.Add(-time.Second), DurationMS: 250, + UpstreamStatusCode: &status, UpstreamStatus: "502 Bad Gateway", ResponseHeaders: http.Header{"Content-Type": {"application/json"}, "X-Upstream": {"edge-a", "edge-b"}}, + ResponseBody: []byte{'{', '"', 'e', 'r', 'r', 'o', 'r', '"', ':', ' ', '"', 'f', 'a', 'i', 'l', 'e', 'd', '"', '}', 0xff}, ResponseBodyTruncated: true, + }, + }, + } + if err := repository.Create(ctx, record); err != nil { + t.Fatal(err) + } + stored, err := repository.Get(ctx, 1) + if err != nil { + t.Fatal(err) + } + if stored.AttemptCount != 2 || len(stored.Attempts) != 2 { + t.Fatalf("attempt count = %d, attempts = %#v", stored.AttemptCount, stored.Attempts) + } + if stored.Attempts[0].Number != 1 || stored.Attempts[0].Stage != "dns_lookup" || len(stored.Attempts[0].ErrorChain) != 2 { + t.Fatalf("transport attempt = %#v", stored.Attempts[0]) + } + httpAttempt := stored.Attempts[1] + if httpAttempt.Number != 2 || httpAttempt.UpstreamStatusCode == nil || *httpAttempt.UpstreamStatusCode != status || string(httpAttempt.ResponseBody) != string(record.Attempts[1].ResponseBody) || !httpAttempt.ResponseBodyTruncated || len(httpAttempt.ResponseHeaders["X-Upstream"]) != 2 { + t.Fatalf("HTTP attempt = %#v", httpAttempt) + } + if err := repository.Create(ctx, record); err != nil { + t.Fatal(err) + } + if count := tableRowCount(t, database, "request_audit_attempts"); count != 2 { + t.Fatalf("idempotent attempts = %d", count) + } + if _, err := repository.Get(ctx, 999); !errors.Is(err, repositorypkg.ErrNotFound) { + t.Fatalf("missing audit error = %v", err) + } +} + +func TestAuditRepositoryNormalizesUntrustedUsage(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-normalize.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + negativeFirstToken := int64(-4) + if err := repository.Create(ctx, audit.Record{RequestID: "normalize", ClientKeyID: 1, ModelRouteID: 1, StatusCode: 200, Streaming: true, MediaInputImages: -1, MediaOutputImages: -2, MediaOutputSeconds: -3, InputTokens: -1, TotalTokens: -2, FirstTokenMS: &negativeFirstToken, DurationMS: -3, CreatedAt: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + values, _, err := repository.List(ctx, 0, 1) + if err != nil || len(values) != 1 { + t.Fatalf("values = %#v, err = %v", values, err) + } + if values[0].MediaInputImages != 0 || values[0].MediaOutputImages != 0 || values[0].MediaOutputSeconds != 0 || values[0].InputTokens != 0 || values[0].TotalTokens != 0 || values[0].FirstTokenMS == nil || *values[0].FirstTokenMS != 0 || values[0].DurationMS != 0 { + t.Fatalf("normalized audit = %#v", values[0]) + } +} + +func TestAuditRepositorySummaryAppliesRangeAndGroupsPricingTier(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-summary.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAuditRepository(database) + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + values := []audit.Record{ + {RequestID: "standard", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "public", ModelUpstreamModel: "grok-build-0.1", StatusCode: 200, Streaming: true, InputTokens: 100, CachedInputTokens: 20, OutputTokens: 50, ReasoningTokens: 10, TotalTokens: 150, EstimatedCostInUSDTicks: 1_840_000, PricingModel: "grok-build-0.1", PricingVersion: "2026-07-11", DurationMS: 100, CreatedAt: now.Add(-time.Hour)}, + {RequestID: "long", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "public", ModelUpstreamModel: "grok-build-0.1", StatusCode: 500, Streaming: false, InputTokens: 210_000, ContextInputTokens: 210_000, OutputTokens: 100, TotalTokens: 210_100, DurationMS: 300, CreatedAt: now.Add(-2 * time.Hour)}, + {RequestID: "outside", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "public", ModelUpstreamModel: "grok-build-0.1", StatusCode: 200, TotalTokens: 999, CreatedAt: now.Add(-8 * 24 * time.Hour)}, + } + if err := repository.CreateBatch(ctx, values); err != nil { + t.Fatal(err) + } + summary, err := repository.Summarize(ctx, repositorypkg.AuditSummaryQuery{Search: "public", Start: now.Add(-7 * 24 * time.Hour), End: now}) + if err != nil { + t.Fatal(err) + } + if summary.Requests != 2 || summary.SuccessfulRequests != 1 || summary.FailedRequests != 1 || summary.TotalTokens != 210_250 || summary.DurationMS != 400 { + t.Fatalf("summary = %#v", summary) + } + if summary.EstimatedCostInUSDTicks != 1_840_000 || summary.PricedRequests != 1 || summary.UnpricedRequests != 1 || summary.PricedTokens != 150 || summary.UnpricedTokens != 210_100 { + t.Fatalf("summary pricing = %#v", summary) + } +} + +func uint64Pointer(value uint64) *uint64 { return &value } diff --git a/backend/internal/infra/persistence/relational/build_super_entitlement_test.go b/backend/internal/infra/persistence/relational/build_super_entitlement_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c265fbdb907ac5dd4cb7cca5a0307965e15baaa5 --- /dev/null +++ b/backend/internal/infra/persistence/relational/build_super_entitlement_test.go @@ -0,0 +1,131 @@ +package relational + +import ( + "context" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestBuildSuperEntitledDefaultsFalseAndSurvivesUpsert(t *testing.T) { + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + created, wasCreated, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "entitled", SourceKey: "entitled-upsert", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil || !wasCreated { + t.Fatalf("create = %#v created=%v err=%v", created, wasCreated, err) + } + if created.BuildSuperEntitled { + t.Fatal("new account must default BuildSuperEntitled=false") + } + if created.BuildRouteMode != account.BuildRouteAuto { + t.Fatalf("new account route mode = %q", created.BuildRouteMode) + } + created.BuildSuperEntitled = true + created.BuildRouteMode = account.BuildRouteXAI + if _, err := repo.Update(ctx, created); err != nil { + t.Fatal(err) + } + // 普通 upsert 不得清除 entitlement。 + updated, wasCreated, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "entitled-renamed", SourceKey: "entitled-upsert", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + BuildSuperEntitled: false, + }) + if err != nil || wasCreated { + t.Fatalf("upsert = %#v created=%v err=%v", updated, wasCreated, err) + } + if !updated.BuildSuperEntitled || updated.BuildRouteMode != account.BuildRouteXAI || updated.Name != "entitled-renamed" { + t.Fatalf("entitlement must survive upsert: %#v", updated) + } + // token refresh 路径不改 account 表 entitlement 列。 + refreshed, err := repo.UpdateTokens(ctx, updated.ID, "encrypted-new", "encrypted-refresh", time.Now().UTC().Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if !refreshed.BuildSuperEntitled || refreshed.BuildRouteMode != account.BuildRouteXAI { + t.Fatalf("token refresh must preserve Build settings: %#v", refreshed) + } +} + +func TestBuildSuperEntitledNonBuildForcedFalse(t *testing.T) { + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + created, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "web-entitled", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + BuildSuperEntitled: true, BuildRouteMode: account.BuildRouteXAI, + }) + if err != nil { + t.Fatal(err) + } + if created.BuildSuperEntitled { + t.Fatal("non-Build must not persist BuildSuperEntitled") + } + if created.BuildRouteMode != account.BuildRouteAuto { + t.Fatalf("non-Build route mode = %q", created.BuildRouteMode) + } +} + +func TestListRoutingCandidatesSharesEntitledBuildModels(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + models := NewModelRepository(database) + observer, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "observer", SourceKey: "ent-observer", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + BuildSuperEntitled: true, + }) + if err != nil { + t.Fatal(err) + } + // re-set after upsert create (fromAccountDomain true only when ProviderBuild — create path uses input) + observer.BuildSuperEntitled = true + if _, err := accounts.Update(ctx, observer); err != nil { + t.Fatal(err) + } + peer, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "peer", SourceKey: "ent-peer", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + peer.BuildSuperEntitled = true + if _, err := accounts.Update(ctx, peer); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + const sharedModel = "grok-imagine-video-1.5" + if err := models.UpsertDiscovered(ctx, account.ProviderBuild, []string{sharedModel}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, observer.ID, []string{sharedModel}, now); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, peer.ID, []string{"grok-4.5"}, now); err != nil { + t.Fatal(err) + } + candidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, 0, sharedModel, "") + if err != nil { + t.Fatal(err) + } + byID := map[uint64]account.RoutingCandidate{} + for _, c := range candidates { + byID[c.Credential.ID] = c + } + if c := byID[observer.ID]; !c.SupportsModel { + t.Fatalf("entitled observer should support model: %#v", c) + } + if c := byID[peer.ID]; !c.SupportsModel || !c.ModelCapabilityKnown { + t.Fatalf("entitled peer should share Super model support: %#v", c) + } + // paid filter includes entitlement + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "paid", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "free", Now: now}, 0) +} diff --git a/backend/internal/infra/persistence/relational/client_key_repository.go b/backend/internal/infra/persistence/relational/client_key_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..d050edc0b8266510656115df2efe5c46f960c0f7 --- /dev/null +++ b/backend/internal/infra/persistence/relational/client_key_repository.go @@ -0,0 +1,490 @@ +package relational + +import ( + "context" + "errors" + "sort" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type ClientKeyRepository struct { + db *Database + observer repository.InvalidationObserver +} + +func NewClientKeyRepository(db *Database) *ClientKeyRepository { return &ClientKeyRepository{db: db} } + +func (r *ClientKeyRepository) SetInvalidationObserver(observer repository.InvalidationObserver) { + r.observer = observer +} + +func (r *ClientKeyRepository) notifyInvalidation(ctx context.Context, clientKeyID uint64) { + if r.observer != nil { + r.observer(ctx, repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged, ClientKeyID: clientKeyID}) + } +} + +func (r *ClientKeyRepository) List(ctx context.Context, input repository.ClientKeyListQuery) ([]clientkey.Key, int64, error) { + var total int64 + query := r.db.db.WithContext(ctx).Model(&clientKeyModel{}) + if search := strings.TrimSpace(input.Page.Search); search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(name) LIKE ? OR LOWER(prefix) LIKE ?", pattern, pattern) + } + switch input.Filter.Status { + case "active": + query = query.Where("enabled = ? AND (expires_at IS NULL OR expires_at > ?)", true, input.Filter.Now) + case "disabled": + query = query.Where("enabled = ?", false) + case "expired": + query = query.Where("enabled = ? AND expires_at IS NOT NULL AND expires_at <= ?", true, input.Filter.Now) + } + switch input.Filter.ModelScope { + case "all": + query = query.Where("NOT EXISTS (SELECT 1 FROM client_key_models permission WHERE permission.client_key_id = client_keys.id)") + case "restricted": + query = query.Where("EXISTS (SELECT 1 FROM client_key_models permission WHERE permission.client_key_id = client_keys.id)") + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []clientKeyModel + // 列表查询不读取可恢复密文,只有管理员显式复制时才按 ID 加载。 + query = applyStableSort(query, input.Page.Sort, map[string]sortSpec{ + "name": {expression: "LOWER(client_keys.name)"}, + "prefix": {expression: "client_keys.prefix"}, + "status": {expression: "CASE WHEN client_keys.enabled = FALSE THEN 1 WHEN client_keys.expires_at IS NOT NULL AND client_keys.expires_at <= CURRENT_TIMESTAMP THEN 2 ELSE 0 END"}, + "rpmLimit": {expression: "client_keys.rpm_limit"}, + "maxConcurrent": {expression: "client_keys.max_concurrent"}, + "billingLimit": {expression: "client_keys.billing_limit_usd_ticks", defaultDirection: repository.SortDescending}, + "expiresAt": {expression: "client_keys.expires_at", nullsLast: true, defaultDirection: repository.SortDescending}, + "lastUsedAt": {expression: "client_keys.last_used_at", nullsLast: true, defaultDirection: repository.SortDescending}, + }, sortSpec{expression: "client_keys.created_at", defaultDirection: repository.SortDescending}, "client_keys.id") + if err := query.Select("id", "name", "prefix", "enabled", "expires_at", "rpm_limit", "max_concurrent", "billing_limit_usd_ticks", "billed_usage_usd_ticks", "reserved_usage_usd_ticks", "allow_model_aliases", "provider_scope_mask", "tier_scope_mask", "last_used_at", "created_at", "updated_at").Offset(input.Page.Offset).Limit(input.Page.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + ids := make([]uint64, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + permissions, err := r.allowedModelsForKeys(ctx, ids) + if err != nil { + return nil, 0, err + } + out := make([]clientkey.Key, 0, len(rows)) + for _, row := range rows { + out = append(out, toClientKeyDomain(row, permissions[row.ID])) + } + return out, total, nil +} + +func (r *ClientKeyRepository) UpdateManyEnabled(ctx context.Context, ids []uint64, enabled bool) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + result := r.db.db.WithContext(ctx).Model(&clientKeyModel{}).Where("id IN ?", ids).Update("enabled", enabled) + if result.Error == nil && result.RowsAffected > 0 { + r.notifyInvalidation(ctx, 0) + } + return result.RowsAffected, result.Error +} + +func (r *ClientKeyRepository) Create(ctx context.Context, value clientkey.Key) (clientkey.Key, error) { + scope, valid := clientkey.NormalizeAccountScope(clientkey.AccountScope{Providers: value.ProviderScope, Tiers: value.TierScope}) + if !valid { + return clientkey.Key{}, repository.ErrConflict + } + row := clientKeyModel{Name: value.Name, Prefix: value.Prefix, SecretHash: value.SecretHash, EncryptedSecret: value.EncryptedSecret, Enabled: value.Enabled, ExpiresAt: value.ExpiresAt, RPMLimit: value.RPMLimit, MaxConcurrent: value.MaxConcurrent, BillingLimitUSDTicks: value.BillingLimitUSDTicks, BilledUsageUSDTicks: value.BilledUsageUSDTicks, ReservedUsageUSDTicks: value.ReservedUsageUSDTicks, AllowModelAliases: value.AllowModelAliases, ProviderScopeMask: uint8(scope.Providers), TierScopeMask: uint8(scope.Tiers)} + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&row).Error; err != nil { + return err + } + // GORM 会把带 default tag 的零值替换为数据库默认值;这里显式写回 0, + // 使其稳定表示该维度无限制。 + unlimited := make(map[string]any, 2) + if value.RPMLimit == 0 { + unlimited["rpm_limit"] = 0 + row.RPMLimit = 0 + } + if value.MaxConcurrent == 0 { + unlimited["max_concurrent"] = 0 + row.MaxConcurrent = 0 + } + if len(unlimited) > 0 { + if err := tx.Model(&row).Updates(unlimited).Error; err != nil { + return err + } + } + return replacePermissions(tx, row.ID, value.AllowedModels) + }) + if err != nil { + return clientkey.Key{}, mapError(err) + } + return toClientKeyDomain(row, value.AllowedModels), nil +} + +func (r *ClientKeyRepository) Get(ctx context.Context, id uint64) (clientkey.Key, error) { + var row clientKeyModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return clientkey.Key{}, mapError(err) + } + models, err := r.allowedModels(ctx, id) + if err != nil { + return clientkey.Key{}, err + } + return toClientKeyDomain(row, models), nil +} + +func (r *ClientKeyRepository) GetByPrefix(ctx context.Context, prefix string) (clientkey.Key, error) { + var row clientKeyModel + if err := r.db.db.WithContext(ctx).Where("prefix = ?", prefix).First(&row).Error; err != nil { + return clientkey.Key{}, mapError(err) + } + models, err := r.allowedModels(ctx, row.ID) + if err != nil { + return clientkey.Key{}, err + } + return toClientKeyDomain(row, models), nil +} + +func (r *ClientKeyRepository) Update(ctx context.Context, value clientkey.Key) (clientkey.Key, error) { + scope, valid := clientkey.NormalizeAccountScope(clientkey.AccountScope{Providers: value.ProviderScope, Tiers: value.TierScope}) + if !valid { + return clientkey.Key{}, repository.ErrConflict + } + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&clientKeyModel{}).Where("id = ?", value.ID).Updates(map[string]any{ + "name": value.Name, "enabled": value.Enabled, "expires_at": value.ExpiresAt, + "rpm_limit": value.RPMLimit, "max_concurrent": value.MaxConcurrent, + "billing_limit_usd_ticks": value.BillingLimitUSDTicks, "allow_model_aliases": value.AllowModelAliases, + "provider_scope_mask": uint8(scope.Providers), "tier_scope_mask": uint8(scope.Tiers), + "updated_at": time.Now().UTC(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return replacePermissions(tx, value.ID, value.AllowedModels) + }) + if err != nil { + return clientkey.Key{}, mapError(err) + } + r.notifyInvalidation(ctx, value.ID) + return r.Get(ctx, value.ID) +} + +func (r *ClientKeyRepository) Delete(ctx context.Context, id uint64) error { + result := r.db.db.WithContext(ctx).Delete(&clientKeyModel{}, id) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + r.notifyInvalidation(ctx, id) + return nil +} + +func (r *ClientKeyRepository) DeleteMany(ctx context.Context, ids []uint64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + result := r.db.db.WithContext(ctx).Where("id IN ?", ids).Delete(&clientKeyModel{}) + if result.Error == nil && result.RowsAffected > 0 { + r.notifyInvalidation(ctx, 0) + } + return result.RowsAffected, result.Error +} + +func (r *ClientKeyRepository) Touch(ctx context.Context, id uint64) error { + now := time.Now().UTC() + return r.db.db.WithContext(ctx).Model(&clientKeyModel{}).Where("id = ?", id).Update("last_used_at", &now).Error +} + +// ReserveBillingUsage 在数据库中原子占用本次请求的最大预计费用。 +func (r *ClientKeyRepository) ReserveBillingUsage(ctx context.Context, id uint64, eventID string, amount int64, expiresAt time.Time) (bool, error) { + if id == 0 || eventID == "" || amount <= 0 { + return false, repository.ErrConflict + } + reserved := false + now := time.Now().UTC() + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing billingReservationModel + err := tx.Where("event_id = ?", eventID).First(&existing).Error + switch { + case err == nil && existing.ClientKeyID != id: + return repository.ErrConflict + case err == nil && existing.ExpiresAt.After(now): + if existing.Amount == amount { + reserved = true + return nil + } + return repository.ErrConflict + case err == nil: + if err := cleanupExpiredBillingReservations(tx, existing.ClientKeyID, now); err != nil { + return err + } + case errors.Is(err, gorm.ErrRecordNotFound): + default: + return err + } + acquired, err := reserveBillingCapacity(tx, id, amount) + if err != nil { + return err + } + if !acquired { + limited, err := billingKeyHasLimit(tx, id) + if err != nil || !limited { + return err + } + if err := cleanupExpiredBillingReservations(tx, id, now); err != nil { + return err + } + acquired, err = reserveBillingCapacity(tx, id, amount) + if err != nil { + return err + } + if !acquired { + limited, err = billingKeyHasLimit(tx, id) + if err != nil || !limited { + return err + } + return repository.ErrLimitExceeded + } + } + reservation := billingReservationModel{EventID: eventID, ClientKeyID: id, Amount: amount, ExpiresAt: expiresAt, CreatedAt: now} + if err := tx.Create(&reservation).Error; err != nil { + return err + } + reserved = true + return nil + }) + if !errors.Is(mapError(err), repository.ErrConflict) { + return reserved, err + } + var existing billingReservationModel + if lookupErr := r.db.db.WithContext(ctx).Where("event_id = ?", eventID).First(&existing).Error; lookupErr == nil && existing.ClientKeyID == id && existing.Amount == amount { + return true, nil + } + return false, repository.ErrConflict +} + +func reserveBillingCapacity(tx *gorm.DB, keyID uint64, amount int64) (bool, error) { + result := tx.Model(&clientKeyModel{}). + Where(`id = ? AND billing_limit_usd_ticks > 0 AND ? <= CASE + WHEN billed_usage_usd_ticks >= billing_limit_usd_ticks THEN 0 + WHEN reserved_usage_usd_ticks >= billing_limit_usd_ticks - billed_usage_usd_ticks THEN 0 + ELSE billing_limit_usd_ticks - billed_usage_usd_ticks - reserved_usage_usd_ticks + END`, keyID, amount). + UpdateColumn("reserved_usage_usd_ticks", gorm.Expr("reserved_usage_usd_ticks + ?", amount)) + return result.RowsAffected == 1, result.Error +} + +func billingKeyHasLimit(tx *gorm.DB, keyID uint64) (bool, error) { + var key clientKeyModel + if err := tx.Select("id", "billing_limit_usd_ticks").First(&key, keyID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, repository.ErrNotFound + } + return false, err + } + return key.BillingLimitUSDTicks > 0, nil +} + +// CancelBillingReservation 释放尚未进入审计结算的请求预留。 +func (r *ClientKeyRepository) CancelBillingReservation(ctx context.Context, eventID string) error { + if eventID == "" { + return nil + } + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var reservation billingReservationModel + if err := tx.Where("event_id = ?", eventID).First(&reservation).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if err := lockClientKey(tx, reservation.ClientKeyID); err != nil { + return err + } + if err := decrementReservedUsage(tx, reservation.ClientKeyID, reservation.Amount); err != nil { + return err + } + result := tx.Where("event_id = ?", eventID).Delete(&billingReservationModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrConflict + } + return nil + }) +} + +// CleanupExpiredBillingReservations 分批释放进程异常遗留的过期预留。 +func (r *ClientKeyRepository) CleanupExpiredBillingReservations(ctx context.Context, now time.Time, limit int, protectedEventIDSets ...[]string) (int, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + protected := make(map[string]struct{}) + for _, values := range protectedEventIDSets { + for _, eventID := range values { + if eventID != "" { + protected[eventID] = struct{}{} + } + } + } + scanLimit := min(200000, limit+len(protected)) + const noPendingMediaUsage = "NOT EXISTS (SELECT 1 FROM media_jobs WHERE billing_reservations.event_id = 'video_usage_' || media_jobs.id AND media_jobs.usage_recorded_at IS NULL)" + var candidates []billingReservationModel + if err := r.db.db.WithContext(ctx).Model(&billingReservationModel{}). + Select("event_id", "client_key_id", "amount", "expires_at"). + Where("expires_at <= ?", now). + Where(noPendingMediaUsage). + Order("expires_at ASC, event_id ASC").Limit(scanLimit).Find(&candidates).Error; err != nil { + return 0, err + } + byKey := make(map[uint64][]string) + selected := 0 + for _, candidate := range candidates { + if _, skip := protected[candidate.EventID]; skip { + continue + } + byKey[candidate.ClientKeyID] = append(byKey[candidate.ClientKeyID], candidate.EventID) + selected++ + if selected >= limit { + break + } + } + keyIDs := make([]uint64, 0, len(byKey)) + for keyID := range byKey { + keyIDs = append(keyIDs, keyID) + } + sort.Slice(keyIDs, func(i, j int) bool { return keyIDs[i] < keyIDs[j] }) + cleaned := 0 + for _, keyID := range keyIDs { + eventIDs := byKey[keyID] + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockClientKey(tx, keyID); err != nil { + return err + } + var rows []billingReservationModel + if err := tx.Model(&billingReservationModel{}).Select("event_id", "amount"). + Where("client_key_id = ? AND event_id IN ? AND expires_at <= ?", keyID, eventIDs, now). + Where(noPendingMediaUsage).Find(&rows).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + var amount int64 + rowIDs := make([]string, 0, len(rows)) + for _, row := range rows { + amount += row.Amount + rowIDs = append(rowIDs, row.EventID) + } + result := tx.Where("client_key_id = ? AND event_id IN ? AND expires_at <= ?", keyID, rowIDs, now). + Where(noPendingMediaUsage).Delete(&billingReservationModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != int64(len(rows)) { + return repository.ErrConflict + } + if err := decrementReservedUsage(tx, keyID, amount); err != nil { + return err + } + cleaned += len(rows) + return nil + }) + if err != nil { + return cleaned, err + } + } + return cleaned, nil +} + +func cleanupExpiredBillingReservations(tx *gorm.DB, keyID uint64, now time.Time) error { + if err := lockClientKey(tx, keyID); err != nil { + return err + } + var amount int64 + if err := tx.Model(&billingReservationModel{}).Where("client_key_id = ? AND expires_at <= ?", keyID, now).Select("COALESCE(SUM(amount), 0)").Scan(&amount).Error; err != nil || amount <= 0 { + return err + } + result := tx.Where("client_key_id = ? AND expires_at <= ?", keyID, now).Delete(&billingReservationModel{}) + if result.Error != nil || result.RowsAffected == 0 { + return result.Error + } + return decrementReservedUsage(tx, keyID, amount) +} + +func lockClientKey(tx *gorm.DB, keyID uint64) error { + var key clientKeyModel + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&key, keyID).Error; err != nil { + return mapError(err) + } + return nil +} + +func expiredBillingReservationCount(tx *gorm.DB, keyID uint64, now time.Time) (int64, error) { + var count int64 + err := tx.Model(&billingReservationModel{}).Where("client_key_id = ? AND expires_at <= ?", keyID, now).Count(&count).Error + return count, err +} + +func decrementReservedUsage(tx *gorm.DB, keyID uint64, amount int64) error { + return tx.Model(&clientKeyModel{}).Where("id = ?", keyID).UpdateColumn( + "reserved_usage_usd_ticks", + gorm.Expr("CASE WHEN reserved_usage_usd_ticks <= ? THEN 0 ELSE reserved_usage_usd_ticks - ? END", amount, amount), + ).Error +} + +func (r *ClientKeyRepository) allowedModels(ctx context.Context, keyID uint64) ([]uint64, error) { + var rows []clientKeyModelPermission + if err := r.db.db.WithContext(ctx).Where("client_key_id = ?", keyID).Order("model_route_id ASC").Find(&rows).Error; err != nil { + return nil, err + } + out := make([]uint64, 0, len(rows)) + for _, row := range rows { + out = append(out, row.ModelRouteID) + } + return out, nil +} + +func (r *ClientKeyRepository) allowedModelsForKeys(ctx context.Context, keyIDs []uint64) (map[uint64][]uint64, error) { + result := make(map[uint64][]uint64, len(keyIDs)) + if len(keyIDs) == 0 { + return result, nil + } + var rows []clientKeyModelPermission + if err := r.db.db.WithContext(ctx).Where("client_key_id IN ?", keyIDs).Order("client_key_id ASC, model_route_id ASC").Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + result[row.ClientKeyID] = append(result[row.ClientKeyID], row.ModelRouteID) + } + return result, nil +} + +func replacePermissions(tx *gorm.DB, keyID uint64, modelIDs []uint64) error { + if err := tx.Where("client_key_id = ?", keyID).Delete(&clientKeyModelPermission{}).Error; err != nil { + return err + } + rows := make([]clientKeyModelPermission, 0, len(modelIDs)) + for _, modelID := range modelIDs { + rows = append(rows, clientKeyModelPermission{ClientKeyID: keyID, ModelRouteID: modelID}) + } + if len(rows) > 0 { + return tx.CreateInBatches(rows, 200).Error + } + return nil +} diff --git a/backend/internal/infra/persistence/relational/client_key_repository_test.go b/backend/internal/infra/persistence/relational/client_key_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1796700ed13be98096980fe211ab3b8f151d05a8 --- /dev/null +++ b/backend/internal/infra/persistence/relational/client_key_repository_test.go @@ -0,0 +1,366 @@ +package relational + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestClientKeyBillingReservationsEnforceLimitAndExpire(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "client-key-reservations.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "limited", Prefix: "limited", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_reservation_limit_0001", 60, now.Add(time.Hour)); err != nil || !reserved { + t.Fatal(err) + } + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_reservation_limit_0001", 60, now.Add(time.Hour)); err != nil || !reserved { + t.Fatalf("idempotent reserve: %v", err) + } + if _, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_reservation_limit_0002", 50, now.Add(time.Hour)); !errors.Is(err, repository.ErrLimitExceeded) { + t.Fatalf("limit error = %v", err) + } + if err := keys.CancelBillingReservation(ctx, "evt_reservation_limit_0001"); err != nil { + t.Fatal(err) + } + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_reservation_expired_0001", 80, now.Add(-time.Minute)); err != nil || !reserved { + t.Fatal(err) + } + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_reservation_after_expiry_0001", 100, now.Add(time.Hour)); err != nil || !reserved { + t.Fatalf("reserve after expiry cleanup: %v", err) + } + stored, err := keys.Get(ctx, key.ID) + if err != nil || stored.ReservedUsageUSDTicks != 100 { + t.Fatalf("stored = %#v, err = %v", stored, err) + } +} + +func TestClientKeyBillingReservationSkipsExpiryCleanupWithoutPressure(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "limited", Prefix: "cleanup-pressure", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_cleanup_pressure_expired", 20, now.Add(-time.Minute)); err != nil || !reserved { + t.Fatalf("expired reserve: reserved=%v, err=%v", reserved, err) + } + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_cleanup_pressure_active_1", 30, now.Add(time.Hour)); err != nil || !reserved { + t.Fatalf("reserve with remaining capacity: reserved=%v, err=%v", reserved, err) + } + var reservations int64 + if err := database.db.WithContext(ctx).Model(&billingReservationModel{}).Where("client_key_id = ?", key.ID).Count(&reservations).Error; err != nil { + t.Fatal(err) + } + if reservations != 2 { + t.Fatalf("reservation count = %d, want 2", reservations) + } + stored, err := keys.Get(ctx, key.ID) + if err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 50 { + t.Fatalf("reserved usage = %d, want 50", stored.ReservedUsageUSDTicks) + } + cleaned, err := keys.CleanupExpiredBillingReservations(ctx, now, 10) + if err != nil { + t.Fatal(err) + } + if cleaned != 1 { + t.Fatalf("cleaned = %d, want 1", cleaned) + } + stored, err = keys.Get(ctx, key.ID) + if err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 30 { + t.Fatalf("reserved usage after cleanup = %d, want 30", stored.ReservedUsageUSDTicks) + } +} + +func TestClientKeyBillingReservationRecreatesExpiredEvent(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "limited", Prefix: "renew-expired", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + const eventID = "evt_renew_expired_reservation" + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, eventID, 40, now.Add(-time.Minute)); err != nil || !reserved { + t.Fatalf("expired reserve: reserved=%v, err=%v", reserved, err) + } + renewedUntil := now.Add(time.Hour) + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, eventID, 60, renewedUntil); err != nil || !reserved { + t.Fatalf("recreate reserve: reserved=%v, err=%v", reserved, err) + } + var reservation billingReservationModel + if err := database.db.WithContext(ctx).Where("event_id = ?", eventID).First(&reservation).Error; err != nil { + t.Fatal(err) + } + if !reservation.ExpiresAt.Equal(renewedUntil) { + t.Fatalf("expires at = %s, want %s", reservation.ExpiresAt, renewedUntil) + } + stored, err := keys.Get(ctx, key.ID) + if err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 60 { + t.Fatalf("reserved usage = %d, want 60", stored.ReservedUsageUSDTicks) + } +} + +func TestClientKeyBillingReservationCannotMoveExpiredEventBetweenKeys(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + first, err := keys.Create(ctx, clientkeydomain.Key{Name: "first", Prefix: "expired-owner-a", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + second, err := keys.Create(ctx, clientkeydomain.Key{Name: "second", Prefix: "expired-owner-b", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + const eventID = "evt_expired_reservation_owner" + if reserved, err := keys.ReserveBillingUsage(ctx, first.ID, eventID, 40, time.Now().UTC().Add(-time.Minute)); err != nil || !reserved { + t.Fatalf("expired reserve: reserved=%v, err=%v", reserved, err) + } + if _, err := keys.ReserveBillingUsage(ctx, second.ID, eventID, 40, time.Now().UTC().Add(time.Hour)); !errors.Is(err, repository.ErrConflict) { + t.Fatalf("cross-key reserve error = %v, want conflict", err) + } + stored, err := keys.Get(ctx, first.ID) + if err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 40 { + t.Fatalf("first key reserved usage = %d, want 40", stored.ReservedUsageUSDTicks) + } +} + +func TestClientKeyBillingReservationsDoNotExceedLimitConcurrently(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "limited", Prefix: "concurrent-reserve", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + const workers = 20 + start := make(chan struct{}) + errorsCh := make(chan error, workers) + var successes atomic.Int64 + var wait sync.WaitGroup + wait.Add(workers) + for index := range workers { + go func() { + defer wait.Done() + <-start + reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, fmt.Sprintf("evt_concurrent_reservation_%04d", index), 10, time.Now().UTC().Add(time.Hour)) + switch { + case reserveErr == nil && reserved: + successes.Add(1) + case errors.Is(reserveErr, repository.ErrLimitExceeded): + default: + errorsCh <- fmt.Errorf("worker %d: reserved=%v, err=%w", index, reserved, reserveErr) + } + }() + } + close(start) + wait.Wait() + close(errorsCh) + for reserveErr := range errorsCh { + t.Error(reserveErr) + } + if t.Failed() { + return + } + if successes.Load() != 10 { + t.Fatalf("successful reservations = %d, want 10", successes.Load()) + } + stored, err := keys.Get(ctx, key.ID) + if err != nil { + t.Fatal(err) + } + if stored.ReservedUsageUSDTicks != 100 { + t.Fatalf("reserved usage = %d, want 100", stored.ReservedUsageUSDTicks) + } +} + +func TestCleanupExpiredBillingReservationsProtectsPendingMediaUsage(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "media", Prefix: "media-cleanup", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + type mediaCase struct { + id string + status string + recorded bool + } + cases := []mediaCase{ + {id: "video_cleanup_queued", status: "queued"}, + {id: "video_cleanup_running", status: "in_progress"}, + {id: "video_cleanup_completed", status: "completed"}, + {id: "video_cleanup_recorded", status: "completed", recorded: true}, + } + for index, test := range cases { + eventID := "video_usage_" + test.id + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, eventID, 100, now.Add(-time.Minute)); reserveErr != nil || !reserved { + t.Fatalf("reserve %s: reserved=%v err=%v", eventID, reserved, reserveErr) + } + var usageRecordedAt *time.Time + if test.recorded { + value := now.Add(-time.Second) + usageRecordedAt = &value + } + job := mediaJobModel{ + ID: test.id, RequestID: fmt.Sprintf("media-cleanup-%d", index), ClientKeyID: key.ID, + Provider: "grok_web", Model: "video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "test", + Seconds: 6, Size: "16:9", Quality: "720p", Status: test.status, InputJSON: "{}", + CreatedAt: now, UpdatedAt: now, UsageRecordedAt: usageRecordedAt, + } + if err := database.db.WithContext(ctx).Create(&job).Error; err != nil { + t.Fatal(err) + } + } + cleaned, err := keys.CleanupExpiredBillingReservations(ctx, now, 10) + if err != nil { + t.Fatal(err) + } + if cleaned != 1 { + t.Fatalf("cleaned = %d, want only recorded terminal media reservation", cleaned) + } + var remaining []billingReservationModel + if err := database.db.WithContext(ctx).Order("event_id ASC").Find(&remaining).Error; err != nil { + t.Fatal(err) + } + if len(remaining) != 3 { + t.Fatalf("remaining reservations = %#v", remaining) + } +} + +func TestCleanupExpiredBillingReservationsLimitsActualRows(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "batch", Prefix: "cleanup-row-limit", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + for index := range 5 { + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, fmt.Sprintf("evt_cleanup_row_%02d", index), 10, now.Add(-time.Minute)); reserveErr != nil || !reserved { + t.Fatalf("reserve %d: reserved=%v err=%v", index, reserved, reserveErr) + } + } + cleaned, err := keys.CleanupExpiredBillingReservations(ctx, now, 2) + if err != nil { + t.Fatal(err) + } + if cleaned != 2 { + t.Fatalf("cleaned = %d, want 2", cleaned) + } + var remaining int64 + if err := database.db.WithContext(ctx).Model(&billingReservationModel{}).Count(&remaining).Error; err != nil { + t.Fatal(err) + } + if remaining != 3 { + t.Fatalf("remaining = %d, want 3", remaining) + } +} + +func TestBillingSettlementRacesExpiredCleanupWithoutLosingUsage(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + audits := NewAuditRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "race", Prefix: "settlement-cleanup", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 10_000}) + if err != nil { + t.Fatal(err) + } + const iterations = 20 + for index := range iterations { + now := time.Now().UTC() + eventID := fmt.Sprintf("evt_settlement_cleanup_%02d", index) + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, eventID, 10, now.Add(-time.Minute)); reserveErr != nil || !reserved { + t.Fatalf("reserve %d: reserved=%v err=%v", index, reserved, reserveErr) + } + start := make(chan struct{}) + errorsCh := make(chan error, 2) + go func() { + <-start + errorsCh <- audits.Create(ctx, auditdomain.Record{ + EventID: eventID, RequestID: eventID, ClientKeyID: key.ID, ModelRouteID: 1, + Provider: "grok_build", Operation: auditdomain.OperationResponses, UsageSource: auditdomain.UsageSourceUpstream, + StatusCode: 200, CostInUSDTicks: 10, CreatedAt: now, + }) + }() + go func() { + <-start + _, cleanupErr := keys.CleanupExpiredBillingReservations(ctx, now, 1) + errorsCh <- cleanupErr + }() + close(start) + for range 2 { + if concurrentErr := <-errorsCh; concurrentErr != nil { + t.Fatalf("iteration %d: %v", index, concurrentErr) + } + } + } + stored, err := keys.Get(ctx, key.ID) + if err != nil { + t.Fatal(err) + } + if stored.BilledUsageUSDTicks != iterations*10 || stored.ReservedUsageUSDTicks != 0 { + t.Fatalf("billing state = %#v", stored) + } +} + +func TestClientKeyUpdateDoesNotOverwriteConcurrentBillingState(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "before", Prefix: "concurrent", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, BillingLimitUSDTicks: 100}) + if err != nil { + t.Fatal(err) + } + stale := key + if reserved, err := keys.ReserveBillingUsage(ctx, key.ID, "evt_update_preserves_reservation_0001", 40, time.Now().UTC().Add(time.Hour)); err != nil || !reserved { + t.Fatal(err) + } + stale.Name = "after" + updated, err := keys.Update(ctx, stale) + if err != nil { + t.Fatal(err) + } + if updated.Name != "after" || updated.ReservedUsageUSDTicks != 40 { + t.Fatalf("updated = %#v", updated) + } +} diff --git a/backend/internal/infra/persistence/relational/console_provider_test.go b/backend/internal/infra/persistence/relational/console_provider_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d3ab2749cc703c45ffeca0ad6fb4d236cd802942 --- /dev/null +++ b/backend/internal/infra/persistence/relational/console_provider_test.go @@ -0,0 +1,69 @@ +package relational + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestConsoleQuotaParticipatesInRoutingAndSummary(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "console.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewAccountRepository(database) + credential, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", SourceKey: "console:test", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + resetAt := now.Add(time.Hour) + if err := repository.SaveQuotaWindows(ctx, credential.ID, "", now, []account.QuotaWindow{{ + AccountID: credential.ID, Mode: "console", Remaining: 20, Total: 20, WindowSeconds: 3600, + ResetAt: &resetAt, Source: account.QuotaSourceDefault, UpdatedAt: now, + }}); err != nil { + t.Fatal(err) + } + var profileCount int64 + if err := database.db.WithContext(ctx).Model(&webAccountProfileModel{}).Where("account_id = ?", credential.ID).Count(&profileCount).Error; err != nil { + t.Fatal(err) + } + if profileCount != 0 { + t.Fatalf("console created %d web profiles", profileCount) + } + candidates, err := repository.ListRoutingCandidates(ctx, account.ProviderConsole, 0, "grok-4.3", "console") + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 || candidates[0].QuotaWindow == nil || candidates[0].QuotaWindow.Remaining != 20 { + t.Fatalf("candidates = %#v", candidates) + } + summary, err := repository.Summarize(ctx, now) + if err != nil { + t.Fatal(err) + } + if len(summary) != 1 || summary[0].Available != 1 || summary[0].WaitingReset != 0 { + t.Fatalf("summary before exhaustion = %#v", summary) + } + if err := repository.ExhaustQuotaWindow(ctx, credential.ID, "console", &resetAt, now); err != nil { + t.Fatal(err) + } + summary, err = repository.Summarize(ctx, now) + if err != nil { + t.Fatal(err) + } + if len(summary) != 1 || summary[0].Available != 0 || summary[0].WaitingReset != 1 { + t.Fatalf("summary after exhaustion = %#v", summary) + } +} diff --git a/backend/internal/infra/persistence/relational/dashboard_repository.go b/backend/internal/infra/persistence/relational/dashboard_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..97c01fd95613bce1dbf0d8854ec44d51e2c78e47 --- /dev/null +++ b/backend/internal/infra/persistence/relational/dashboard_repository.go @@ -0,0 +1,242 @@ +package relational + +import ( + "context" + "fmt" + "strings" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + dashboarddomain "github.com/chenyme/grok2api/backend/internal/domain/dashboard" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +type DashboardRepository struct{ db *Database } + +func NewDashboardRepository(db *Database) *DashboardRepository { return &DashboardRepository{db: db} } + +const dashboardUsageAggregateSelect = ` + COUNT(*) AS requests, + COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) AS successful_requests, + COALESCE(SUM(CASE WHEN status_code < 200 OR status_code >= 300 THEN 1 ELSE 0 END), 0) AS failed_requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COALESCE(SUM(total_tokens), 0) AS tokens, + COALESCE(SUM(CASE WHEN cost_in_usd_ticks > 0 THEN cost_in_usd_ticks ELSE estimated_cost_in_usd_ticks END), 0) AS billed_cost_usd_ticks, + COUNT(first_token_ms) AS first_token_samples, + COALESCE(SUM(first_token_ms), 0) AS first_token_total_ms, + COALESCE(SUM(CASE WHEN first_token_ms IS NOT NULL AND output_tokens > 0 AND duration_ms > first_token_ms THEN 1 ELSE 0 END), 0) AS throughput_samples, + COALESCE(SUM(CASE WHEN first_token_ms IS NOT NULL AND output_tokens > 0 AND duration_ms > first_token_ms THEN output_tokens ELSE 0 END), 0) AS throughput_tokens, + COALESCE(SUM(CASE WHEN first_token_ms IS NOT NULL AND output_tokens > 0 AND duration_ms > first_token_ms THEN duration_ms - first_token_ms ELSE 0 END), 0) AS generation_total_ms` + +const dashboardTopModelsLimit = 10 + +// Snapshot 在同一数据库事务内读取资源计数和指定区间的审计聚合。 +func (r *DashboardRepository) Snapshot(ctx context.Context, window repository.DashboardSnapshotWindow, snapshotAt time.Time) (dashboarddomain.Aggregate, error) { + if err := validateDashboardBoundaries(window.BucketBoundaries); err != nil { + return dashboarddomain.Aggregate{}, err + } + if err := validateDashboardBoundaries(window.ActivityBoundaries); err != nil { + return dashboarddomain.Aggregate{}, err + } + bucketBoundaries := window.BucketBoundaries + start := bucketBoundaries[0] + end := bucketBoundaries[len(bucketBoundaries)-1] + bucketExpression, bucketArgs := dashboardBucketExpression(bucketBoundaries) + activityExpression, activityArgs := dashboardBucketExpression(window.ActivityBoundaries) + result := dashboarddomain.Aggregate{} + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var accounts struct { + Total int64 + Active int64 + BuildAccounts int64 + WebAccounts int64 + ConsoleAccounts int64 + } + if err := tx.Model(&accountModel{}). + Select("COUNT(*) AS total, COALESCE(SUM(CASE WHEN enabled = ? AND auth_status = ? AND (cooldown_until IS NULL OR cooldown_until <= ?) AND NOT EXISTS (SELECT 1 FROM account_quota_recovery WHERE account_quota_recovery.account_id = provider_accounts.id AND account_quota_recovery.status IN ?) THEN 1 ELSE 0 END), 0) AS active, COALESCE(SUM(CASE WHEN provider = 'grok_build' THEN 1 ELSE 0 END), 0) AS build_accounts, COALESCE(SUM(CASE WHEN provider = 'grok_web' THEN 1 ELSE 0 END), 0) AS web_accounts, COALESCE(SUM(CASE WHEN provider = 'grok_console' THEN 1 ELSE 0 END), 0) AS console_accounts", true, "active", snapshotAt, []string{"exhausted", "probing"}). + Scan(&accounts).Error; err != nil { + return err + } + + var models struct { + Total int64 + Enabled int64 + } + if err := tx.Model(&modelRouteModel{}). + Select("COUNT(*) AS total, COALESCE(SUM(CASE WHEN enabled = ? AND "+availableRoutePredicate+" THEN 1 ELSE 0 END), 0) AS enabled", true, true, "active"). + Scan(&models).Error; err != nil { + return err + } + + var clientKeys struct { + Total int64 + Active int64 + } + if err := tx.Model(&clientKeyModel{}). + Select("COUNT(*) AS total, COALESCE(SUM(CASE WHEN enabled = ? AND (expires_at IS NULL OR expires_at > ?) THEN 1 ELSE 0 END), 0) AS active", true, snapshotAt). + Scan(&clientKeys).Error; err != nil { + return err + } + + result.Resources.ActiveAccounts = accounts.Active + result.Resources.TotalAccounts = accounts.Total + result.Resources.BuildAccounts = accounts.BuildAccounts + result.Resources.WebAccounts = accounts.WebAccounts + result.Resources.ConsoleAccounts = accounts.ConsoleAccounts + result.Resources.EnabledModels = models.Enabled + result.Resources.TotalModels = models.Total + result.Resources.ActiveClientKeys = clientKeys.Active + result.Resources.TotalClientKeys = clientKeys.Total + + if err := tx.Model(&requestAuditModel{}). + Select(dashboardUsageAggregateSelect). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&result.Usage).Error; err != nil { + return err + } + var buckets []struct { + BucketIndex int `gorm:"column:bucket_index"` + Requests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 + } + if err := tx.Model(&requestAuditModel{}). + Select(bucketExpression+" AS bucket_index, COUNT(*) AS requests, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, COALESCE(SUM(total_tokens), 0) AS tokens, COALESCE(SUM(CASE WHEN cost_in_usd_ticks > 0 THEN cost_in_usd_ticks ELSE estimated_cost_in_usd_ticks END), 0) AS billed_cost_usd_ticks", bucketArgs...). + Where("created_at >= ? AND created_at < ?", start, end). + Group("bucket_index"). + Order("bucket_index ASC"). + Scan(&buckets).Error; err != nil { + return err + } + result.Buckets = make([]dashboarddomain.Bucket, 0, len(buckets)) + for _, bucket := range buckets { + result.Buckets = append(result.Buckets, dashboarddomain.Bucket{Index: bucket.BucketIndex, Requests: bucket.Requests, InputTokens: bucket.InputTokens, CachedInputTokens: bucket.CachedInputTokens, OutputTokens: bucket.OutputTokens, ReasoningTokens: bucket.ReasoningTokens, Tokens: bucket.Tokens, BilledCostUSDTicks: bucket.BilledCostUSDTicks}) + } + + var activityBuckets []struct { + BucketIndex int `gorm:"column:bucket_index"` + Requests int64 + } + if err := tx.Model(&requestAuditModel{}). + Select(activityExpression+" AS bucket_index, COUNT(*) AS requests", activityArgs...). + Where("created_at >= ? AND created_at < ?", window.ActivityBoundaries[0], window.ActivityBoundaries[len(window.ActivityBoundaries)-1]). + Group("bucket_index"). + Order("bucket_index ASC"). + Scan(&activityBuckets).Error; err != nil { + return err + } + result.ActivityBuckets = make([]dashboarddomain.ActivityBucket, 0, len(activityBuckets)) + for _, bucket := range activityBuckets { + result.ActivityBuckets = append(result.ActivityBuckets, dashboarddomain.ActivityBucket{Index: bucket.BucketIndex, Requests: bucket.Requests}) + } + + var providers []struct { + Provider string + Requests int64 + SuccessfulRequests int64 + Tokens int64 + } + if err := tx.Model(&requestAuditModel{}). + Select("provider, COUNT(*) AS requests, COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) AS successful_requests, COALESCE(SUM(total_tokens), 0) AS tokens"). + Where("created_at >= ? AND created_at < ?", start, end). + Group("provider"). + Order("requests DESC, provider ASC"). + Scan(&providers).Error; err != nil { + return err + } + result.Providers = make([]dashboarddomain.ProviderUsage, 0, len(providers)) + for _, item := range providers { + result.Providers = append(result.Providers, dashboarddomain.ProviderUsage{Provider: item.Provider, Requests: item.Requests, SuccessfulRequests: item.SuccessfulRequests, Tokens: item.Tokens}) + } + + modelExpression := "CASE WHEN TRIM(model_public_id) <> '' THEN model_public_id WHEN TRIM(model_upstream_model) <> '' THEN model_upstream_model ELSE 'unknown' END" + var topModels []struct { + Model string + Requests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 + } + if err := tx.Model(&requestAuditModel{}). + Select(modelExpression+" AS model, COUNT(*) AS requests, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, COALESCE(SUM(total_tokens), 0) AS tokens, COALESCE(SUM(CASE WHEN cost_in_usd_ticks > 0 THEN cost_in_usd_ticks ELSE estimated_cost_in_usd_ticks END), 0) AS billed_cost_usd_ticks"). + Where("created_at >= ? AND created_at < ?", start, end). + Group(modelExpression). + Order("billed_cost_usd_ticks DESC, requests DESC, tokens DESC, model ASC"). + Limit(dashboardTopModelsLimit). + Scan(&topModels).Error; err != nil { + return err + } + result.TopModels = make([]dashboarddomain.ModelUsage, 0, dashboardTopModelsLimit) + listedModels := make(map[string]struct{}, dashboardTopModelsLimit) + for _, item := range topModels { + result.TopModels = append(result.TopModels, dashboarddomain.ModelUsage{Model: item.Model, Requests: item.Requests, InputTokens: item.InputTokens, CachedInputTokens: item.CachedInputTokens, OutputTokens: item.OutputTokens, ReasoningTokens: item.ReasoningTokens, Tokens: item.Tokens, BilledCostUSDTicks: item.BilledCostUSDTicks}) + listedModels[item.Model] = struct{}{} + } + if len(result.TopModels) < dashboardTopModelsLimit { + var enabledModels []struct { + PublicID string + Provider string + } + if err := tx.Model(&modelRouteModel{}). + Select("public_id, provider"). + Where("enabled = ?", true). + Order("public_id ASC"). + Limit(dashboardTopModelsLimit * len(accountdomain.Providers())). + Scan(&enabledModels).Error; err != nil { + return err + } + for _, route := range enabledModels { + publicID := modeldomain.ExternalPublicID(accountdomain.Provider(route.Provider), route.PublicID) + if publicID == "" { + continue + } + if _, exists := listedModels[publicID]; exists { + continue + } + result.TopModels = append(result.TopModels, dashboarddomain.ModelUsage{Model: publicID}) + listedModels[publicID] = struct{}{} + if len(result.TopModels) == dashboardTopModelsLimit { + break + } + } + } + return nil + }) + return result, err +} + +func validateDashboardBoundaries(boundaries []time.Time) error { + if len(boundaries) < 2 { + return fmt.Errorf("Dashboard 聚合范围无效") + } + for index := 1; index < len(boundaries); index++ { + if !boundaries[index-1].Before(boundaries[index]) { + return fmt.Errorf("Dashboard 时间桶无效") + } + } + return nil +} + +func dashboardBucketExpression(boundaries []time.Time) (string, []any) { + var expression strings.Builder + expression.WriteString("CASE") + args := make([]any, 0) + for index := 0; index < len(boundaries)-1; index++ { + expression.WriteString(" WHEN created_at >= ? AND created_at < ? THEN ?") + args = append(args, boundaries[index], boundaries[index+1], index) + } + expression.WriteString(" ELSE -1 END") + return expression.String(), args +} diff --git a/backend/internal/infra/persistence/relational/dashboard_repository_test.go b/backend/internal/infra/persistence/relational/dashboard_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9a9c36fc7740032690ca07a4aa65d987bb4c520c --- /dev/null +++ b/backend/internal/infra/persistence/relational/dashboard_repository_test.go @@ -0,0 +1,266 @@ +package relational + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestDashboardRepositorySnapshot(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "dashboard.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + active := &accountModel{IdentityKey: testIdentityKey("active"), Provider: "grok_build", Name: "active", SourceKey: "active", Enabled: true, AuthStatus: "active", MaxConcurrent: 1} + exhausted := &accountModel{IdentityKey: testIdentityKey("exhausted"), Provider: "grok_build", Name: "exhausted", SourceKey: "exhausted", Enabled: true, AuthStatus: "active", MaxConcurrent: 1} + enabledRoute := &modelRouteModel{PublicID: "enabled", Provider: "grok_build", UpstreamModel: "enabled", Capability: "responses", Enabled: true} + rows := []any{ + active, + exhausted, + &accountModel{IdentityKey: testIdentityKey("disabled"), Provider: "grok_build", Name: "disabled", SourceKey: "disabled", Enabled: false, AuthStatus: "active", MaxConcurrent: 1}, + enabledRoute, + &modelRouteModel{PublicID: "disabled", Provider: "grok_build", UpstreamModel: "disabled", Capability: "responses", Enabled: false}, + &clientKeyModel{Name: "active", Prefix: "gkp_active", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true}, + &clientKeyModel{Name: "expired", Prefix: "gkp_expired", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, ExpiresAt: timePointer(now.Add(-time.Hour))}, + } + for _, row := range rows { + if err := database.db.WithContext(ctx).Create(row).Error; err != nil { + t.Fatal(err) + } + } + if err := database.db.WithContext(ctx).Create(&accountModelCapabilityModel{AccountID: active.ID, UpstreamModel: enabledRoute.UpstreamModel}).Error; err != nil { + t.Fatal(err) + } + for _, value := range []accountCredentialModel{ + {AccountID: 1, AuthType: "oauth", EncryptedPrimary: testEncryptedToken, UpdatedAt: now}, + {AccountID: 2, AuthType: "oauth", EncryptedPrimary: testEncryptedToken, UpdatedAt: now}, + {AccountID: 3, AuthType: "oauth", EncryptedPrimary: testEncryptedToken, UpdatedAt: now}, + } { + if err := database.db.WithContext(ctx).Create(&value).Error; err != nil { + t.Fatal(err) + } + } + if err := database.db.WithContext(ctx).Create("aRecoveryModel{AccountID: exhausted.ID, Kind: "free", Status: "exhausted", NextProbeAt: timePointer(now.Add(24 * time.Hour)), UpdatedAt: now}).Error; err != nil { + t.Fatal(err) + } + firstTokenOne := int64(100) + firstTokenTwo := int64(300) + audits := []requestAuditModel{ + {RequestID: "success-1", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "grok-primary", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, Streaming: true, OutputTokens: 90, TotalTokens: 100, FirstTokenMS: &firstTokenOne, DurationMS: 1100, CreatedAt: now.Add(-23 * time.Hour)}, + {RequestID: "success-2", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "grok-secondary", Provider: "grok_web", Operation: "responses", UsageSource: "upstream", StatusCode: 201, Streaming: true, OutputTokens: 10, TotalTokens: 50, FirstTokenMS: &firstTokenTwo, DurationMS: 1300, CreatedAt: now.Add(-time.Hour)}, + {RequestID: "failed", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "grok-primary", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 500, Streaming: true, OutputTokens: 100, TotalTokens: 10, DurationMS: 550, CreatedAt: now.Add(-2 * time.Hour)}, + {RequestID: "outside", ClientKeyID: 1, ModelRouteID: 1, Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, TotalTokens: 999, CreatedAt: now.Add(-25 * time.Hour)}, + } + for index := range audits { + if err := database.db.WithContext(ctx).Create(&audits[index]).Error; err != nil { + t.Fatal(err) + } + } + + boundaries := testDashboardBoundaries(now.Add(-24*time.Hour), 2*time.Hour, 12) + snapshot, err := NewDashboardRepository(database).Snapshot(ctx, testDashboardWindow(boundaries), now) + if err != nil { + t.Fatal(err) + } + if snapshot.Resources.ActiveAccounts != 1 || snapshot.Resources.TotalAccounts != 3 || snapshot.Resources.BuildAccounts != 3 || snapshot.Resources.WebAccounts != 0 || snapshot.Resources.ConsoleAccounts != 0 || snapshot.Resources.EnabledModels != 1 || snapshot.Resources.TotalModels != 2 || snapshot.Resources.ActiveClientKeys != 1 || snapshot.Resources.TotalClientKeys != 2 { + t.Fatalf("resources = %#v", snapshot.Resources) + } + if snapshot.Usage.Requests != 3 || snapshot.Usage.SuccessfulRequests != 2 || snapshot.Usage.FailedRequests != 1 || snapshot.Usage.Tokens != 160 { + t.Fatalf("usage = %#v", snapshot.Usage) + } + if snapshot.Usage.FirstTokenSamples != 2 || snapshot.Usage.FirstTokenTotalMS != 400 || snapshot.Usage.ThroughputSamples != 2 || snapshot.Usage.ThroughputTokens != 100 || snapshot.Usage.GenerationTotalMS != 2000 { + t.Fatalf("performance usage = %#v", snapshot.Usage) + } + var bucketRequests int64 + var bucketTokens int64 + bucketsByIndex := make(map[int]dashboardBucketSummary) + for _, bucket := range snapshot.Buckets { + bucketRequests += bucket.Requests + bucketTokens += bucket.Tokens + bucketsByIndex[bucket.Index] = dashboardBucketSummary{Requests: bucket.Requests, Tokens: bucket.Tokens} + } + if bucketRequests != 3 || bucketTokens != 160 { + t.Fatalf("buckets = %#v", snapshot.Buckets) + } + if bucketsByIndex[0] != (dashboardBucketSummary{Requests: 1, Tokens: 100}) || bucketsByIndex[11] != (dashboardBucketSummary{Requests: 2, Tokens: 60}) { + t.Fatalf("bucket distribution = %#v", bucketsByIndex) + } + if len(snapshot.TopModels) != 3 || snapshot.TopModels[0].Model != "grok-primary" || snapshot.TopModels[0].Requests != 2 || snapshot.TopModels[0].Tokens != 110 || snapshot.TopModels[2].Model != "enabled" || snapshot.TopModels[2].Requests != 0 { + t.Fatalf("top models = %#v", snapshot.TopModels) + } + if len(snapshot.Providers) != 2 || snapshot.Providers[0].Provider != "grok_build" || snapshot.Providers[0].Requests != 2 || snapshot.Providers[1].Provider != "grok_web" || snapshot.Providers[1].Requests != 1 { + t.Fatalf("providers = %#v", snapshot.Providers) + } + var activityRequests int64 + for _, bucket := range snapshot.ActivityBuckets { + activityRequests += bucket.Requests + } + if activityRequests != 3 { + t.Fatalf("activity buckets = %#v", snapshot.ActivityBuckets) + } +} + +func BenchmarkDashboardUsageAggregate(b *testing.B) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(b.TempDir(), "dashboard-benchmark.db")) + if err != nil { + b.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + b.Fatal(err) + } + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + audits := make([]requestAuditModel, 50_000) + for index := range audits { + firstTokenMS := int64(100 + index%900) + audits[index] = requestAuditModel{ + RequestID: fmt.Sprintf("benchmark-%d", index), ClientKeyID: 1, ModelRouteID: 1, + Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, + Streaming: true, InputTokens: 1000, OutputTokens: 100, TotalTokens: 1100, + FirstTokenMS: &firstTokenMS, DurationMS: firstTokenMS + 2000, CreatedAt: now.Add(-time.Duration(index%3600) * time.Second), + } + } + if err := database.db.WithContext(ctx).CreateInBatches(audits, 100).Error; err != nil { + b.Fatal(err) + } + start := now.Add(-24 * time.Hour) + legacySelect := "COUNT(*) AS requests, COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) AS successful_requests, COALESCE(SUM(CASE WHEN status_code < 200 OR status_code >= 300 THEN 1 ELSE 0 END), 0) AS failed_requests, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, COALESCE(SUM(total_tokens), 0) AS tokens, COALESCE(SUM(CASE WHEN cost_in_usd_ticks > 0 THEN cost_in_usd_ticks ELSE estimated_cost_in_usd_ticks END), 0) AS billed_cost_usd_ticks" + b.Run("legacy", func(b *testing.B) { + for b.Loop() { + var usage dashboardBenchmarkUsage + if err := database.db.WithContext(ctx).Model(&requestAuditModel{}).Select(legacySelect).Where("created_at >= ? AND created_at < ?", start, now.Add(time.Second)).Scan(&usage).Error; err != nil { + b.Fatal(err) + } + } + }) + b.Run("performance", func(b *testing.B) { + for b.Loop() { + var usage dashboardBenchmarkUsage + if err := database.db.WithContext(ctx).Model(&requestAuditModel{}).Select(dashboardUsageAggregateSelect).Where("created_at >= ? AND created_at < ?", start, now.Add(time.Second)).Scan(&usage).Error; err != nil { + b.Fatal(err) + } + } + }) +} + +type dashboardBenchmarkUsage struct { + Requests int64 + SuccessfulRequests int64 + FailedRequests int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + Tokens int64 + BilledCostUSDTicks int64 + FirstTokenSamples int64 + FirstTokenTotalMS int64 + ThroughputSamples int64 + ThroughputTokens int64 + GenerationTotalMS int64 +} + +func TestDashboardRepositoryRanksTopModels(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "dashboard-top-models.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + rows := []requestAuditModel{ + {RequestID: "primary-1", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "grok-primary", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, InputTokens: 80, CachedInputTokens: 20, OutputTokens: 20, ReasoningTokens: 5, TotalTokens: 100, CostInUSDTicks: 1_000_000_000, EstimatedCostInUSDTicks: 9_000_000_000, CreatedAt: now.Add(-3 * time.Hour)}, + {RequestID: "primary-2", ClientKeyID: 1, ModelRouteID: 1, ModelPublicID: "grok-primary", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, InputTokens: 30, CachedInputTokens: 5, OutputTokens: 20, ReasoningTokens: 10, TotalTokens: 50, EstimatedCostInUSDTicks: 2_000_000_000, CreatedAt: now.Add(-2 * time.Hour)}, + {RequestID: "fallback", ClientKeyID: 1, ModelRouteID: 1, ModelUpstreamModel: "grok-fallback", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, TotalTokens: 200, CostInUSDTicks: 4_000_000_000, CreatedAt: now.Add(-time.Hour)}, + } + if err := database.db.WithContext(ctx).Create(&rows).Error; err != nil { + t.Fatal(err) + } + boundaries := testDashboardBoundaries(now.Add(-24*time.Hour), time.Hour, 24) + snapshot, err := NewDashboardRepository(database).Snapshot(ctx, testDashboardWindow(boundaries), now) + if err != nil { + t.Fatal(err) + } + if len(snapshot.TopModels) != 2 || snapshot.TopModels[0].Model != "grok-fallback" || snapshot.TopModels[0].BilledCostUSDTicks != 4_000_000_000 || snapshot.TopModels[1].Model != "grok-primary" || snapshot.TopModels[1].Requests != 2 || snapshot.TopModels[1].InputTokens != 110 || snapshot.TopModels[1].CachedInputTokens != 25 || snapshot.TopModels[1].OutputTokens != 40 || snapshot.TopModels[1].ReasoningTokens != 15 || snapshot.TopModels[1].Tokens != 150 || snapshot.TopModels[1].BilledCostUSDTicks != 3_000_000_000 { + t.Fatalf("top models = %#v", snapshot.TopModels) + } +} + +func TestDashboardRepositoryFillsTopModelsFromEnabledRoutes(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "dashboard-enabled-models.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + for index := 0; index < 12; index++ { + name := fmt.Sprintf("grok-%02d", index) + route := modelRouteModel{PublicID: "Build/" + name, Provider: "grok_build", UpstreamModel: name, Capability: "responses", Enabled: true} + if err := database.db.WithContext(ctx).Create(&route).Error; err != nil { + t.Fatal(err) + } + } + duplicate := modelRouteModel{PublicID: "Web/grok-00", Provider: "grok_web", UpstreamModel: "grok-00", Capability: "responses", Enabled: true} + if err := database.db.WithContext(ctx).Create(&duplicate).Error; err != nil { + t.Fatal(err) + } + disabled := modelRouteModel{PublicID: "Build/disabled", Provider: "grok_build", UpstreamModel: "disabled", Capability: "responses", Enabled: false} + if err := database.db.WithContext(ctx).Create(&disabled).Error; err != nil { + t.Fatal(err) + } + + boundaries := testDashboardBoundaries(now.Add(-24*time.Hour), time.Hour, 24) + snapshot, err := NewDashboardRepository(database).Snapshot(ctx, testDashboardWindow(boundaries), now) + if err != nil { + t.Fatal(err) + } + if len(snapshot.TopModels) != 10 { + t.Fatalf("top models count = %d, want 10: %#v", len(snapshot.TopModels), snapshot.TopModels) + } + for index, item := range snapshot.TopModels { + want := fmt.Sprintf("grok-%02d", index) + if item.Model != want || item.Requests != 0 || item.Tokens != 0 || item.BilledCostUSDTicks != 0 { + t.Fatalf("top model %d = %#v, want zero-usage %q", index, item, want) + } + } +} + +type dashboardBucketSummary struct { + Requests int64 + Tokens int64 +} + +func timePointer(value time.Time) *time.Time { return &value } + +func testDashboardBoundaries(start time.Time, step time.Duration, count int) []time.Time { + values := make([]time.Time, count+1) + for index := range values { + values[index] = start.Add(time.Duration(index) * step) + } + return values +} + +func testDashboardWindow(boundaries []time.Time) repository.DashboardSnapshotWindow { + return repository.DashboardSnapshotWindow{ + BucketBoundaries: boundaries, + ActivityBoundaries: boundaries, + } +} diff --git a/backend/internal/infra/persistence/relational/database.go b/backend/internal/infra/persistence/relational/database.go new file mode 100644 index 0000000000000000000000000000000000000000..902d105a55a4ff9b3f52a179f660b3ee6a29eec8 --- /dev/null +++ b/backend/internal/infra/persistence/relational/database.go @@ -0,0 +1,94 @@ +package relational + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + glebarezsqlite "github.com/glebarez/sqlite" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Database 持有关系型数据库连接和各仓储实现共享的 GORM 实例。 +type Database struct { + db *gorm.DB + dialect string +} + +func (d *Database) Stats() sql.DBStats { + if d == nil { + return sql.DBStats{} + } + sqlDB, err := d.db.DB() + if err != nil { + return sql.DBStats{} + } + return sqlDB.Stats() +} + +func (d *Database) Dialect() string { + if d == nil { + return "" + } + return d.dialect +} + +// OpenSQLite 打开纯 Go SQLite 数据库并启用 WAL、外键与 busy timeout。 +// 显式事务使用 IMMEDIATE,避免并发读后写事务在锁升级时直接返回 SQLITE_BUSY。 +func OpenSQLite(ctx context.Context, path string) (*Database, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("创建数据库目录: %w", err) + } + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate", path) + db, err := gorm.Open(glebarezsqlite.Open(dsn), gormConfig()) + if err != nil { + return nil, fmt.Errorf("打开 SQLite: %w", err) + } + return configureDatabase(ctx, db, "sqlite", 16, 16) +} + +// OpenPostgres 打开 PostgreSQL 数据库并配置连接池。 +func OpenPostgres(ctx context.Context, dsn string, maxOpenConns, maxIdleConns int) (*Database, error) { + db, err := gorm.Open(postgres.Open(dsn), gormConfig()) + if err != nil { + return nil, fmt.Errorf("打开 PostgreSQL: %w", err) + } + return configureDatabase(ctx, db, "postgres", maxOpenConns, maxIdleConns) +} + +func gormConfig() *gorm.Config { + return &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + TranslateError: true, + NowFunc: func() time.Time { return time.Now().UTC() }, + } +} + +func configureDatabase(ctx context.Context, db *gorm.DB, dialect string, maxOpenConns, maxIdleConns int) (*Database, error) { + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(maxIdleConns) + sqlDB.SetConnMaxLifetime(time.Hour) + if err := sqlDB.PingContext(ctx); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("连接 %s: %w", dialect, err) + } + return &Database{db: db, dialect: dialect}, nil +} + +// Close 关闭底层数据库连接。 +func (d *Database) Close() error { + sqlDB, err := d.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} diff --git a/backend/internal/infra/persistence/relational/egress_operations_test.go b/backend/internal/infra/persistence/relational/egress_operations_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c6581f5683b11162bd45d22314e40ed2211ce8ed --- /dev/null +++ b/backend/internal/infra/persistence/relational/egress_operations_test.go @@ -0,0 +1,1078 @@ +package relational + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestEgressOperationsAutoAssignRespectsNodeCapacity(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + first := createHealthyEgressNode(t, ctx, nodes, cipher, "first", 1) + second := createHealthyEgressNode(t, ctx, nodes, cipher, "second", 1) + created := []account.Credential{ + createEgressOperationsAccount(t, ctx, accounts, "one"), + createEgressOperationsAccount(t, ctx, accounts, "two"), + createEgressOperationsAccount(t, ctx, accounts, "three"), + } + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.RebalanceAccounts(ctx, true, false, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 2 || result.Unplaced != 1 || result.Rebalanced != 0 { + t.Fatalf("rebalance result = %#v", result) + } + + assigned := make(map[uint64]int) + for _, value := range created { + actual, err := accounts.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if actual.EgressNodeID != 0 { + if actual.EgressAssignmentMode != account.EgressAssignmentAuto { + t.Fatalf("account %d assignment mode = %q", actual.ID, actual.EgressAssignmentMode) + } + assigned[actual.EgressNodeID]++ + } + } + if assigned[first.ID] != 1 || assigned[second.ID] != 1 { + t.Fatalf("capacity assignments = %#v", assigned) + } +} + +func TestEgressOperationsAutoAssignMovesAccountOffUnhealthyNode(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + unhealthy := createHealthyEgressNode(t, ctx, nodes, cipher, "unhealthy", 0) + healthy := createHealthyEgressNode(t, ctx, nodes, cipher, "healthy", 0) + credential := createEgressOperationsAccount(t, ctx, accounts, "recover") + old := time.Now().UTC().Add(-10 * time.Minute) + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{credential.ID}, &unhealthy.ID, account.EgressAssignmentAuto, old); err != nil { + t.Fatal(err) + } + unhealthy.ProbeStatus = egress.ProbeStatusUnhealthy + if _, err := nodes.UpdateEgressNode(ctx, unhealthy); err != nil { + t.Fatal(err) + } + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.RebalanceAccounts(ctx, true, false, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 0 || result.Rebalanced != 1 || result.Unplaced != 0 { + t.Fatalf("rebalance result = %#v", result) + } + actual, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if actual.EgressNodeID != healthy.ID || actual.EgressAssignmentMode != account.EgressAssignmentAuto { + t.Fatalf("unhealthy assignment was not repaired: %#v", actual) + } +} + +func TestEgressOperationsAutoAssignRepairsExistingCapacityOverflow(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + limited := createHealthyEgressNode(t, ctx, nodes, cipher, "limited", 1) + available := createHealthyEgressNode(t, ctx, nodes, cipher, "available", 100) + credentials := []account.Credential{ + createEgressOperationsAccount(t, ctx, accounts, "limited-one"), + createEgressOperationsAccount(t, ctx, accounts, "limited-two"), + createEgressOperationsAccount(t, ctx, accounts, "available-one"), + } + old := time.Now().UTC().Add(-10 * time.Minute) + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{credentials[0].ID, credentials[1].ID}, &limited.ID, account.EgressAssignmentAuto, old); err != nil { + t.Fatal(err) + } + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{credentials[2].ID}, &available.ID, account.EgressAssignmentAuto, old); err != nil { + t.Fatal(err) + } + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.RebalanceAccounts(ctx, true, false, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 0 || result.Rebalanced != 1 || result.Unplaced != 0 { + t.Fatalf("rebalance result = %#v", result) + } + loads := map[uint64]int{} + for _, credential := range credentials { + actual, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + loads[actual.EgressNodeID]++ + } + if loads[limited.ID] != 1 || loads[available.ID] != 2 { + t.Fatalf("capacity repair loads = %#v", loads) + } +} + +func TestEgressOperationsBalanceNeverMovesManualBindings(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + first := createHealthyEgressNode(t, ctx, nodes, cipher, "first", 0) + second := createHealthyEgressNode(t, ctx, nodes, cipher, "second", 0) + manual := []account.Credential{ + createEgressOperationsAccount(t, ctx, accounts, "manual-one"), + createEgressOperationsAccount(t, ctx, accounts, "manual-two"), + } + automatic := []account.Credential{ + createEgressOperationsAccount(t, ctx, accounts, "auto-one"), + createEgressOperationsAccount(t, ctx, accounts, "auto-two"), + } + old := time.Now().UTC().Add(-10 * time.Minute) + manualIDs := []uint64{manual[0].ID, manual[1].ID} + automaticIDs := []uint64{automatic[0].ID, automatic[1].ID} + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, manualIDs, &first.ID, account.EgressAssignmentManual, old); err != nil { + t.Fatal(err) + } + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, automaticIDs, &first.ID, account.EgressAssignmentAuto, old); err != nil { + t.Fatal(err) + } + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.RebalanceAccounts(ctx, true, true, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 0 || result.Rebalanced != 2 || result.Unplaced != 0 { + t.Fatalf("rebalance result = %#v", result) + } + for _, value := range manual { + actual, err := accounts.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if actual.EgressNodeID != first.ID || actual.EgressAssignmentMode != account.EgressAssignmentManual { + t.Fatalf("manual account moved: %#v", actual) + } + } + for _, value := range automatic { + actual, err := accounts.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if actual.EgressNodeID != second.ID || actual.EgressAssignmentMode != account.EgressAssignmentAuto { + t.Fatalf("automatic account was not balanced: %#v", actual) + } + } +} + +func TestEgressOperationsSharesWebNodeCapacityAcrossProviders(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNodeForScope(t, ctx, nodes, cipher, "shared-web", egress.ScopeWeb, 1) + web := createEgressOperationsProviderAccount(t, ctx, accounts, account.ProviderWeb, "web") + console := createEgressOperationsProviderAccount(t, ctx, accounts, account.ProviderConsole, "console") + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.RebalanceAccounts(ctx, true, false, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 1 || result.Unplaced != 1 { + t.Fatalf("rebalance result = %#v", result) + } + storedWeb, err := accounts.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + storedConsole, err := accounts.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if storedWeb.EgressNodeID != node.ID || storedConsole.EgressNodeID != 0 { + t.Fatalf("shared node capacity web=%d console=%d", storedWeb.EgressNodeID, storedConsole.EgressNodeID) + } +} + +func TestEgressOperationsRejectsIncompatibleNodeScopeChangeWithBindings(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNode(t, ctx, nodes, cipher, "bound-build", 0) + credential := createEgressOperationsAccount(t, ctx, accounts, "bound-build") + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + if _, err := service.AssignAccounts(ctx, node.ID, account.ProviderBuild, []uint64{credential.ID}, account.EgressAssignmentManual); err != nil { + t.Fatal(err) + } + + _, err := service.Update(ctx, node.ID, egressapp.Input{Name: node.Name, Scope: egress.ScopeWeb, Enabled: true}) + if !errors.Is(err, egressapp.ErrInvalidInput) { + t.Fatalf("incompatible scope update error = %v", err) + } + stored, err := nodes.GetEgressNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if stored.Scope != egress.ScopeBuild { + t.Fatalf("persisted scope = %q", stored.Scope) + } +} + +func TestEgressOperationsAllowsCompatibleNodeScopeChangeWithBindings(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNodeForScope(t, ctx, nodes, cipher, "bound-console", egress.ScopeWeb, 0) + credential := createEgressOperationsProviderAccount(t, ctx, accounts, account.ProviderConsole, "bound-console") + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + if _, err := service.AssignAccounts(ctx, node.ID, account.ProviderConsole, []uint64{credential.ID}, account.EgressAssignmentManual); err != nil { + t.Fatal(err) + } + + updated, err := service.Update(ctx, node.ID, egressapp.Input{Name: node.Name, Scope: egress.ScopeConsole, Enabled: true}) + if err != nil { + t.Fatal(err) + } + if updated.Scope != egress.ScopeConsole { + t.Fatalf("updated scope = %q", updated.Scope) + } +} + +func TestEgressOperationsRejectsIncompatibleSourceScopeChangeWithBindings(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + url := "https://subscription.example/proxies" + source, err := service.CreateSource(ctx, egressapp.SubscriptionSourceInput{Name: "bound-source", Scope: egress.ScopeBuild, Enabled: true, URL: &url}) + if err != nil { + t.Fatal(err) + } + proxy, err := cipher.Encrypt("http://source-node.example:8080") + if err != nil { + t.Fatal(err) + } + if _, err := nodes.UpsertEgressNodesFromSource(ctx, source.ID, []egress.Node{{ + Name: "source-node", Scope: egress.ScopeBuild, Enabled: true, SourceID: source.ID, + SourceKey: "source-node", EncryptedProxyURL: proxy, + }}); err != nil { + t.Fatal(err) + } + listed, err := nodes.ListEgressNodes(ctx, egress.ScopeBuild, repository.SortQuery{}) + if err != nil || len(listed) != 1 { + t.Fatalf("source nodes = %#v, err = %v", listed, err) + } + credential := createEgressOperationsAccount(t, ctx, accounts, "bound-source") + if _, err := service.AssignAccounts(ctx, listed[0].ID, account.ProviderBuild, []uint64{credential.ID}, account.EgressAssignmentManual); err != nil { + t.Fatal(err) + } + + _, err = service.UpdateSource(ctx, source.ID, egressapp.SubscriptionSourceInput{Name: source.Name, Scope: egress.ScopeWeb, Enabled: true}) + if !errors.Is(err, egressapp.ErrInvalidInput) { + t.Fatalf("incompatible source scope update error = %v", err) + } + stored, err := nodes.GetEgressSource(ctx, source.ID) + if err != nil { + t.Fatal(err) + } + if stored.Scope != egress.ScopeBuild { + t.Fatalf("persisted source scope = %q", stored.Scope) + } +} + +func TestEgressOperationsAutoAssignSkipsCoolingFixedNode(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + cooling := createHealthyEgressNode(t, ctx, nodes, cipher, "cooling", 0) + available := createHealthyEgressNode(t, ctx, nodes, cipher, "available", 0) + cooldownUntil := time.Now().UTC().Add(time.Hour) + cooling.CooldownUntil = &cooldownUntil + if _, err := nodes.UpdateEgressNode(ctx, cooling); err != nil { + t.Fatal(err) + } + credential := createEgressOperationsAccount(t, ctx, accounts, "cooldown") + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.RebalanceAccounts(ctx, true, false, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 1 || result.Unplaced != 0 { + t.Fatalf("rebalance result = %#v", result) + } + stored, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.EgressNodeID != available.ID { + t.Fatalf("assigned node = %d, want %d (cooling node %d)", stored.EgressNodeID, available.ID, cooling.ID) + } +} + +func TestEgressOperationsAssignsManyAccountsToOneManualNode(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNode(t, ctx, nodes, cipher, "manual", 0) + first := createEgressOperationsAccount(t, ctx, accounts, "first") + second := createEgressOperationsAccount(t, ctx, accounts, "second") + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + result, err := service.AssignAccounts(ctx, node.ID, account.ProviderBuild, []uint64{first.ID, second.ID}, account.EgressAssignmentManual) + if err != nil { + t.Fatal(err) + } + if result.Assigned != 2 { + t.Fatalf("assigned = %#v", result) + } + for _, value := range []account.Credential{first, second} { + actual, err := accounts.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if actual.EgressNodeID != node.ID || actual.EgressAssignmentMode != account.EgressAssignmentManual { + t.Fatalf("manual binding = %#v", actual) + } + } +} + +func TestEgressOperationsBatchDeleteClearsAccountBindings(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + first := createHealthyEgressNode(t, ctx, nodes, cipher, "delete-first", 0) + second := createHealthyEgressNode(t, ctx, nodes, cipher, "delete-second", 0) + firstAccount := createEgressOperationsAccount(t, ctx, accounts, "delete-first-account") + secondAccount := createEgressOperationsAccount(t, ctx, accounts, "delete-second-account") + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{firstAccount.ID}, &first.ID, account.EgressAssignmentManual, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{secondAccount.ID}, &second.ID, account.EgressAssignmentAuto, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + deleted, err := service.DeleteMany(ctx, []uint64{first.ID, second.ID, first.ID}) + if err != nil { + t.Fatal(err) + } + if deleted != 2 { + t.Fatalf("deleted = %d", deleted) + } + for _, value := range []account.Credential{firstAccount, secondAccount} { + stored, err := accounts.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if stored.EgressNodeID != 0 || stored.EgressAssignmentMode != "" || stored.EgressAssignedAt != nil { + t.Fatalf("account binding not cleared: %#v", stored) + } + } +} + +func TestEgressOperationsBatchUpdatesEnabledState(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + first := createHealthyEgressNode(t, ctx, nodes, cipher, "batch-enable-first", 0) + second := createHealthyEgressNode(t, ctx, nodes, cipher, "batch-enable-second", 0) + second.Enabled = false + if _, err := nodes.UpdateEgressNode(ctx, second); err != nil { + t.Fatal(err) + } + + service := egressapp.NewService(nodes, cipher, "test-browser") + updated, err := service.UpdateManyEnabled(ctx, []uint64{first.ID, second.ID, first.ID}, false) + if err != nil || updated != 1 { + t.Fatalf("disable updated = %d, err = %v", updated, err) + } + updated, err = service.UpdateManyEnabled(ctx, []uint64{first.ID, second.ID}, true) + if err != nil || updated != 2 { + t.Fatalf("enable updated = %d, err = %v", updated, err) + } + for _, id := range []uint64{first.ID, second.ID} { + stored, err := nodes.GetEgressNode(ctx, id) + if err != nil { + t.Fatal(err) + } + if !stored.Enabled { + t.Fatalf("node %d remained disabled", id) + } + } +} + +func TestEgressOperationsBatchDisableRejectsFixedFallback(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + fallbackNode := createHealthyEgressNode(t, ctx, nodes, cipher, "batch-fallback", 0) + otherNode := createHealthyEgressNode(t, ctx, nodes, cipher, "batch-other", 0) + service := egressapp.NewService(nodes, cipher, "test-browser") + if _, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeFixed, NodeID: fallbackNode.ID}, + }, + }); err != nil { + t.Fatal(err) + } + + if _, err := service.UpdateManyEnabled(ctx, []uint64{otherNode.ID, fallbackNode.ID}, false); err == nil { + t.Fatal("expected fixed fallback disable to fail") + } + for _, id := range []uint64{fallbackNode.ID, otherNode.ID} { + stored, err := nodes.GetEgressNode(ctx, id) + if err != nil { + t.Fatal(err) + } + if !stored.Enabled { + t.Fatalf("node %d changed despite rejected batch", id) + } + } +} + +func TestEgressOperationsConfigRechecksFallbackNodeInsideTransaction(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + fallbackNode := createHealthyEgressNode(t, ctx, nodes, cipher, "transaction-fallback", 0) + + if _, err := nodes.UpdateEgressNodesEnabled(ctx, []uint64{fallbackNode.ID}, false); err != nil { + t.Fatal(err) + } + config := egress.DefaultOperationsConfig() + config.Fallbacks[egress.ScopeBuild] = egress.FallbackConfig{Mode: egress.FallbackModeFixed, NodeID: fallbackNode.ID} + config.UpdatedAt = time.Now().UTC() + if _, err := nodes.SaveEgressOperationsConfig(ctx, config); !errors.Is(err, repository.ErrEgressFallbackInUse) { + t.Fatalf("disabled fallback save error = %v", err) + } + stored, err := nodes.GetEgressOperationsConfig(ctx) + if err != nil { + t.Fatal(err) + } + if fallback := stored.FallbackFor(egress.ScopeBuild); fallback.Mode != egress.FallbackModeNone || fallback.NodeID != 0 { + t.Fatalf("rejected fallback was persisted: %#v", fallback) + } +} + +func TestEgressOperationsCleanupDeletesOnlyDualStackUnhealthyNodes(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + + source, err := nodes.CreateEgressSource(ctx, egress.SubscriptionSource{ + Name: "cleanup-source", Scope: egress.ScopeBuild, Enabled: true, RefreshIntervalSeconds: 900, + }) + if err != nil { + t.Fatal(err) + } + manual := createHealthyEgressNode(t, ctx, nodes, cipher, "cleanup-manual", 0) + managed := createHealthyEgressNode(t, ctx, nodes, cipher, "cleanup-managed", 0) + managed.SourceID = source.ID + managed.SourceKey = "managed" + if managed, err = nodes.UpdateEgressNode(ctx, managed); err != nil { + t.Fatal(err) + } + v4Healthy := createHealthyEgressNode(t, ctx, nodes, cipher, "cleanup-v4-healthy", 0) + v6Healthy := createHealthyEgressNode(t, ctx, nodes, cipher, "cleanup-v6-healthy", 0) + untested := createHealthyEgressNode(t, ctx, nodes, cipher, "cleanup-untested", 0) + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + if _, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeFixed, NodeID: manual.ID}, + }, + }); err != nil { + t.Fatal(err) + } + + setEgressProbeFamilies(t, ctx, nodes, manual, egress.ProbeStatusUnhealthy, egress.ProbeStatusUnhealthy) + setEgressProbeFamilies(t, ctx, nodes, managed, egress.ProbeStatusUnhealthy, egress.ProbeStatusUnhealthy) + setEgressProbeFamilies(t, ctx, nodes, v4Healthy, egress.ProbeStatusHealthy, egress.ProbeStatusUnhealthy) + setEgressProbeFamilies(t, ctx, nodes, v6Healthy, egress.ProbeStatusUnhealthy, egress.ProbeStatusHealthy) + setEgressProbeFamilies(t, ctx, nodes, untested, egress.ProbeStatusUnknown, egress.ProbeStatusUnknown) + + firstAccount := createEgressOperationsAccount(t, ctx, accounts, "cleanup-manual-account") + secondAccount := createEgressOperationsAccount(t, ctx, accounts, "cleanup-managed-account") + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{firstAccount.ID}, &manual.ID, account.EgressAssignmentManual, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{secondAccount.ID}, &managed.ID, account.EgressAssignmentAuto, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + preview, err := service.PreviewUnhealthyCleanup(ctx) + if err != nil { + t.Fatal(err) + } + if preview.Nodes != 2 || preview.BoundAccounts != 2 || preview.SubscriptionManaged != 1 { + t.Fatalf("cleanup preview = %#v", preview) + } + deleted, err := service.DeleteUnhealthy(ctx) + if err != nil { + t.Fatal(err) + } + if deleted != 2 { + t.Fatalf("deleted = %d", deleted) + } + for _, id := range []uint64{manual.ID, managed.ID} { + if _, err := nodes.GetEgressNode(ctx, id); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("dual-stack unhealthy node %d still exists: %v", id, err) + } + } + for _, id := range []uint64{v4Healthy.ID, v6Healthy.ID, untested.ID} { + if _, err := nodes.GetEgressNode(ctx, id); err != nil { + t.Fatalf("preserved node %d: %v", id, err) + } + } + for _, value := range []account.Credential{firstAccount, secondAccount} { + stored, err := accounts.Get(ctx, value.ID) + if err != nil { + t.Fatal(err) + } + if stored.EgressNodeID != 0 || stored.EgressAssignmentMode != "" || stored.EgressAssignedAt != nil { + t.Fatalf("account binding not cleared: %#v", stored) + } + } + config, err := nodes.GetEgressOperationsConfig(ctx) + if err != nil { + t.Fatal(err) + } + if fallback := config.FallbackFor(egress.ScopeBuild); fallback.Mode != egress.FallbackModeNone || fallback.NodeID != 0 { + t.Fatalf("cleanup fallback reference = %#v", fallback) + } +} + +func TestEgressOperationsRejectsManualBindingsToDisabledOrDirectNodes(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + credential := createEgressOperationsAccount(t, ctx, accounts, "manual-validation") + direct, err := nodes.CreateEgressNode(ctx, egress.Node{Name: "direct", Scope: egress.ScopeBuild, Enabled: true}) + if err != nil { + t.Fatal(err) + } + disabled := createHealthyEgressNode(t, ctx, nodes, cipher, "disabled", 0) + disabled.Enabled = false + if _, err := nodes.UpdateEgressNode(ctx, disabled); err != nil { + t.Fatal(err) + } + + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + for _, nodeID := range []uint64{direct.ID, disabled.ID} { + if _, err := service.AssignAccounts(ctx, nodeID, account.ProviderBuild, []uint64{credential.ID}, account.EgressAssignmentManual); err == nil { + t.Fatalf("node %d was accepted for a manual proxy binding", nodeID) + } + } +} + +func TestEgressOperationsPersistsProbeResult(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNode(t, ctx, nodes, cipher, "probe", 0) + probedAt := time.Now().UTC().Truncate(time.Millisecond) + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + service.SetNodeProber(egressProbeStub{result: egress.ProbeResult{ + Status: egress.ProbeStatusHealthy, TestedAt: probedAt, LatencyMS: 42, ExitIP: "1.1.1.1", + Provider: egress.ProbeProviderCloudflare, + IPv4: egress.ProbeFamilyResult{Status: egress.ProbeStatusHealthy, TestedAt: probedAt, LatencyMS: 40, ExitIP: "1.1.1.1"}, + IPv6: egress.ProbeFamilyResult{Status: egress.ProbeStatusHealthy, TestedAt: probedAt, LatencyMS: 42, ExitIP: "2606:4700:4700::1111"}, + }}) + + result, err := service.TestNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if result.Status != egress.ProbeStatusHealthy || result.ExitIP != "1.1.1.1" { + t.Fatalf("probe result = %#v", result) + } + stored, err := nodes.GetEgressNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if stored.ProbeStatus != egress.ProbeStatusHealthy || stored.ProbeProvider != egress.ProbeProviderCloudflare || stored.ProbeLatencyMS != 42 || stored.ExitIP != "1.1.1.1" || stored.LastProbedAt == nil { + t.Fatalf("stored probe = %#v", stored) + } + if stored.IPv4Probe.ExitIP != "1.1.1.1" || stored.IPv6Probe.ExitIP != "2606:4700:4700::1111" || stored.IPv6Probe.Status != egress.ProbeStatusHealthy { + t.Fatalf("stored family probes = ipv4:%#v ipv6:%#v", stored.IPv4Probe, stored.IPv6Probe) + } + updatedConfig, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeProvider: egress.ProbeProviderIPInfo, ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + }) + if err != nil || updatedConfig.ProbeProvider != egress.ProbeProviderIPInfo { + t.Fatalf("updated probe provider = %#v, err=%v", updatedConfig, err) + } + stored, err = nodes.GetEgressNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if stored.ProbeProvider != egress.ProbeProviderCloudflare { + t.Fatalf("stored result provider changed with future probe configuration: %q", stored.ProbeProvider) + } +} + +func TestEgressOperationsDiscardsProbeAfterProxyConfigurationChanges(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNode(t, ctx, nodes, cipher, "probe-stale", 0) + replacementProxy, err := cipher.Encrypt("http://replacement.example:8080") + if err != nil { + t.Fatal(err) + } + probedAt := time.Now().UTC().Truncate(time.Millisecond) + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + service.SetNodeProber(mutatingEgressProbeStub{ + repository: nodes, + replacement: replacementProxy, + result: egress.ProbeResult{ + Status: egress.ProbeStatusHealthy, TestedAt: probedAt, LatencyMS: 10, ExitIP: "198.51.100.20", Provider: egress.ProbeProviderCloudflare, + IPv4: egress.ProbeFamilyResult{Status: egress.ProbeStatusHealthy, TestedAt: probedAt, LatencyMS: 10, ExitIP: "198.51.100.20"}, + IPv6: egress.ProbeFamilyResult{Status: egress.ProbeStatusUnhealthy, TestedAt: probedAt, LatencyMS: 10, Error: "代理连接失败"}, + }, + }) + + _, err = service.TestNode(ctx, node.ID) + if !errors.Is(err, egressapp.ErrProbeStale) { + t.Fatalf("stale probe error = %v", err) + } + stored, err := nodes.GetEgressNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if stored.EncryptedProxyURL != replacementProxy || stored.ProbeStatus != egress.ProbeStatusUnknown || stored.ProbeProvider != "" || stored.IPv4Probe.Status != egress.ProbeStatusUnknown { + t.Fatalf("stale probe overwrote edited node: %#v", stored) + } +} + +func TestEgressOperationsReturnsPersistedUnhealthyProbeAsResult(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + node := createHealthyEgressNode(t, ctx, nodes, cipher, "unreachable", 0) + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + service.SetNodeProber(egressProbeStub{err: errors.New("connection refused")}) + + result, err := service.TestNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if result.Status != egress.ProbeStatusUnhealthy || result.Error == "" { + t.Fatalf("failed probe result = %#v", result) + } + stored, err := nodes.GetEgressNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if stored.ProbeStatus != egress.ProbeStatusUnhealthy || stored.ProbeError == "" || stored.LastProbedAt == nil { + t.Fatalf("stored failed probe = %#v", stored) + } +} + +func TestEgressOperationsStoresSubscriptionURLEncrypted(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + url := "https://subscription.example/proxies?token=subscription-token" + interval := 900 + capacity := 3 + created, err := service.CreateSource(ctx, egressapp.SubscriptionSourceInput{ + Name: "source", Scope: egress.ScopeBuild, Enabled: true, URL: &url, + RefreshIntervalSeconds: &interval, DefaultAccountCapacity: &capacity, + }) + if err != nil { + t.Fatal(err) + } + if !created.URLConfigured || created.DefaultAccountCapacity != capacity { + t.Fatalf("public source = %#v", created) + } + stored, err := nodes.GetEgressSource(ctx, created.ID) + if err != nil { + t.Fatal(err) + } + if stored.EncryptedURL == url || strings.Contains(stored.EncryptedURL, "subscription-token") { + t.Fatalf("subscription URL stored in plaintext: %q", stored.EncryptedURL) + } +} + +func TestEgressOperationsSubscriptionImportCountsOnlyNewNodes(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + source, err := nodes.CreateEgressSource(ctx, egress.SubscriptionSource{ + Name: "count-source", Scope: egress.ScopeBuild, Enabled: true, EncryptedURL: "encrypted", + RefreshIntervalSeconds: 900, + }) + if err != nil { + t.Fatal(err) + } + proxy, err := cipher.Encrypt("http://count-source.example:8080") + if err != nil { + t.Fatal(err) + } + values := []egress.Node{{ + Name: "count-node", Scope: egress.ScopeBuild, Enabled: true, SourceID: source.ID, + SourceKey: "count-node", EncryptedProxyURL: proxy, + }} + firstValues := append(append([]egress.Node(nil), values...), values[0]) + first, err := nodes.UpsertEgressNodesFromSource(ctx, source.ID, firstValues) + if err != nil { + t.Fatal(err) + } + second, err := nodes.UpsertEgressNodesFromSource(ctx, source.ID, values) + if err != nil { + t.Fatal(err) + } + if first != 1 || second != 0 { + t.Fatalf("import counts = first %d, second %d", first, second) + } +} + +func TestEgressOperationsMaintenanceRetriesAssignmentAfterFailure(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := &retryingAssignmentRepository{AccountRepository: NewAccountRepository(database), failNext: true} + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + if _, err := nodes.SaveEgressOperationsConfig(ctx, egress.OperationsConfig{ + ProbeIntervalSeconds: 900, AutoAssignEnabled: true, AssignmentIntervalSeconds: 3600, + }); err != nil { + t.Fatal(err) + } + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + + if err := service.RunMaintenance(ctx); err == nil { + t.Fatal("first maintenance run unexpectedly succeeded") + } + firstCalls := accounts.assignmentCalls + if firstCalls != 1 { + t.Fatalf("first assignment calls = %d", firstCalls) + } + if err := service.RunMaintenance(ctx); err != nil { + t.Fatalf("second maintenance run = %v", err) + } + if accounts.assignmentCalls <= firstCalls { + t.Fatalf("assignment was not retried: calls = %d", accounts.assignmentCalls) + } +} + +func TestEgressOperationsConfigPersistsFixedFallback(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + fixed := createHealthyEgressNode(t, ctx, nodes, cipher, "fixed-fallback", 0) + service := egressapp.NewService(nodes, cipher, "test-browser") + + saved, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeProvider: egress.ProbeProviderCloudflare, ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeFixed, NodeID: fixed.ID}, + egress.ScopeWeb: {Mode: egress.FallbackModeDirect}, + }, + }) + if err != nil { + t.Fatal(err) + } + if fallback := saved.FallbackFor(egress.ScopeBuild); fallback.Mode != egress.FallbackModeFixed || fallback.NodeID != fixed.ID { + t.Fatalf("saved Build fallback = %#v", fallback) + } + if saved.ProbeProvider != egress.ProbeProviderCloudflare { + t.Fatalf("saved probe provider = %q", saved.ProbeProvider) + } + if fallback := saved.FallbackFor(egress.ScopeWeb); fallback.Mode != egress.FallbackModeDirect || fallback.NodeID != 0 { + t.Fatalf("saved Web fallback = %#v", fallback) + } + if fallback := saved.FallbackFor(egress.ScopeConsole); fallback.Mode != egress.FallbackModeNone || fallback.NodeID != 0 { + t.Fatalf("default Console fallback = %#v", fallback) + } + + stored, err := nodes.GetEgressOperationsConfig(ctx) + if err != nil { + t.Fatal(err) + } + if fallback := stored.FallbackFor(egress.ScopeBuild); fallback.Mode != egress.FallbackModeFixed || fallback.NodeID != fixed.ID { + t.Fatalf("stored Build fallback = %#v", fallback) + } + if fallback := stored.FallbackFor(egress.ScopeWeb); fallback.Mode != egress.FallbackModeDirect || fallback.NodeID != 0 { + t.Fatalf("stored Web fallback = %#v", fallback) + } + if stored.ProbeProvider != egress.ProbeProviderCloudflare { + t.Fatalf("stored probe provider = %q", stored.ProbeProvider) + } + updated, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeNone}, + }, + }) + if err != nil { + t.Fatal(err) + } + if fallback := updated.FallbackFor(egress.ScopeWeb); fallback.Mode != egress.FallbackModeDirect { + t.Fatalf("sparse update reset Web fallback = %#v", fallback) + } +} + +func TestFixedFallbackReferenceIsProtectedAndClearedOnDelete(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + fixed := createHealthyEgressNode(t, ctx, nodes, cipher, "fixed-fallback", 0) + service := egressapp.NewService(nodes, cipher, "test-browser") + if _, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeFixed, NodeID: fixed.ID}, + }, + }); err != nil { + t.Fatal(err) + } + if _, err := service.Update(ctx, fixed.ID, egressapp.Input{ + Name: fixed.Name, Scope: fixed.Scope, Enabled: false, + }); !errors.Is(err, egressapp.ErrInvalidInput) || !strings.Contains(err.Error(), "固定回退") { + t.Fatalf("disable fixed fallback error = %v", err) + } + if err := service.Delete(ctx, fixed.ID); err != nil { + t.Fatal(err) + } + stored, err := nodes.GetEgressOperationsConfig(ctx) + if err != nil { + t.Fatal(err) + } + if fallback := stored.FallbackFor(egress.ScopeBuild); fallback.Mode != egress.FallbackModeNone || fallback.NodeID != 0 { + t.Fatalf("deleted fallback reference = %#v", fallback) + } +} + +func TestSubscriptionSyncClearsStaleFixedFallbackReference(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + source, err := nodes.CreateEgressSource(ctx, egress.SubscriptionSource{ + Name: "source", Scope: egress.ScopeBuild, Enabled: true, RefreshIntervalSeconds: 900, + }) + if err != nil { + t.Fatal(err) + } + encryptedProxy, err := cipher.Encrypt("http://subscription.example:8080") + if err != nil { + t.Fatal(err) + } + if _, err := nodes.UpsertEgressNodesFromSource(ctx, source.ID, []egress.Node{{ + Name: "subscription", Scope: egress.ScopeBuild, Enabled: true, SourceID: source.ID, + SourceKey: "one", EncryptedProxyURL: encryptedProxy, Health: 1, + }}); err != nil { + t.Fatal(err) + } + listed, err := nodes.ListEgressNodes(ctx, egress.ScopeBuild, repository.SortQuery{}) + if err != nil || len(listed) != 1 { + t.Fatalf("subscription nodes=%#v err=%v", listed, err) + } + service := egressapp.NewService(nodes, cipher, "test-browser") + if _, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeFixed, NodeID: listed[0].ID}, + }, + }); err != nil { + t.Fatal(err) + } + if _, err := nodes.UpsertEgressNodesFromSource(ctx, source.ID, nil); err != nil { + t.Fatal(err) + } + stored, err := nodes.GetEgressOperationsConfig(ctx) + if err != nil { + t.Fatal(err) + } + if fallback := stored.FallbackFor(egress.ScopeBuild); fallback.Mode != egress.FallbackModeNone || fallback.NodeID != 0 { + t.Fatalf("stale subscription fallback reference = %#v", fallback) + } +} + +func TestEgressOperationsConfigRejectsUnsafeFixedFallback(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + nodes := NewEgressRepository(database) + cipher := egressOperationsCipher(t) + pool := createHealthyEgressNode(t, ctx, nodes, cipher, "pool-fallback", 0) + pool.ProxyPool = true + if _, err := nodes.UpdateEgressNode(ctx, pool); err != nil { + t.Fatal(err) + } + service := egressapp.NewService(nodes, cipher, "test-browser") + _, err := service.UpdateOperationsConfig(ctx, egressapp.OperationsConfigInput{ + ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[egress.Scope]egressapp.FallbackConfigInput{ + egress.ScopeBuild: {Mode: egress.FallbackModeFixed, NodeID: pool.ID}, + }, + }) + if !errors.Is(err, egressapp.ErrInvalidInput) || !strings.Contains(err.Error(), "代理池") { + t.Fatalf("pool fallback error = %v", err) + } +} + +type retryingAssignmentRepository struct { + *AccountRepository + failNext bool + assignmentCalls int +} + +func (r *retryingAssignmentRepository) ListEgressAssignments(ctx context.Context, provider account.Provider) ([]account.Credential, error) { + r.assignmentCalls++ + if r.failNext { + r.failNext = false + return nil, errors.New("temporary assignment failure") + } + return r.AccountRepository.ListEgressAssignments(ctx, provider) +} + +type egressProbeStub struct { + result egress.ProbeResult + err error +} + +type mutatingEgressProbeStub struct { + repository *EgressRepository + replacement string + result egress.ProbeResult +} + +func (stub mutatingEgressProbeStub) ProbeEgressNode(ctx context.Context, node egress.Node) (egress.ProbeResult, error) { + node.EncryptedProxyURL = stub.replacement + node.ProbeStatus = egress.ProbeStatusUnknown + node.LastProbedAt = nil + node.ProbeLatencyMS = 0 + node.ExitIP = "" + node.ProbeError = "" + node.ProbeProvider = "" + node.IPv4Probe = egress.ProbeFamilyResult{Status: egress.ProbeStatusUnknown} + node.IPv6Probe = egress.ProbeFamilyResult{Status: egress.ProbeStatusUnknown} + if _, err := stub.repository.UpdateEgressNode(ctx, node); err != nil { + return egress.ProbeResult{}, err + } + return stub.result, nil +} + +func (stub egressProbeStub) ProbeEgressNode(context.Context, egress.Node) (egress.ProbeResult, error) { + return stub.result, stub.err +} + +func egressOperationsCipher(t *testing.T) *security.Cipher { + t.Helper() + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + return cipher +} + +func createHealthyEgressNode(t *testing.T, ctx context.Context, repository *EgressRepository, cipher *security.Cipher, name string, capacity int) egress.Node { + return createHealthyEgressNodeForScope(t, ctx, repository, cipher, name, egress.ScopeBuild, capacity) +} + +func createHealthyEgressNodeForScope(t *testing.T, ctx context.Context, repository *EgressRepository, cipher *security.Cipher, name string, scope egress.Scope, capacity int) egress.Node { + t.Helper() + proxy, err := cipher.Encrypt("http://" + name + ".example:8080") + if err != nil { + t.Fatal(err) + } + probedAt := time.Now().UTC() + created, err := repository.CreateEgressNode(ctx, egress.Node{ + Name: name, Scope: scope, Enabled: true, EncryptedProxyURL: proxy, AccountCapacity: capacity, + Health: 1, ProbeStatus: egress.ProbeStatusHealthy, LastProbedAt: &probedAt, + }) + if err != nil { + t.Fatal(err) + } + return created +} + +func setEgressProbeFamilies(t *testing.T, ctx context.Context, repository *EgressRepository, node egress.Node, ipv4, ipv6 egress.ProbeStatus) { + t.Helper() + now := time.Now().UTC() + if err := repository.UpdateEgressNodeProbe(ctx, node.ID, node.EncryptedProxyURL, egress.ProbeResult{ + Status: egress.ProbeStatusUnhealthy, TestedAt: now, Error: "probe failed", + IPv4: egress.ProbeFamilyResult{Status: ipv4, TestedAt: now}, + IPv6: egress.ProbeFamilyResult{Status: ipv6, TestedAt: now}, + }); err != nil { + t.Fatal(err) + } +} + +func createEgressOperationsAccount(t *testing.T, ctx context.Context, repository *AccountRepository, sourceKey string) account.Credential { + return createEgressOperationsProviderAccount(t, ctx, repository, account.ProviderBuild, sourceKey) +} + +func createEgressOperationsProviderAccount(t *testing.T, ctx context.Context, repository *AccountRepository, provider account.Provider, sourceKey string) account.Credential { + t.Helper() + authType := account.AuthTypeOAuth + if provider != account.ProviderBuild { + authType = account.AuthTypeSSO + } + created, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: provider, AuthType: authType, Name: sourceKey, SourceKey: sourceKey, + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + return created +} diff --git a/backend/internal/infra/persistence/relational/egress_repository.go b/backend/internal/infra/persistence/relational/egress_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..b0da95dfcaf374e3c54f2cbb54526919c7223d5d --- /dev/null +++ b/backend/internal/infra/persistence/relational/egress_repository.go @@ -0,0 +1,834 @@ +package relational + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type EgressRepository struct{ db *Database } + +func NewEgressRepository(db *Database) *EgressRepository { return &EgressRepository{db: db} } + +func (r *EgressRepository) ListEgressNodes(ctx context.Context, scope egress.Scope, sort repository.SortQuery) ([]egress.Node, error) { + query := r.db.db.WithContext(ctx).Model(&egressNodeModel{}) + if scope != "" { + query = query.Where("scope = ?", scope) + } + var rows []egressNodeModel + query = applyStableSort(query, sort, map[string]sortSpec{ + "name": {expression: "LOWER(egress_nodes.name)"}, + "scope": {expression: "egress_nodes.scope"}, + "proxy": {expression: "CASE WHEN egress_nodes.encrypted_proxy_url <> '' THEN 0 ELSE 1 END"}, + "clearance": {expression: "CASE WHEN egress_nodes.encrypted_cloudflare_cookie <> '' THEN 0 ELSE 1 END"}, + "health": {expression: "egress_nodes.health", defaultDirection: repository.SortDescending}, + }, sortSpec{expression: "egress_nodes.scope"}, "egress_nodes.id") + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + values := make([]egress.Node, 0, len(rows)) + counts, err := r.assignedAccountCounts(ctx) + if err != nil { + return nil, err + } + for _, row := range rows { + value := toEgressDomain(row) + value.AssignedAccountCount = counts[value.ID] + values = append(values, value) + } + return values, nil +} + +func (r *EgressRepository) ListEgressNodePage(ctx context.Context, input repository.EgressNodeListQuery) ([]egress.Node, int64, error) { + query := r.db.db.WithContext(ctx).Model(&egressNodeModel{}) + if search := strings.TrimSpace(input.Page.Search); search != "" { + query = query.Where("LOWER(egress_nodes.name) LIKE ?", "%"+strings.ToLower(search)+"%") + } + if input.Filter.Scope != "" { + query = query.Where("egress_nodes.scope = ?", input.Filter.Scope) + } + if input.Filter.Enabled != nil { + query = query.Where("egress_nodes.enabled = ?", *input.Filter.Enabled) + } + if input.Filter.ProbeStatus != "" { + query = query.Where("egress_nodes.probe_status = ?", input.Filter.ProbeStatus) + } + switch input.Filter.Assignment { + case "bound": + query = query.Where("EXISTS (SELECT 1 FROM provider_accounts account WHERE account.egress_node_id = egress_nodes.id)") + case "unbound": + query = query.Where("NOT EXISTS (SELECT 1 FROM provider_accounts account WHERE account.egress_node_id = egress_nodes.id)") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + query = applyStableSort(query, input.Page.Sort, map[string]sortSpec{ + "name": {expression: "LOWER(egress_nodes.name)"}, + "scope": {expression: "egress_nodes.scope"}, + "proxy": {expression: "CASE WHEN egress_nodes.encrypted_proxy_url <> '' THEN 0 ELSE 1 END"}, + "clearance": {expression: "CASE WHEN egress_nodes.encrypted_cloudflare_cookie <> '' THEN 0 ELSE 1 END"}, + "health": {expression: "egress_nodes.health", defaultDirection: repository.SortDescending}, + }, sortSpec{expression: "egress_nodes.scope"}, "egress_nodes.id") + var rows []egressNodeModel + if err := query.Offset(input.Page.Offset).Limit(input.Page.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + if len(rows) == 0 { + return []egress.Node{}, total, nil + } + + ids := make([]uint64, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + counts, err := r.assignedAccountCountsForNodes(ctx, ids) + if err != nil { + return nil, 0, err + } + values := make([]egress.Node, 0, len(rows)) + for _, row := range rows { + value := toEgressDomain(row) + value.AssignedAccountCount = counts[value.ID] + values = append(values, value) + } + return values, total, nil +} + +func (r *EgressRepository) GetEgressNode(ctx context.Context, id uint64) (egress.Node, error) { + var row egressNodeModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return egress.Node{}, mapError(err) + } + // AssignedAccountCount is management-list metadata. Keep point lookups lean: + // this method is also used by the bound-account inference hot path. + return toEgressDomain(row), nil +} + +func (r *EgressRepository) CreateEgressNode(ctx context.Context, value egress.Node) (egress.Node, error) { + row := fromEgressDomain(value) + if err := r.db.db.WithContext(ctx).Create(&row).Error; err != nil { + return egress.Node{}, mapError(err) + } + return toEgressDomain(row), nil +} + +func (r *EgressRepository) CreateEgressNodes(ctx context.Context, values []egress.Node) (int, error) { + if len(values) == 0 { + return 0, nil + } + rows := make([]egressNodeModel, 0, len(values)) + for _, value := range values { + rows = append(rows, fromEgressDomain(value)) + } + if err := r.db.db.WithContext(ctx).CreateInBatches(&rows, 100).Error; err != nil { + return 0, mapError(err) + } + return len(rows), nil +} + +func (r *EgressRepository) UpdateEgressNode(ctx context.Context, value egress.Node) (egress.Node, error) { + row := fromEgressDomain(value) + result := r.db.db.WithContext(ctx).Save(&row) + if result.Error != nil { + return egress.Node{}, mapError(result.Error) + } + if result.RowsAffected == 0 { + return egress.Node{}, repository.ErrNotFound + } + return toEgressDomain(row), nil +} + +func (r *EgressRepository) UpdateEgressNodesEnabled(ctx context.Context, ids []uint64, enabled bool) (int, error) { + if len(ids) == 0 { + return 0, nil + } + if enabled { + result := r.db.db.WithContext(ctx).Model(&egressNodeModel{}). + Where("id IN ? AND enabled <> ?", ids, true). + Updates(map[string]any{"enabled": true, "updated_at": time.Now().UTC()}) + return int(result.RowsAffected), mapError(result.Error) + } + var updated int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + config, err := lockEgressOperationsConfig(tx) + if err != nil { + return err + } + var lockedIDs []uint64 + if err := tx.Model(&egressNodeModel{}).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id IN ?", ids).Order("id ASC").Pluck("id", &lockedIDs).Error; err != nil { + return err + } + if configReferencesAnyFallbackNode(config, lockedIDs) { + return repository.ErrEgressFallbackInUse + } + result := tx.Model(&egressNodeModel{}). + Where("id IN ? AND enabled <> ?", lockedIDs, false). + Updates(map[string]any{"enabled": false, "updated_at": time.Now().UTC()}) + updated = result.RowsAffected + return result.Error + }) + return int(updated), mapError(err) +} + +func (r *EgressRepository) UpdateEgressNodeClearance(ctx context.Context, id uint64, encryptedCookie, userAgent, fingerprint, bindingFingerprint string, refreshedAt time.Time) error { + result := r.db.db.WithContext(ctx).Model(&egressNodeModel{}).Where("id = ?", id).Updates(map[string]any{ + "encrypted_cloudflare_cookie": encryptedCookie, "user_agent": userAgent, + "clearance_fingerprint": fingerprint, "clearance_refreshed_at": refreshedAt, + "clearance_binding_fingerprint": bindingFingerprint, + "last_error": "", "updated_at": time.Now().UTC(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func (r *EgressRepository) UpdateEgressNodeHealth(ctx context.Context, id uint64, health float64, failureCount int, cooldownUntil *time.Time, lastError string) error { + result := r.db.db.WithContext(ctx).Model(&egressNodeModel{}).Where("id = ?", id).Updates(map[string]any{ + "health": health, "failure_count": failureCount, "cooldown_until": cooldownUntil, "last_error": lastError, "updated_at": time.Now().UTC(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func (r *EgressRepository) UpdateEgressNodeLastError(ctx context.Context, id uint64, lastError string) error { + result := r.db.db.WithContext(ctx).Model(&egressNodeModel{}).Where("id = ?", id).Updates(map[string]any{ + "last_error": lastError, "updated_at": time.Now().UTC(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// UpdateEgressNodeProbe persists the result of a direct proxy probe without +// affecting request health or Cloudflare clearance state. +func (r *EgressRepository) UpdateEgressNodeProbe(ctx context.Context, id uint64, expectedEncryptedProxyURL string, value egress.ProbeResult) error { + result := r.db.db.WithContext(ctx).Model(&egressNodeModel{}). + Where("id = ? AND encrypted_proxy_url = ?", id, expectedEncryptedProxyURL). + Updates(map[string]any{ + "probe_status": value.Status, "last_probed_at": value.TestedAt.UTC(), + "probe_latency_ms": value.LatencyMS, "exit_ip": value.ExitIP, "probe_error": value.Error, "probe_provider": storedProbeProvider(value.Provider), + "ipv4_probe_status": normalizedProbeStatus(value.IPv4.Status), "ipv4_last_probed_at": probeTestedAt(value.IPv4), + "ipv4_probe_latency_ms": value.IPv4.LatencyMS, "ipv4_exit_ip": value.IPv4.ExitIP, "ipv4_probe_error": value.IPv4.Error, + "ipv6_probe_status": normalizedProbeStatus(value.IPv6.Status), "ipv6_last_probed_at": probeTestedAt(value.IPv6), + "ipv6_probe_latency_ms": value.IPv6.LatencyMS, "ipv6_exit_ip": value.IPv6.ExitIP, "ipv6_probe_error": value.IPv6.Error, + "updated_at": time.Now().UTC(), + }) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + var count int64 + if err := r.db.db.WithContext(ctx).Model(&egressNodeModel{}).Where("id = ?", id).Count(&count).Error; err != nil { + return mapError(err) + } + if count == 0 { + return repository.ErrNotFound + } + return repository.ErrConflict + } + return nil +} + +func (r *EgressRepository) ListDueEgressNodes(ctx context.Context, now time.Time, interval time.Duration, limit int) ([]egress.Node, error) { + if limit < 1 { + return []egress.Node{}, nil + } + if interval <= 0 { + interval = 15 * time.Minute + } + var rows []egressNodeModel + if err := r.db.db.WithContext(ctx). + Where("enabled = ? AND encrypted_proxy_url <> '' AND (last_probed_at IS NULL OR last_probed_at <= ?)", true, now.UTC().Add(-interval)). + Order("last_probed_at ASC NULLS FIRST, id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapError(err) + } + values := make([]egress.Node, 0, len(rows)) + for _, row := range rows { + values = append(values, toEgressDomain(row)) + } + return values, nil +} + +func (r *EgressRepository) ListEgressSources(ctx context.Context) ([]egress.SubscriptionSource, error) { + var rows []egressSubscriptionSourceModel + if err := r.db.db.WithContext(ctx).Order("name ASC, id ASC").Find(&rows).Error; err != nil { + return nil, mapError(err) + } + values := make([]egress.SubscriptionSource, 0, len(rows)) + for _, row := range rows { + values = append(values, toEgressSubscriptionSourceDomain(row)) + } + return values, nil +} + +func (r *EgressRepository) ListDueEgressSources(ctx context.Context, now time.Time, limit int) ([]egress.SubscriptionSource, error) { + if limit < 1 { + return []egress.SubscriptionSource{}, nil + } + var rows []egressSubscriptionSourceModel + if err := r.db.db.WithContext(ctx). + Where("enabled = ? AND encrypted_url <> '' AND (next_sync_at IS NULL OR next_sync_at <= ?)", true, now.UTC()). + Order("next_sync_at ASC NULLS FIRST, id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapError(err) + } + values := make([]egress.SubscriptionSource, 0, len(rows)) + for _, row := range rows { + values = append(values, toEgressSubscriptionSourceDomain(row)) + } + return values, nil +} + +func (r *EgressRepository) GetEgressSource(ctx context.Context, id uint64) (egress.SubscriptionSource, error) { + var row egressSubscriptionSourceModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return egress.SubscriptionSource{}, mapError(err) + } + return toEgressSubscriptionSourceDomain(row), nil +} + +func (r *EgressRepository) CreateEgressSource(ctx context.Context, value egress.SubscriptionSource) (egress.SubscriptionSource, error) { + row := fromEgressSubscriptionSourceDomain(value) + if err := r.db.db.WithContext(ctx).Create(&row).Error; err != nil { + return egress.SubscriptionSource{}, mapError(err) + } + return toEgressSubscriptionSourceDomain(row), nil +} + +func (r *EgressRepository) UpdateEgressSource(ctx context.Context, value egress.SubscriptionSource) (egress.SubscriptionSource, error) { + row := fromEgressSubscriptionSourceDomain(value) + result := r.db.db.WithContext(ctx).Save(&row) + if result.Error != nil { + return egress.SubscriptionSource{}, mapError(result.Error) + } + if result.RowsAffected == 0 { + return egress.SubscriptionSource{}, repository.ErrNotFound + } + return toEgressSubscriptionSourceDomain(row), nil +} + +// DeleteEgressSource keeps already imported nodes intact. They become normal +// manually managed nodes rather than silently losing proxy configuration. +func (r *EgressRepository) DeleteEgressSource(ctx context.Context, id uint64) error { + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&egressNodeModel{}).Where("source_id = ?", id).Updates(map[string]any{"source_id": nil, "source_key": ""}).Error; err != nil { + return mapError(err) + } + result := tx.Delete(&egressSubscriptionSourceModel{}, id) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil + }) +} + +func (r *EgressRepository) UpdateEgressSourceSync(ctx context.Context, id uint64, syncedAt, nextSyncAt time.Time, imported int, lastError string) error { + result := r.db.db.WithContext(ctx).Model(&egressSubscriptionSourceModel{}).Where("id = ?", id).Updates(map[string]any{ + "last_synced_at": syncedAt.UTC(), "next_sync_at": nextSyncAt.UTC(), "last_sync_imported": imported, + "last_sync_error": lastError, "updated_at": time.Now().UTC(), + }) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// UpsertEgressNodesFromSource replaces the active representation of a source +// atomically. Stale nodes are disabled instead of deleted so auto-assigned +// accounts can be moved by the next balancing cycle without touching manual +// bindings. +func (r *EgressRepository) UpsertEgressNodesFromSource(ctx context.Context, sourceID uint64, values []egress.Node) (int, error) { + if sourceID == 0 { + return 0, errors.New("subscription source id is required") + } + returned := 0 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existingKeys []string + if err := tx.Model(&egressNodeModel{}).Where("source_id = ?", sourceID).Pluck("source_key", &existingKeys).Error; err != nil { + return mapError(err) + } + existing := make(map[string]struct{}, len(existingKeys)) + for _, key := range existingKeys { + existing[key] = struct{}{} + } + keys := make([]string, 0, len(values)) + for _, value := range values { + if value.SourceID != sourceID || value.SourceKey == "" { + return errors.New("invalid subscription node") + } + row := fromEgressDomain(value) + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_id"}, {Name: "source_key"}}, + DoUpdates: clause.Assignments(map[string]any{ + "name": row.Name, "scope": row.Scope, "enabled": row.Enabled, "proxy_pool": row.ProxyPool, + "account_capacity": row.AccountCapacity, "encrypted_proxy_url": row.EncryptedProxyURL, + "updated_at": time.Now().UTC(), + }), + }).Create(&row).Error; err != nil { + return mapError(err) + } + keys = append(keys, value.SourceKey) + if _, found := existing[value.SourceKey]; !found { + returned++ + existing[value.SourceKey] = struct{}{} + } + } + stale := tx.Model(&egressNodeModel{}).Where("source_id = ?", sourceID) + if len(keys) > 0 { + stale = stale.Where("source_key NOT IN ?", keys) + } + if err := stale.Updates(map[string]any{ + "enabled": false, "probe_status": string(egress.ProbeStatusUnknown), "probe_error": "subscription entry removed", "probe_provider": "", + "ipv4_probe_status": string(egress.ProbeStatusUnknown), "ipv4_last_probed_at": nil, "ipv4_probe_latency_ms": 0, "ipv4_exit_ip": "", "ipv4_probe_error": "subscription entry removed", + "ipv6_probe_status": string(egress.ProbeStatusUnknown), "ipv6_last_probed_at": nil, "ipv6_probe_latency_ms": 0, "ipv6_exit_ip": "", "ipv6_probe_error": "subscription entry removed", + "updated_at": time.Now().UTC(), + }).Error; err != nil { + return mapError(err) + } + if err := clearInvalidEgressFallbackNodeReferences(tx); err != nil { + return err + } + return nil + }) + return returned, err +} + +func (r *EgressRepository) GetEgressOperationsConfig(ctx context.Context) (egress.OperationsConfig, error) { + var row egressOperationsConfigModel + if err := r.db.db.WithContext(ctx).First(&row, 1).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return egress.DefaultOperationsConfig(), nil + } + return egress.OperationsConfig{}, mapError(err) + } + return toEgressOperationsConfigDomain(row), nil +} + +func (r *EgressRepository) SaveEgressOperationsConfig(ctx context.Context, value egress.OperationsConfig) (egress.OperationsConfig, error) { + row := fromEgressOperationsConfigDomain(value) + row.ID = 1 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if _, err := lockEgressOperationsConfig(tx); err != nil { + return err + } + if err := validateLockedEgressFallbackNodes(tx, row); err != nil { + return err + } + return tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error + }) + if err != nil { + return egress.OperationsConfig{}, mapError(err) + } + return toEgressOperationsConfigDomain(row), nil +} + +func lockEgressOperationsConfig(tx *gorm.DB) (egressOperationsConfigModel, error) { + defaults := fromEgressOperationsConfigDomain(egress.DefaultOperationsConfig()) + defaults.ID = 1 + defaults.UpdatedAt = time.Now().UTC() + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil { + return egressOperationsConfigModel{}, err + } + var row egressOperationsConfigModel + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, 1).Error; err != nil { + return egressOperationsConfigModel{}, err + } + return row, nil +} + +func configReferencesAnyFallbackNode(config egressOperationsConfigModel, ids []uint64) bool { + selected := make(map[uint64]struct{}, len(ids)) + for _, id := range ids { + selected[id] = struct{}{} + } + for _, fallback := range []struct { + mode string + nodeID uint64 + }{ + {config.BuildFallbackMode, config.BuildFallbackNodeID}, + {config.WebFallbackMode, config.WebFallbackNodeID}, + {config.ConsoleFallbackMode, config.ConsoleFallbackNodeID}, + {config.WebAssetFallbackMode, config.WebAssetFallbackNodeID}, + } { + if egress.FallbackMode(fallback.mode).Normalized() != egress.FallbackModeFixed { + continue + } + if _, exists := selected[fallback.nodeID]; exists { + return true + } + } + return false +} + +func validateLockedEgressFallbackNodes(tx *gorm.DB, config egressOperationsConfigModel) error { + fallbacks := []struct { + scope egress.Scope + mode string + nodeID uint64 + }{ + {egress.ScopeBuild, config.BuildFallbackMode, config.BuildFallbackNodeID}, + {egress.ScopeWeb, config.WebFallbackMode, config.WebFallbackNodeID}, + {egress.ScopeConsole, config.ConsoleFallbackMode, config.ConsoleFallbackNodeID}, + {egress.ScopeWebAsset, config.WebAssetFallbackMode, config.WebAssetFallbackNodeID}, + } + ids := make([]uint64, 0, len(fallbacks)) + for _, fallback := range fallbacks { + if egress.FallbackMode(fallback.mode).Normalized() == egress.FallbackModeFixed && fallback.nodeID != 0 { + ids = append(ids, fallback.nodeID) + } + } + if len(ids) == 0 { + return nil + } + var rows []egressNodeModel + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil { + return err + } + byID := make(map[uint64]egressNodeModel, len(rows)) + for _, row := range rows { + byID[row.ID] = row + } + for _, fallback := range fallbacks { + if egress.FallbackMode(fallback.mode).Normalized() != egress.FallbackModeFixed { + continue + } + node, exists := byID[fallback.nodeID] + if !exists || !node.Enabled || node.ProxyPool || node.EncryptedProxyURL == "" || !egress.SupportsScope(egress.Scope(node.Scope), fallback.scope) { + return repository.ErrEgressFallbackInUse + } + } + return nil +} + +func (r *EgressRepository) DeleteEgressNode(ctx context.Context, id uint64) error { + return r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&accountModel{}).Where("egress_node_id = ?", id).Updates(map[string]any{ + "egress_node_id": nil, "egress_assignment_mode": "", "egress_assigned_at": nil, + }) + if result.Error != nil { + return result.Error + } + if err := clearEgressFallbackNodeReferences(tx, []uint64{id}); err != nil { + return err + } + result = tx.Delete(&egressNodeModel{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil + }) +} + +// DeleteEgressNodes deletes a selection atomically. Account bindings are +// removed first so no account can retain a reference to a deleted node. +func (r *EgressRepository) DeleteEgressNodes(ctx context.Context, ids []uint64) (int, error) { + if len(ids) == 0 { + return 0, nil + } + var deleted int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + deleted, err = deleteEgressNodeIDs(tx, ids) + return err + }) + return int(deleted), mapError(err) +} + +func (r *EgressRepository) PreviewUnhealthyEgressNodes(ctx context.Context) (repository.EgressNodeCleanupPreview, error) { + var result repository.EgressNodeCleanupPreview + if err := r.unhealthyEgressNodes(ctx).Count(&result.Nodes).Error; err != nil { + return result, mapError(err) + } + if err := r.unhealthyEgressNodes(ctx).Where("source_id IS NOT NULL").Count(&result.SubscriptionManaged).Error; err != nil { + return result, mapError(err) + } + subquery := r.db.db.WithContext(ctx).Model(&egressNodeModel{}).Select("id"). + Where("ipv4_probe_status = ? AND ipv6_probe_status = ?", egress.ProbeStatusUnhealthy, egress.ProbeStatusUnhealthy) + if err := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("egress_node_id IN (?)", subquery).Count(&result.BoundAccounts).Error; err != nil { + return result, mapError(err) + } + return result, nil +} + +func (r *EgressRepository) unhealthyEgressNodes(ctx context.Context) *gorm.DB { + return r.db.db.WithContext(ctx).Model(&egressNodeModel{}). + Where("ipv4_probe_status = ? AND ipv6_probe_status = ?", egress.ProbeStatusUnhealthy, egress.ProbeStatusUnhealthy) +} + +func (r *EgressRepository) DeleteUnhealthyEgressNodes(ctx context.Context) ([]uint64, error) { + ids := make([]uint64, 0) + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.Model(&egressNodeModel{}). + Where("ipv4_probe_status = ? AND ipv6_probe_status = ?", egress.ProbeStatusUnhealthy, egress.ProbeStatusUnhealthy). + Order("id ASC") + if tx.Dialector.Name() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Pluck("id", &ids).Error; err != nil { + return err + } + _, err := deleteEgressNodeIDs(tx, ids) + return err + }) + return ids, mapError(err) +} + +func deleteEgressNodeIDs(tx *gorm.DB, ids []uint64) (int64, error) { + var deleted int64 + for start := 0; start < len(ids); start += 500 { + end := min(start+500, len(ids)) + batch := ids[start:end] + if result := tx.Model(&accountModel{}).Where("egress_node_id IN ?", batch).Updates(map[string]any{ + "egress_node_id": nil, "egress_assignment_mode": "", "egress_assigned_at": nil, + }); result.Error != nil { + return deleted, result.Error + } + if err := clearEgressFallbackNodeReferences(tx, batch); err != nil { + return deleted, err + } + result := tx.Where("id IN ?", batch).Delete(&egressNodeModel{}) + if result.Error != nil { + return deleted, result.Error + } + deleted += result.RowsAffected + } + return deleted, nil +} + +func clearEgressFallbackNodeReferences(tx *gorm.DB, ids []uint64) error { + if len(ids) == 0 { + return nil + } + for _, columns := range [][2]string{ + {"build_fallback_mode", "build_fallback_node_id"}, + {"web_fallback_mode", "web_fallback_node_id"}, + {"console_fallback_mode", "console_fallback_node_id"}, + {"web_asset_fallback_mode", "web_asset_fallback_node_id"}, + } { + if err := tx.Model(&egressOperationsConfigModel{}). + Where("id = ? AND "+columns[1]+" IN ?", 1, ids). + Updates(map[string]any{columns[0]: string(egress.FallbackModeNone), columns[1]: 0}).Error; err != nil { + return err + } + } + return nil +} + +func clearInvalidEgressFallbackNodeReferences(tx *gorm.DB) error { + var config egressOperationsConfigModel + if err := tx.First(&config, 1).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + for _, fallback := range []struct { + scope egress.Scope + mode string + nodeID uint64 + modeColumn, idColumn string + }{ + {egress.ScopeBuild, config.BuildFallbackMode, config.BuildFallbackNodeID, "build_fallback_mode", "build_fallback_node_id"}, + {egress.ScopeWeb, config.WebFallbackMode, config.WebFallbackNodeID, "web_fallback_mode", "web_fallback_node_id"}, + {egress.ScopeConsole, config.ConsoleFallbackMode, config.ConsoleFallbackNodeID, "console_fallback_mode", "console_fallback_node_id"}, + {egress.ScopeWebAsset, config.WebAssetFallbackMode, config.WebAssetFallbackNodeID, "web_asset_fallback_mode", "web_asset_fallback_node_id"}, + } { + if egress.FallbackMode(fallback.mode).Normalized() != egress.FallbackModeFixed { + continue + } + var node egressNodeModel + err := tx.First(&node, fallback.nodeID).Error + valid := err == nil && node.Enabled && !node.ProxyPool && node.EncryptedProxyURL != "" && egress.SupportsScope(egress.Scope(node.Scope), fallback.scope) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if valid { + continue + } + if err := tx.Model(&egressOperationsConfigModel{}).Where("id = ?", 1). + Updates(map[string]any{fallback.modeColumn: string(egress.FallbackModeNone), fallback.idColumn: 0}).Error; err != nil { + return err + } + } + return nil +} + +func (r *EgressRepository) assignedAccountCounts(ctx context.Context) (map[uint64]int, error) { + return r.assignedAccountCountsForNodes(ctx, nil) +} + +func (r *EgressRepository) assignedAccountCountsForNodes(ctx context.Context, nodeIDs []uint64) (map[uint64]int, error) { + type row struct { + NodeID uint64 + Count int + } + var rows []row + query := r.db.db.WithContext(ctx).Model(&accountModel{}). + Select("egress_node_id AS node_id, COUNT(*) AS count"). + Where("egress_node_id IS NOT NULL") + if nodeIDs != nil { + if len(nodeIDs) == 0 { + return map[uint64]int{}, nil + } + query = query.Where("egress_node_id IN ?", nodeIDs) + } + if err := query.Group("egress_node_id").Scan(&rows).Error; err != nil { + return nil, err + } + result := make(map[uint64]int, len(rows)) + for _, row := range rows { + result[row.NodeID] = row.Count + } + return result, nil +} + +func toEgressDomain(row egressNodeModel) egress.Node { + return egress.Node{ + ID: row.ID, Name: row.Name, Scope: egress.Scope(row.Scope), Enabled: row.Enabled, ProxyPool: row.ProxyPool, + SourceID: valueEgressNodeID(row.SourceID), SourceKey: row.SourceKey, AccountCapacity: row.AccountCapacity, + EncryptedProxyURL: row.EncryptedProxyURL, UserAgent: row.UserAgent, EncryptedCloudflareCookie: row.EncryptedCloudflareCookie, + ClearanceRefreshedAt: row.ClearanceRefreshedAt, ClearanceFingerprint: row.ClearanceFingerprint, + ClearanceBindingFingerprint: row.ClearanceBindingFingerprint, + Health: row.Health, FailureCount: row.FailureCount, CooldownUntil: row.CooldownUntil, LastError: row.LastError, + ProbeStatus: egress.ProbeStatus(row.ProbeStatus), LastProbedAt: row.LastProbedAt, ProbeLatencyMS: row.ProbeLatencyMS, ExitIP: row.ExitIP, ProbeError: row.ProbeError, + ProbeProvider: storedProbeProvider(egress.ProbeProvider(row.ProbeProvider)), + IPv4Probe: probeFamilyFromRow(row.IPv4ProbeStatus, row.IPv4LastProbedAt, row.IPv4ProbeLatencyMS, row.IPv4ExitIP, row.IPv4ProbeError), + IPv6Probe: probeFamilyFromRow(row.IPv6ProbeStatus, row.IPv6LastProbedAt, row.IPv6ProbeLatencyMS, row.IPv6ExitIP, row.IPv6ProbeError), + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } +} + +func fromEgressDomain(value egress.Node) egressNodeModel { + health := value.Health + if health == 0 && value.ID == 0 { + health = 1 + } + probeStatus := value.ProbeStatus + if !probeStatus.IsValid() { + probeStatus = egress.ProbeStatusUnknown + } + return egressNodeModel{ + ID: value.ID, Name: value.Name, Scope: string(value.Scope), Enabled: value.Enabled, ProxyPool: value.ProxyPool, + SourceID: egressNodeID(value.SourceID), SourceKey: value.SourceKey, AccountCapacity: value.AccountCapacity, + EncryptedProxyURL: value.EncryptedProxyURL, UserAgent: value.UserAgent, EncryptedCloudflareCookie: value.EncryptedCloudflareCookie, + ClearanceRefreshedAt: value.ClearanceRefreshedAt, ClearanceFingerprint: value.ClearanceFingerprint, + ClearanceBindingFingerprint: value.ClearanceBindingFingerprint, + Health: health, FailureCount: value.FailureCount, CooldownUntil: value.CooldownUntil, LastError: value.LastError, + ProbeStatus: string(probeStatus), LastProbedAt: value.LastProbedAt, ProbeLatencyMS: value.ProbeLatencyMS, ExitIP: value.ExitIP, ProbeError: value.ProbeError, + ProbeProvider: string(storedProbeProvider(value.ProbeProvider)), + IPv4ProbeStatus: string(normalizedProbeStatus(value.IPv4Probe.Status)), IPv4LastProbedAt: probeTestedAt(value.IPv4Probe), IPv4ProbeLatencyMS: value.IPv4Probe.LatencyMS, IPv4ExitIP: value.IPv4Probe.ExitIP, IPv4ProbeError: value.IPv4Probe.Error, + IPv6ProbeStatus: string(normalizedProbeStatus(value.IPv6Probe.Status)), IPv6LastProbedAt: probeTestedAt(value.IPv6Probe), IPv6ProbeLatencyMS: value.IPv6Probe.LatencyMS, IPv6ExitIP: value.IPv6Probe.ExitIP, IPv6ProbeError: value.IPv6Probe.Error, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func normalizedProbeStatus(value egress.ProbeStatus) egress.ProbeStatus { + if value.IsValid() { + return value + } + return egress.ProbeStatusUnknown +} + +func storedProbeProvider(value egress.ProbeProvider) egress.ProbeProvider { + if value.IsValid() { + return value + } + return "" +} + +func probeTestedAt(value egress.ProbeFamilyResult) *time.Time { + if value.TestedAt.IsZero() { + return nil + } + testedAt := value.TestedAt.UTC() + return &testedAt +} + +func probeFamilyFromRow(status string, testedAt *time.Time, latencyMS int, exitIP, probeError string) egress.ProbeFamilyResult { + result := egress.ProbeFamilyResult{ + Status: normalizedProbeStatus(egress.ProbeStatus(status)), LatencyMS: latencyMS, ExitIP: exitIP, Error: probeError, + } + if testedAt != nil { + result.TestedAt = testedAt.UTC() + } + return result +} + +func toEgressSubscriptionSourceDomain(row egressSubscriptionSourceModel) egress.SubscriptionSource { + return egress.SubscriptionSource{ + ID: row.ID, Name: row.Name, Scope: egress.Scope(row.Scope), Enabled: row.Enabled, EncryptedURL: row.EncryptedURL, + RefreshIntervalSeconds: row.RefreshIntervalSeconds, DefaultAccountCapacity: row.DefaultAccountCapacity, + LastSyncedAt: row.LastSyncedAt, NextSyncAt: row.NextSyncAt, LastSyncImported: row.LastSyncImported, LastSyncError: row.LastSyncError, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } +} + +func fromEgressSubscriptionSourceDomain(value egress.SubscriptionSource) egressSubscriptionSourceModel { + return egressSubscriptionSourceModel{ + ID: value.ID, Name: value.Name, Scope: string(value.Scope), Enabled: value.Enabled, EncryptedURL: value.EncryptedURL, + RefreshIntervalSeconds: value.RefreshIntervalSeconds, DefaultAccountCapacity: value.DefaultAccountCapacity, + LastSyncedAt: value.LastSyncedAt, NextSyncAt: value.NextSyncAt, LastSyncImported: value.LastSyncImported, LastSyncError: value.LastSyncError, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func toEgressOperationsConfigDomain(row egressOperationsConfigModel) egress.OperationsConfig { + return egress.OperationsConfig{ + ProbeProvider: egress.ProbeProvider(row.ProbeProvider).Normalized(), + ProbeIntervalSeconds: row.ProbeIntervalSeconds, AutoAssignEnabled: row.AutoAssignEnabled, AutoBalanceEnabled: row.AutoBalanceEnabled, + AssignmentIntervalSeconds: row.AssignmentIntervalSeconds, + Fallbacks: map[egress.Scope]egress.FallbackConfig{ + egress.ScopeBuild: {Mode: egress.FallbackMode(row.BuildFallbackMode).Normalized(), NodeID: row.BuildFallbackNodeID}, + egress.ScopeWeb: {Mode: egress.FallbackMode(row.WebFallbackMode).Normalized(), NodeID: row.WebFallbackNodeID}, + egress.ScopeConsole: {Mode: egress.FallbackMode(row.ConsoleFallbackMode).Normalized(), NodeID: row.ConsoleFallbackNodeID}, + egress.ScopeWebAsset: {Mode: egress.FallbackMode(row.WebAssetFallbackMode).Normalized(), NodeID: row.WebAssetFallbackNodeID}, + }, + UpdatedAt: row.UpdatedAt, + } +} + +func fromEgressOperationsConfigDomain(value egress.OperationsConfig) egressOperationsConfigModel { + buildFallback := value.FallbackFor(egress.ScopeBuild) + webFallback := value.FallbackFor(egress.ScopeWeb) + consoleFallback := value.FallbackFor(egress.ScopeConsole) + webAssetFallback := value.FallbackFor(egress.ScopeWebAsset) + return egressOperationsConfigModel{ + ID: 1, ProbeProvider: string(value.ProbeProvider.Normalized()), ProbeIntervalSeconds: value.ProbeIntervalSeconds, AutoAssignEnabled: value.AutoAssignEnabled, + AutoBalanceEnabled: value.AutoBalanceEnabled, AssignmentIntervalSeconds: value.AssignmentIntervalSeconds, + BuildFallbackMode: string(buildFallback.Mode), BuildFallbackNodeID: buildFallback.NodeID, + WebFallbackMode: string(webFallback.Mode), WebFallbackNodeID: webFallback.NodeID, + ConsoleFallbackMode: string(consoleFallback.Mode), ConsoleFallbackNodeID: consoleFallback.NodeID, + WebAssetFallbackMode: string(webAssetFallback.Mode), WebAssetFallbackNodeID: webAssetFallback.NodeID, + UpdatedAt: value.UpdatedAt, + } +} diff --git a/backend/internal/infra/persistence/relational/egress_sort_test.go b/backend/internal/infra/persistence/relational/egress_sort_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b3bb1ea765403abeb01b3606861ba5dbfa197083 --- /dev/null +++ b/backend/internal/infra/persistence/relational/egress_sort_test.go @@ -0,0 +1,226 @@ +package relational + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type legacyAllEgressNode struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Scope string `gorm:"size:32;not null"` +} + +func (legacyAllEgressNode) TableName() string { return "egress_nodes" } + +type legacyEgressNodeWithoutProxyPool struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:160;not null"` + Scope string `gorm:"size:32;not null"` + Enabled bool `gorm:"not null;default:true"` + EncryptedProxyURL string `gorm:"type:text;not null;default:''"` + UserAgent string `gorm:"size:512;not null;default:''"` + EncryptedCloudflareCookie string `gorm:"type:text;not null;default:''"` + ClearanceRefreshedAt *time.Time + ClearanceFingerprint string `gorm:"size:64;not null;default:''"` + ClearanceBindingFingerprint string `gorm:"size:64;not null;default:''"` + Health float64 `gorm:"not null;default:1"` + FailureCount int `gorm:"not null;default:0"` + CooldownUntil *time.Time + LastError string `gorm:"size:512"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (legacyEgressNodeWithoutProxyPool) TableName() string { return "egress_nodes" } + +func TestEgressRepositorySortsInDatabase(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "egress-sort.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewEgressRepository(database) + for _, value := range []egress.Node{ + {Name: "slow", Scope: egress.ScopeBuild, Enabled: true, Health: 0.2}, + {Name: "healthy", Scope: egress.ScopeWeb, Enabled: true, Health: 0.9}, + {Name: "middle", Scope: egress.ScopeWebAsset, Enabled: true, Health: 0.5}, + } { + if _, err := repo.CreateEgressNode(ctx, value); err != nil { + t.Fatal(err) + } + } + values, err := repo.ListEgressNodes(ctx, "", repository.SortQuery{Field: "health", Direction: repository.SortDescending}) + if err != nil || len(values) != 3 || values[0].Name != "healthy" || values[2].Name != "slow" { + t.Fatalf("health sort = %#v, err = %v", values, err) + } +} + +func TestEgressRepositoryPaginatesAndFiltersManagementList(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "egress-page.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewEgressRepository(database) + created := make(map[string]egress.Node) + for _, value := range []egress.Node{ + {Name: "alpha", Scope: egress.ScopeBuild, Enabled: true, Health: 0.9, ProbeStatus: egress.ProbeStatusHealthy}, + {Name: "beta", Scope: egress.ScopeWeb, Enabled: false, Health: 0.4, ProbeStatus: egress.ProbeStatusUnhealthy}, + {Name: "gamma", Scope: egress.ScopeBuild, Enabled: true, Health: 0.7, ProbeStatus: egress.ProbeStatusUnknown}, + } { + node, createErr := repo.CreateEgressNode(ctx, value) + if createErr != nil { + t.Fatal(createErr) + } + created[node.Name] = node + } + if err := database.db.WithContext(ctx).Model(&egressNodeModel{}).Where("id = ?", created["beta"].ID).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + alphaID := created["alpha"].ID + if err := database.db.WithContext(ctx).Create(&accountModel{ + IdentityKey: strings.Repeat("a", 64), Provider: "grok_build", Name: "bound", SourceKey: "bound-source", + Enabled: true, AuthStatus: "active", Priority: 1, MaxConcurrent: 8, EgressNodeID: &alphaID, + }).Error; err != nil { + t.Fatal(err) + } + + values, total, err := repo.ListEgressNodePage(ctx, repository.EgressNodeListQuery{ + Page: repository.PageQuery{Limit: 2, Sort: repository.SortQuery{Field: "name", Direction: repository.SortAscending}}, + }) + if err != nil || total != 3 || len(values) != 2 || values[0].Name != "alpha" || values[1].Name != "beta" || values[0].AssignedAccountCount != 1 { + t.Fatalf("first page = %#v, total=%d, err=%v", values, total, err) + } + values, total, err = repo.ListEgressNodePage(ctx, repository.EgressNodeListQuery{ + Page: repository.PageQuery{Offset: 2, Limit: 2, Sort: repository.SortQuery{Field: "name", Direction: repository.SortAscending}}, + }) + if err != nil || total != 3 || len(values) != 1 || values[0].Name != "gamma" { + t.Fatalf("second page = %#v, total=%d, err=%v", values, total, err) + } + + bound := "bound" + values, total, err = repo.ListEgressNodePage(ctx, repository.EgressNodeListQuery{ + Page: repository.PageQuery{Limit: 20, Search: "ALP"}, + Filter: repository.EgressNodeListFilter{ + Scope: egress.ScopeBuild, ProbeStatus: egress.ProbeStatusHealthy, Assignment: bound, + }, + }) + if err != nil || total != 1 || len(values) != 1 || values[0].Name != "alpha" || values[0].AssignedAccountCount != 1 { + t.Fatalf("filtered page = %#v, total=%d, err=%v", values, total, err) + } + + disabled := false + values, total, err = repo.ListEgressNodePage(ctx, repository.EgressNodeListQuery{ + Page: repository.PageQuery{Limit: 20}, Filter: repository.EgressNodeListFilter{Enabled: &disabled, Assignment: "unbound"}, + }) + if err != nil || total != 1 || len(values) != 1 || values[0].Name != "beta" { + t.Fatalf("disabled unbound page = %#v, total=%d, err=%v", values, total, err) + } +} + +func TestEgressStateUpdatesDoNotOverwriteClearanceOrHealth(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "egress-state.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewEgressRepository(database) + node, err := repo.CreateEgressNode(ctx, egress.Node{Name: "web", Scope: egress.ScopeWeb, Enabled: true, Health: 1, UserAgent: "old", EncryptedCloudflareCookie: "old-cookie"}) + if err != nil { + t.Fatal(err) + } + if err := repo.UpdateEgressNodeHealth(ctx, node.ID, 0.4, 2, nil, "anti-bot rejection"); err != nil { + t.Fatal(err) + } + if err := repo.UpdateEgressNodeClearance(ctx, node.ID, "new-cookie", "new-agent", strings.Repeat("a", 64), strings.Repeat("b", 64), time.Now().UTC()); err != nil { + t.Fatal(err) + } + actual, err := repo.GetEgressNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if actual.Health != 0.4 || actual.FailureCount != 2 || actual.EncryptedCloudflareCookie != "new-cookie" || actual.UserAgent != "new-agent" || actual.ClearanceBindingFingerprint != strings.Repeat("b", 64) { + t.Fatalf("partial updates overwrote state: %#v", actual) + } +} + +func TestInitializeSchemaRemovesAndRejectsLegacyAllEgressNodes(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-egress.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.db.WithContext(ctx).AutoMigrate(&legacyAllEgressNode{}); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&legacyAllEgressNode{Scope: "all"}).Error; err != nil { + t.Fatal(err) + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + var count int64 + if err := database.db.WithContext(ctx).Model(&egressNodeModel{}).Where("scope = ?", "all").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("legacy all-scope nodes = %d", count) + } + if _, err := NewEgressRepository(database).CreateEgressNode(ctx, egress.Node{Name: "invalid", Scope: egress.Scope("all"), Enabled: true}); err == nil { + t.Fatal("all-scope node passed the database constraint") + } +} + +func TestInitializeSchemaAddsProxyPoolWithoutChangingExistingRows(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-proxy-pool.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.db.WithContext(ctx).AutoMigrate(&legacyEgressNodeWithoutProxyPool{}); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&legacyEgressNodeWithoutProxyPool{Name: "existing", Scope: string(egress.ScopeBuild), Enabled: true}).Error; err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("repeated schema initialization failed: %v", err) + } + repo := NewEgressRepository(database) + existing, err := repo.GetEgressNode(ctx, 1) + if err != nil { + t.Fatal(err) + } + if existing.ProxyPool || existing.Name != "existing" || existing.ProbeProvider != "" || existing.IPv4Probe.Status != egress.ProbeStatusUnknown || existing.IPv6Probe.Status != egress.ProbeStatusUnknown { + t.Fatalf("legacy row changed during migration: %#v", existing) + } + existing.ProxyPool = true + updated, err := repo.UpdateEgressNode(ctx, existing) + if err != nil || !updated.ProxyPool { + t.Fatalf("proxy pool did not round trip: %#v, err=%v", updated, err) + } +} diff --git a/backend/internal/infra/persistence/relational/errors.go b/backend/internal/infra/persistence/relational/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..cadfd5615cdfe87d2a8606e7b08147794884f610 --- /dev/null +++ b/backend/internal/infra/persistence/relational/errors.go @@ -0,0 +1,21 @@ +package relational + +import ( + "errors" + + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +func mapError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return repository.ErrNotFound + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return repository.ErrConflict + } + return err +} diff --git a/backend/internal/infra/persistence/relational/invalidation_test.go b/backend/internal/infra/persistence/relational/invalidation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9ce7f97c6439bc35e531453ce3511c6581249a9c --- /dev/null +++ b/backend/internal/infra/persistence/relational/invalidation_test.go @@ -0,0 +1,225 @@ +package relational + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestRoutingMutationsNotifyAfterCommit(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "invalidation.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := NewAccountRepository(database) + models := NewModelRepository(database) + var events []repository.InvalidationEvent + observe := func(_ context.Context, event repository.InvalidationEvent) { + events = append(events, event) + } + accounts.SetInvalidationObserver(observe) + models.SetInvalidationObserver(observe) + + credential, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "build", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: credential.ID, MonthlyLimit: 100, SyncedAt: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, credential.ID, []string{"model-a"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := models.Create(ctx, model.Route{ + PublicID: "model-a", Provider: account.ProviderBuild, UpstreamModel: "model-a", Capability: model.CapabilityResponses, Enabled: true, + }, []uint64{credential.ID}); err != nil { + t.Fatal(err) + } + + want := []repository.InvalidationKind{ + repository.InvalidationAccountStateChanged, + repository.InvalidationAccountBillingChanged, + repository.InvalidationAccountCapabilityChanged, + repository.InvalidationRouteChanged, + repository.InvalidationModelBindingChanged, + } + if len(events) != len(want) { + t.Fatalf("events = %#v", events) + } + for index, kind := range want { + if events[index].Kind != kind || !events[index].Valid() { + t.Fatalf("event %d = %#v, want %s", index, events[index], kind) + } + } + events = events[:0] + if err := models.UpsertDiscovered(ctx, account.ProviderBuild, []string{"model-a"}); err != nil { + t.Fatal(err) + } + if len(events) != 0 { + t.Fatalf("unchanged discovered routes emitted invalidation: %#v", events) + } + + before := len(events) + credential.ID = 999999 + if _, err := accounts.Update(ctx, credential); err == nil { + t.Fatal("missing account update should fail") + } + if len(events) != before { + t.Fatalf("failed transaction emitted invalidation: %#v", events[before:]) + } +} + +func TestClientKeyMutationsNotifyAfterCommit(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "client-key-invalidation.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := NewClientKeyRepository(database) + created, err := keys.Create(ctx, clientkeydomain.Key{ + Name: "scoped", Prefix: "scoped", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, ProviderScope: clientkeydomain.ProviderScopeBuild, TierScope: clientkeydomain.TierScopeFree, + }) + if err != nil { + t.Fatal(err) + } + var events []repository.InvalidationEvent + keys.SetInvalidationObserver(func(_ context.Context, event repository.InvalidationEvent) { + events = append(events, event) + }) + + created.ProviderScope = clientkeydomain.ProviderScopeWeb + created.TierScope = clientkeydomain.TierScopeSuper + if _, err := keys.Update(ctx, created); err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Kind != repository.InvalidationClientKeyChanged || events[0].ClientKeyID != created.ID || !events[0].Valid() { + t.Fatalf("client-key update events = %#v", events) + } + + missing := created + missing.ID = created.ID + 999 + if _, err := keys.Update(ctx, missing); err == nil { + t.Fatal("missing client-key update should fail") + } + if len(events) != 1 { + t.Fatalf("failed update emitted invalidation: %#v", events[1:]) + } + + if _, err := keys.UpdateManyEnabled(ctx, []uint64{created.ID}, false); err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[1].ClientKeyID != 0 || !events[1].Valid() { + t.Fatalf("client-key batch events = %#v", events) + } + if err := keys.Delete(ctx, created.ID); err != nil { + t.Fatal(err) + } + if len(events) != 3 || events[2].ClientKeyID != created.ID || !events[2].Valid() { + t.Fatalf("client-key delete events = %#v", events) + } +} + +func TestModelUpdateInvalidationUsesStoredRouteIdentity(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "model-update-invalidation.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + models := NewModelRepository(database) + created, err := models.Create(ctx, model.Route{ + PublicID: "model-a", Provider: account.ProviderBuild, UpstreamModel: "upstream-a", Capability: model.CapabilityResponses, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + var events []repository.InvalidationEvent + models.SetInvalidationObserver(func(_ context.Context, event repository.InvalidationEvent) { + events = append(events, event) + }) + created.Provider = account.ProviderWeb + created.UpstreamModel = "caller-supplied-upstream" + created.PublicID = "renamed" + bindings := []uint64{} + updated, err := models.Update(ctx, created, &bindings) + if err != nil { + t.Fatal(err) + } + wantPublicID, _ := model.NormalizePublicID(account.ProviderBuild, "renamed") + if updated.Provider != account.ProviderBuild || updated.UpstreamModel != "upstream-a" || updated.PublicID != wantPublicID { + t.Fatalf("updated route = %#v", updated) + } + if len(events) != 2 { + t.Fatalf("events = %#v", events) + } + for _, event := range events { + if event.Provider != account.ProviderBuild || event.UpstreamModel != "upstream-a" { + t.Fatalf("invalidation used caller identity: %#v", event) + } + } + events = events[:0] + updatedCount, err := models.UpdateManyEnabled(ctx, []uint64{created.ID}, true) + if err != nil { + t.Fatal(err) + } + if updatedCount != 0 || len(events) != 0 { + t.Fatalf("unchanged enabled state emitted invalidation: %#v", events) + } + updatedCount, err = models.UpdateManyEnabled(ctx, []uint64{created.ID}, false) + if err != nil { + t.Fatal(err) + } + if updatedCount != 1 || len(events) != 1 || events[0].Provider != account.ProviderBuild { + t.Fatalf("changed enabled state events = %#v", events) + } +} + +func TestAccountUpdatePreservesStoredProvider(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "account-update-provider.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := NewAccountRepository(database) + created, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "build", EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + created.Provider = account.ProviderWeb + created.BuildSuperEntitled = true + created.BuildRouteMode = account.BuildRouteBuild + updated, err := accounts.Update(ctx, created) + if err != nil { + t.Fatal(err) + } + if updated.Provider != account.ProviderBuild || !updated.BuildSuperEntitled || updated.BuildRouteMode != account.BuildRouteBuild { + t.Fatalf("updated account = %#v", updated) + } +} diff --git a/backend/internal/infra/persistence/relational/list_filter_repository_test.go b/backend/internal/infra/persistence/relational/list_filter_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7cff89d54e7d7b85d6282c85801d63e153dbb4b7 --- /dev/null +++ b/backend/internal/infra/persistence/relational/list_filter_repository_test.go @@ -0,0 +1,338 @@ +package relational + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestListFilters(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "list-filters.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + + free := accountModel{IdentityKey: testIdentityKey("free"), Provider: "grok_build", Name: "free", SourceKey: "free", ObservedModel: "grok-build-free", Enabled: true, AuthStatus: "active", Priority: 1} + paid := accountModel{IdentityKey: testIdentityKey("paid"), Provider: "grok_build", Name: "paid", SourceKey: "paid", Enabled: true, AuthStatus: "active", Priority: 1} + disabled := accountModel{IdentityKey: testIdentityKey("disabled-filter"), Provider: "grok_build", Name: "disabled", SourceKey: "disabled-filter", Enabled: false, AuthStatus: "active", Priority: 1} + for _, value := range []*accountModel{&free, &paid, &disabled} { + if err := database.db.WithContext(ctx).Create(value).Error; err != nil { + t.Fatal(err) + } + } + credentials := []accountCredentialModel{ + {AccountID: free.ID, AuthType: "oauth", EncryptedRefresh: "refresh", UpdatedAt: now}, + {AccountID: paid.ID, AuthType: "oauth", EncryptedPrimary: testEncryptedToken, UpdatedAt: now}, + {AccountID: disabled.ID, AuthType: "oauth", EncryptedPrimary: testEncryptedToken, UpdatedAt: now}, + } + for index := range credentials { + if err := database.db.WithContext(ctx).Create(&credentials[index]).Error; err != nil { + t.Fatal(err) + } + } + if err := database.db.WithContext(ctx).Create(&billingModel{AccountID: paid.ID, PlanName: "SuperGrokPro", IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", SyncedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&billingModel{AccountID: disabled.ID, CreditUsagePercent: 42.5, IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", SyncedAt: now}).Error; err != nil { + t.Fatal(err) + } + + accounts := NewAccountRepository(database) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "free", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "paid", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "unknown", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Status: "active", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Status: "disabled", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{AccountIDs: []uint64{free.ID}, RestrictIDs: true, Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{RestrictIDs: true, Now: now}, 0) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{ExcludeIDs: []uint64{free.ID}, Now: now}, 2) + refreshable := true + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Refreshable: &refreshable, Now: now}, 1) + boundNode := egressNodeModel{Name: "bound-filter", Scope: "grok_build", Enabled: true} + if err := database.db.WithContext(ctx).Create(&boundNode).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", free.ID).Update("egress_node_id", boundNode.ID).Error; err != nil { + t.Fatal(err) + } + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Egress: "bound", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Egress: "unbound", Now: now}, 2) + + // 零 Billing + build_super_entitled:paid 可查到,free 查不到。 + entitled := accountModel{IdentityKey: testIdentityKey("entitled-zero"), Provider: "grok_build", Name: "entitled-zero", SourceKey: "entitled-zero", ObservedModel: "grok-build-free", Enabled: true, AuthStatus: "active", Priority: 1, BuildSuperEntitled: true} + if err := database.db.WithContext(ctx).Create(&entitled).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&accountCredentialModel{AccountID: entitled.ID, AuthType: "oauth", EncryptedPrimary: testEncryptedToken, UpdatedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&billingModel{AccountID: entitled.ID, IsUnifiedBillingUser: true, SyncedAt: now}).Error; err != nil { + t.Fatal(err) + } + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "paid", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "free", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Status: "active", Now: now}, 3) + + var webByTier = map[string]accountModel{} + for _, tier := range []string{"auto", "basic", "super", "heavy"} { + value := accountModel{IdentityKey: testIdentityKey("web-" + tier), Provider: "grok_web", Name: "web-" + tier, SourceKey: "web-" + tier, Enabled: true, AuthStatus: "active", Priority: 1} + if err := database.db.WithContext(ctx).Create(&value).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webAccountProfileModel{AccountID: value.ID, Tier: tier, SyncedAt: &now}).Error; err != nil { + t.Fatal(err) + } + webByTier[tier] = value + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", QuotaType: tier, Now: now}, 1) + } + // Web agreement and association filters cover NSFW, terms, Build, and Console. + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "nsfwDisabled", Now: now}, 4) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "termsNotAccepted", Now: now}, 4) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "allNotAccepted", Now: now}, 4) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "allUnlinked", Now: now}, 4) + + nsfwWeb := webByTier["auto"] + termsWeb := webByTier["basic"] + bothAgreedWeb := webByTier["super"] + if err := database.db.WithContext(ctx).Model(&webAccountProfileModel{}).Where("account_id = ?", nsfwWeb.ID).Updates(map[string]any{"nsfw_enabled_at": now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&webAccountProfileModel{}).Where("account_id = ?", termsWeb.ID).Updates(map[string]any{ + "terms_accepted_at": now, "terms_accepted_version": account.CurrentWebTermsVersion, + }).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&webAccountProfileModel{}).Where("account_id = ?", bothAgreedWeb.ID).Updates(map[string]any{ + "nsfw_enabled_at": now, "terms_accepted_at": now, "terms_accepted_version": account.CurrentWebTermsVersion, + }).Error; err != nil { + t.Fatal(err) + } + // An outdated terms version must not count as accepted. + staleTermsWeb := webByTier["heavy"] + if err := database.db.WithContext(ctx).Model(&webAccountProfileModel{}).Where("account_id = ?", staleTermsWeb.ID).Updates(map[string]any{ + "terms_accepted_at": now, "terms_accepted_version": account.CurrentWebTermsVersion - 1, + }).Error; err != nil { + t.Fatal(err) + } + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "nsfwEnabled", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "nsfwDisabled", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "termsAccepted", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "termsNotAccepted", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "allAccepted", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Agreement: "allNotAccepted", Now: now}, 1) + + buildAccount := accountModel{IdentityKey: testIdentityKey("link-build"), Provider: "grok_build", Name: "link-build", SourceKey: "link-build", Enabled: true, AuthStatus: "active", Priority: 1} + consoleAccount := accountModel{IdentityKey: testIdentityKey("link-console"), Provider: "grok_console", Name: "link-console", SourceKey: "link-console", Enabled: true, AuthStatus: "active", Priority: 1} + for _, value := range []*accountModel{&buildAccount, &consoleAccount} { + if err := database.db.WithContext(ctx).Create(value).Error; err != nil { + t.Fatal(err) + } + } + if err := database.db.WithContext(ctx).Create(&accountProviderLinkModel{WebAccountID: nsfwWeb.ID, BuildAccountID: buildAccount.ID, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webConsoleAccountLinkModel{WebAccountID: termsWeb.ID, ConsoleAccountID: consoleAccount.ID, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + bothLinkedBuild := accountModel{IdentityKey: testIdentityKey("both-build"), Provider: "grok_build", Name: "both-build", SourceKey: "both-build", Enabled: true, AuthStatus: "active", Priority: 1} + bothLinkedConsole := accountModel{IdentityKey: testIdentityKey("both-console"), Provider: "grok_console", Name: "both-console", SourceKey: "both-console", Enabled: true, AuthStatus: "active", Priority: 1} + for _, value := range []*accountModel{&bothLinkedBuild, &bothLinkedConsole} { + if err := database.db.WithContext(ctx).Create(value).Error; err != nil { + t.Fatal(err) + } + } + if err := database.db.WithContext(ctx).Create(&accountProviderLinkModel{WebAccountID: bothAgreedWeb.ID, BuildAccountID: bothLinkedBuild.ID, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webConsoleAccountLinkModel{WebAccountID: bothAgreedWeb.ID, ConsoleAccountID: bothLinkedConsole.ID, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "buildLinked", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "buildUnlinked", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "consoleLinked", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "consoleUnlinked", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "allLinked", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_web", Association: "allUnlinked", Now: now}, 1) + // Seed an orphan Console account so the Build/Console Web-link filters have an unlinked result. + orphanConsole := accountModel{IdentityKey: testIdentityKey("orphan-console"), Provider: "grok_console", Name: "orphan-console", SourceKey: "orphan-console", Enabled: true, AuthStatus: "active", Priority: 1} + if err := database.db.WithContext(ctx).Create(&orphanConsole).Error; err != nil { + t.Fatal(err) + } + // Four Build accounts are unlinked; link-build and both-build are linked. + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_build", Association: "webLinked", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_build", Association: "webUnlinked", Now: now}, 4) + // link-console and both-console are linked; orphan-console is unlinked. + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_console", Association: "webLinked", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_console", Association: "webUnlinked", Now: now}, 1) + // Combine association and state filters: the disabled Build account is unlinked and both linked accounts are active. + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_build", Association: "webLinked", Status: "active", Now: now}, 2) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{Provider: "grok_build", Association: "webUnlinked", Status: "disabled", Now: now}, 1) + accountValues, _, err := accounts.List(ctx, repository.AccountListQuery{Page: repository.PageQuery{Limit: 20, Sort: repository.SortQuery{Field: "name", Direction: repository.SortAscending}}, Filter: repository.AccountListFilter{Provider: "grok_build", Now: now}}) + if err != nil || len(accountValues) < 4 || accountValues[0].Name != "both-build" || accountValues[len(accountValues)-1].Name != "paid" { + t.Fatalf("account name sort = %#v, err = %v", accountValues, err) + } + + models := NewModelRepository(database) + for _, value := range []*modelRouteModel{ + {PublicID: "public-enabled", Provider: "grok_build", UpstreamModel: "upstream-alpha", Capability: "responses", Enabled: true}, + {PublicID: "public-disabled", Provider: "grok_web", UpstreamModel: "upstream-beta", Capability: "chat", Enabled: false}, + } { + if err := database.db.WithContext(ctx).Create(value).Error; err != nil { + t.Fatal(err) + } + } + enabled := true + _, total, err := models.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 20}, Filter: repository.ModelListFilter{Provider: "grok_build", Enabled: &enabled}}) + if err != nil || total != 1 { + t.Fatalf("model filter total = %d, err = %v", total, err) + } + assertModelSearchCount(t, ctx, models, "public-enabled", 1) + assertModelSearchCount(t, ctx, models, "upstream-beta", 1) + modelValues, _, err := models.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 20, Sort: repository.SortQuery{Field: "publicId", Direction: repository.SortAscending}}}) + if err != nil || len(modelValues) != 2 || modelValues[0].PublicID != "public-disabled" || modelValues[1].PublicID != "public-enabled" { + t.Fatalf("model name sort = %#v, err = %v", modelValues, err) + } + + keys := NewClientKeyRepository(database) + activeKey, err := keys.Create(ctx, clientkeydomain.Key{Name: "production", Prefix: "abc123", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8}) + if err != nil { + t.Fatal(err) + } + if _, err := keys.Create(ctx, clientkeydomain.Key{Name: "disabled", Prefix: "def456", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: false, RPMLimit: 120, MaxConcurrent: 8}); err != nil { + t.Fatal(err) + } + expiredAt := now.Add(-time.Hour) + if _, err := keys.Create(ctx, clientkeydomain.Key{Name: "expired", Prefix: "expired", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, ExpiresAt: &expiredAt, RPMLimit: 120, MaxConcurrent: 8}); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&clientKeyModelPermission{ClientKeyID: activeKey.ID, ModelRouteID: 1}).Error; err != nil { + t.Fatal(err) + } + _, total, err = keys.List(ctx, repository.ClientKeyListQuery{Page: repository.PageQuery{Limit: 20}, Filter: repository.ClientKeyListFilter{Status: "expired", Now: now}}) + if err != nil || total != 1 { + t.Fatalf("expired key filter total = %d, err = %v", total, err) + } + _, total, err = keys.List(ctx, repository.ClientKeyListQuery{Page: repository.PageQuery{Limit: 20}, Filter: repository.ClientKeyListFilter{ModelScope: "restricted", Now: now}}) + if err != nil || total != 1 { + t.Fatalf("key scope filter total = %d, err = %v", total, err) + } + assertClientKeySearchCount(t, ctx, keys, "production", 1) + assertClientKeySearchCount(t, ctx, keys, "abc123", 1) + keyValues, _, err := keys.List(ctx, repository.ClientKeyListQuery{Page: repository.PageQuery{Limit: 20, Sort: repository.SortQuery{Field: "name", Direction: repository.SortDescending}}, Filter: repository.ClientKeyListFilter{Now: now}}) + if err != nil || len(keyValues) != 3 || keyValues[0].Name != "production" || keyValues[2].Name != "disabled" { + t.Fatalf("client key name sort = %#v, err = %v", keyValues, err) + } + + audits := NewAuditRepository(database) + for _, value := range []requestAuditModel{ + {RequestID: "success", ClientKeyID: 1, ClientKeyName: "production-key", ModelRouteID: 1, ModelPublicID: "grok-public", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", AccountID: uint64PointerForFilter(11), AccountName: "build-primary", StatusCode: 200, Streaming: true, CreatedAt: now}, + {RequestID: "client", ClientKeyID: 2, ClientKeyName: "staging-key", ModelRouteID: 1, ModelUpstreamModel: "grok-upstream", Provider: "grok_build", Operation: "responses", UsageSource: "upstream", AccountID: uint64PointerForFilter(12), AccountName: "build-secondary", StatusCode: 429, Streaming: false, CreatedAt: now}, + {RequestID: "server", ClientKeyID: 3, ClientKeyName: "fallback-key", ModelRouteID: 1, Provider: "grok_build", Operation: "responses", UsageSource: "upstream", AccountID: uint64PointerForFilter(13), AccountName: "web-heavy", StatusCode: 500, Streaming: true, CreatedAt: now}, + } { + if err := database.db.WithContext(ctx).Create(&value).Error; err != nil { + t.Fatal(err) + } + } + values, _, err := audits.ListCursor(ctx, repository.AuditCursorQuery{Limit: 20, Filter: repository.AuditListFilter{Status: "success", Mode: "stream"}}) + if err != nil || len(values) != 1 || values[0].RequestID != "success" { + t.Fatalf("audit filters = %#v, err = %v", values, err) + } + assertAuditSearchResult(t, ctx, audits, "success", "success") + assertAuditSearchResult(t, ctx, audits, "grok-public", "success") + assertAuditSearchResult(t, ctx, audits, "grok-upstream", "client") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Model: "grok-public"}, "success") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Status: "4xx"}, "client") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Mode: "nonStream"}, "client") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Key: "staging"}, "client") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Key: "2"}, "client") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Account: "secondary"}, "client") + assertAuditFilterResult(t, ctx, audits, repository.AuditListFilter{Account: "12"}, "client") + auditSort := repository.SortQuery{Field: "status", Direction: repository.SortDescending} + firstAuditPage, hasMore, err := audits.ListCursor(ctx, repository.AuditCursorQuery{Limit: 2, Sort: auditSort}) + if err != nil || !hasMore || len(firstAuditPage) != 2 || firstAuditPage[0].RequestID != "server" || firstAuditPage[1].RequestID != "client" { + t.Fatalf("audit status first page = %#v, hasMore = %v, err = %v", firstAuditPage, hasMore, err) + } + secondAuditPage, hasMore, err := audits.ListCursor(ctx, repository.AuditCursorQuery{Limit: 2, Sort: auditSort, Cursor: &repository.SortCursor{ID: firstAuditPage[1].ID, Value: int64(firstAuditPage[1].StatusCode)}}) + if err != nil || hasMore || len(secondAuditPage) != 1 || secondAuditPage[0].RequestID != "success" { + t.Fatalf("audit status second page = %#v, hasMore = %v, err = %v", secondAuditPage, hasMore, err) + } +} + +func TestListFiltersRecognizesInferredFreeBilling(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "inferred-free.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + value := accountModel{IdentityKey: testIdentityKey("inferred-free"), Provider: "grok_build", Name: "inferred-free", SourceKey: "inferred-free", Enabled: true, AuthStatus: "active", Priority: 1} + if err := database.db.WithContext(ctx).Create(&value).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&billingModel{ + AccountID: value.ID, IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", SyncedAt: now, + }).Error; err != nil { + t.Fatal(err) + } + accounts := NewAccountRepository(database) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "free", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "paid", Now: now}, 0) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "unknown", Now: now}, 0) +} + +func assertAccountFilterCount(t *testing.T, ctx context.Context, accounts *AccountRepository, filter repository.AccountListFilter, expected int64) { + t.Helper() + _, total, err := accounts.List(ctx, repository.AccountListQuery{Page: repository.PageQuery{Limit: 20}, Filter: filter}) + if err != nil || total != expected { + t.Fatalf("account filter %#v total = %d, err = %v", filter, total, err) + } +} + +func assertModelSearchCount(t *testing.T, ctx context.Context, models *ModelRepository, search string, expected int64) { + t.Helper() + _, total, err := models.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 20, Search: search}}) + if err != nil || total != expected { + t.Fatalf("model search %q total = %d, err = %v", search, total, err) + } +} + +func assertClientKeySearchCount(t *testing.T, ctx context.Context, keys *ClientKeyRepository, search string, expected int64) { + t.Helper() + _, total, err := keys.List(ctx, repository.ClientKeyListQuery{Page: repository.PageQuery{Limit: 20, Search: search}, Filter: repository.ClientKeyListFilter{Now: time.Now().UTC()}}) + if err != nil || total != expected { + t.Fatalf("client key search %q total = %d, err = %v", search, total, err) + } +} + +func assertAuditSearchResult(t *testing.T, ctx context.Context, audits *AuditRepository, search, expectedRequestID string) { + t.Helper() + values, _, err := audits.ListCursor(ctx, repository.AuditCursorQuery{Limit: 20, Search: search}) + if err != nil || len(values) != 1 || values[0].RequestID != expectedRequestID { + t.Fatalf("audit search %q values = %#v, err = %v", search, values, err) + } +} + +func assertAuditFilterResult(t *testing.T, ctx context.Context, audits *AuditRepository, filter repository.AuditListFilter, expectedRequestID string) { + t.Helper() + values, _, err := audits.ListCursor(ctx, repository.AuditCursorQuery{Limit: 20, Filter: filter}) + if err != nil || len(values) != 1 || values[0].RequestID != expectedRequestID { + t.Fatalf("audit filter %#v values = %#v, err = %v", filter, values, err) + } +} + +func uint64PointerForFilter(value uint64) *uint64 { return &value } diff --git a/backend/internal/infra/persistence/relational/mapping.go b/backend/internal/infra/persistence/relational/mapping.go new file mode 100644 index 0000000000000000000000000000000000000000..3258e023f34751b02a2d47d2cf86db742efe4b94 --- /dev/null +++ b/backend/internal/infra/persistence/relational/mapping.go @@ -0,0 +1,279 @@ +package relational + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/admin" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +func toAdminDomain(value adminModel) admin.Admin { + return admin.Admin{ID: value.ID, Username: value.Username, PasswordHash: value.PasswordHash, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt} +} + +func toSessionDomain(value adminSessionModel) admin.Session { + return admin.Session{ID: value.ID, AdminID: value.AdminID, RefreshTokenHash: value.RefreshTokenHash, ExpiresAt: value.ExpiresAt, LastUsedAt: value.LastUsedAt, CreatedAt: value.CreatedAt} +} + +func toAccountDomain(value accountModel) account.Credential { + var expiresAt time.Time + var refreshDueAt, lastRefreshAt *time.Time + var refreshFailures int + var lastRefreshErrorStatus int + var lastRefreshError string + var lastRefreshErrorMessage string + var lastRefreshErrorResponse string + var refreshPermanent bool + var authType account.AuthType + var clientID, encryptedPrimary, encryptedRefresh, encryptedCloudflareCookie string + if value.Credential != nil { + authType = account.AuthType(value.Credential.AuthType) + clientID = value.Credential.ClientID + encryptedPrimary = value.Credential.EncryptedPrimary + encryptedRefresh = value.Credential.EncryptedRefresh + encryptedCloudflareCookie = value.Credential.EncryptedCloudflareCookie + // The account-level Cloudflare cookie is intentionally never exposed by + // the transport DTO; it is only used when constructing the upstream Cookie header. + if value.Credential.ExpiresAt != nil { + expiresAt = *value.Credential.ExpiresAt + } + refreshDueAt = value.Credential.RefreshDueAt + lastRefreshAt = value.Credential.LastRefreshAt + refreshFailures = value.Credential.RefreshFailures + lastRefreshErrorStatus = value.Credential.LastRefreshErrorStatus + lastRefreshError = value.Credential.LastRefreshError + lastRefreshErrorMessage = value.Credential.LastRefreshErrorMessage + lastRefreshErrorResponse = value.Credential.LastRefreshErrorResponse + refreshPermanent = value.Credential.RefreshPermanent + } + var webTier account.WebTier + var webTierSyncedAt *time.Time + var webNSFWEnabledAt *time.Time + var webTermsAcceptedAt *time.Time + var webTermsAcceptedVersion int + var webBirthDateSetAt *time.Time + var egressIdentity string + if value.WebProfile != nil { + webTier = account.WebTier(value.WebProfile.Tier) + webTierSyncedAt = value.WebProfile.SyncedAt + webNSFWEnabledAt = value.WebProfile.NSFWEnabledAt + webTermsAcceptedVersion = value.WebProfile.TermsAcceptedVersion + if webTermsAcceptedVersion >= account.CurrentWebTermsVersion { + webTermsAcceptedAt = value.WebProfile.TermsAcceptedAt + } + webBirthDateSetAt = value.WebProfile.BirthDateSetAt + egressIdentity = value.WebProfile.EgressIdentity + } + buildRouteMode := account.BuildRouteMode(value.BuildRouteMode) + if account.Provider(value.Provider) != account.ProviderBuild || !buildRouteMode.IsValid() { + buildRouteMode = account.BuildRouteAuto + } + return account.Credential{ + ID: value.ID, Provider: account.Provider(value.Provider), AuthType: authType, Name: value.Name, Email: value.Email, + UserID: value.UserID, TeamID: value.TeamID, SourceKey: value.SourceKey, OIDCClientID: clientID, + EncryptedAccessToken: encryptedPrimary, EncryptedRefreshToken: encryptedRefresh, EncryptedCloudflareCookie: encryptedCloudflareCookie, + ExpiresAt: expiresAt, RefreshDueAt: refreshDueAt, LastRefreshAt: lastRefreshAt, + RefreshFailureCount: refreshFailures, LastRefreshErrorStatus: lastRefreshErrorStatus, LastRefreshErrorCode: lastRefreshError, LastRefreshErrorMessage: lastRefreshErrorMessage, LastRefreshErrorResponse: lastRefreshErrorResponse, RefreshPermanent: refreshPermanent, + Enabled: value.Enabled, AuthStatus: account.AuthStatus(value.AuthStatus), ReauthMarkedAt: value.ReauthMarkedAt, Priority: value.Priority, + MaxConcurrent: value.MaxConcurrent, MinimumRemaining: value.MinimumRemaining, FailureCount: value.FailureCount, + CooldownUntil: value.CooldownUntil, LastError: value.LastError, LastUsedAt: value.LastUsedAt, + ObservedModel: value.ObservedModel, ObservedModelAt: value.ObservedModelAt, WebTier: webTier, WebTierSyncedAt: webTierSyncedAt, + WebNSFWEnabledAt: webNSFWEnabledAt, WebTermsAcceptedAt: webTermsAcceptedAt, WebTermsAcceptedVersion: webTermsAcceptedVersion, WebBirthDateSetAt: webBirthDateSetAt, EgressIdentity: egressIdentity, + EgressNodeID: valueEgressNodeID(value.EgressNodeID), EgressAssignmentMode: account.EgressAssignmentMode(value.EgressAssignmentMode), EgressAssignedAt: value.EgressAssignedAt, + BuildAPIFallback: value.BuildAPIFallback, BuildRouteMode: buildRouteMode, + BuildSuperEntitled: value.BuildSuperEntitled && account.Provider(value.Provider) == account.ProviderBuild, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func toCredentialMaterialDomain(value accountCredentialModel, provider account.Provider) account.CredentialMaterial { + var expiresAt time.Time + if value.ExpiresAt != nil { + expiresAt = *value.ExpiresAt + } + return account.CredentialMaterial{ + AccountID: value.AccountID, Provider: provider, AuthType: account.AuthType(value.AuthType), OIDCClientID: value.ClientID, + EncryptedAccessToken: value.EncryptedPrimary, EncryptedRefreshToken: value.EncryptedRefresh, + EncryptedCloudflareCookie: value.EncryptedCloudflareCookie, ExpiresAt: expiresAt, + RefreshDueAt: value.RefreshDueAt, LastRefreshAt: value.LastRefreshAt, + RefreshFailureCount: value.RefreshFailures, LastRefreshErrorStatus: value.LastRefreshErrorStatus, LastRefreshErrorCode: value.LastRefreshError, LastRefreshErrorMessage: value.LastRefreshErrorMessage, LastRefreshErrorResponse: value.LastRefreshErrorResponse, + RefreshPermanent: value.RefreshPermanent, UpdatedAt: value.UpdatedAt, + } +} + +func fromAccountDomain(value account.Credential) accountModel { + // entitlement、推理地址与 XAI 回退标记仅对 grok_build 有意义。 + buildAPIFallback := value.BuildAPIFallback && value.Provider == account.ProviderBuild + buildSuperEntitled := value.BuildSuperEntitled && value.Provider == account.ProviderBuild + buildRouteMode := account.BuildRouteAuto + if value.Provider == account.ProviderBuild && value.BuildRouteMode.IsValid() { + buildRouteMode = value.BuildRouteMode + } + return accountModel{ + ID: value.ID, IdentityKey: accountIdentity(value), Provider: string(value.Provider), Name: value.Name, Email: value.Email, + UserID: value.UserID, TeamID: value.TeamID, SourceKey: value.SourceKey, + Enabled: value.Enabled, AuthStatus: string(value.AuthStatus), ReauthMarkedAt: value.ReauthMarkedAt, Priority: value.Priority, + MaxConcurrent: value.MaxConcurrent, MinimumRemaining: value.MinimumRemaining, FailureCount: value.FailureCount, + CooldownUntil: value.CooldownUntil, LastError: value.LastError, LastUsedAt: value.LastUsedAt, + ObservedModel: value.ObservedModel, ObservedModelAt: value.ObservedModelAt, + BuildAPIFallback: buildAPIFallback, BuildRouteMode: string(buildRouteMode), BuildSuperEntitled: buildSuperEntitled, + EgressNodeID: egressNodeID(value.EgressNodeID), EgressAssignmentMode: string(value.EgressAssignmentMode), EgressAssignedAt: value.EgressAssignedAt, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func valueEgressNodeID(value *uint64) uint64 { + if value == nil { + return 0 + } + return *value +} + +func egressNodeID(value uint64) *uint64 { + if value == 0 { + return nil + } + result := value + return &result +} + +func fromAccountCredentialDomain(value account.Credential) accountCredentialModel { + var expiresAt *time.Time + if !value.ExpiresAt.IsZero() { + copy := value.ExpiresAt + expiresAt = © + } + refreshDueAt := value.RefreshDueAt + if refreshDueAt == nil && value.EncryptedRefreshToken != "" && !value.ExpiresAt.IsZero() { + due := account.CredentialRefreshDueAt(value.ID, value.ExpiresAt) + refreshDueAt = &due + } + authType := value.AuthType + if authType == "" { + if value.Provider == account.ProviderWeb || value.Provider == account.ProviderConsole { + authType = account.AuthTypeSSO + } else { + authType = account.AuthTypeOAuth + } + } + return accountCredentialModel{ + AccountID: value.ID, AuthType: string(authType), ClientID: value.OIDCClientID, + EncryptedPrimary: value.EncryptedAccessToken, EncryptedRefresh: value.EncryptedRefreshToken, + EncryptedCloudflareCookie: value.EncryptedCloudflareCookie, + ExpiresAt: expiresAt, RefreshDueAt: refreshDueAt, LastRefreshAt: value.LastRefreshAt, + RefreshFailures: value.RefreshFailureCount, LastRefreshErrorStatus: value.LastRefreshErrorStatus, LastRefreshError: value.LastRefreshErrorCode, LastRefreshErrorMessage: value.LastRefreshErrorMessage, LastRefreshErrorResponse: value.LastRefreshErrorResponse, RefreshPermanent: value.RefreshPermanent, + UpdatedAt: time.Now().UTC(), + } +} + +func fromWebProfileDomain(value account.Credential) *webAccountProfileModel { + if value.Provider != account.ProviderWeb { + return nil + } + tier := value.WebTier + if tier == "" { + tier = account.WebTierAuto + } + return &webAccountProfileModel{AccountID: value.ID, Tier: string(tier), SyncedAt: value.WebTierSyncedAt, NSFWEnabledAt: value.WebNSFWEnabledAt, TermsAcceptedAt: value.WebTermsAcceptedAt, TermsAcceptedVersion: value.WebTermsAcceptedVersion, BirthDateSetAt: value.WebBirthDateSetAt, EgressIdentity: value.EgressIdentity} +} + +func accountIdentity(value account.Credential) string { + provider := string(value.Provider) + var identity string + switch { + case strings.TrimSpace(value.UserID) != "": + identity = strings.Join([]string{provider, "user", strings.TrimSpace(value.UserID), strings.TrimSpace(value.TeamID)}, "|") + case strings.TrimSpace(value.Email) != "": + identity = strings.Join([]string{provider, "email", strings.ToLower(strings.TrimSpace(value.Email)), strings.TrimSpace(value.TeamID)}, "|") + default: + identity = strings.Join([]string{provider, "source", strings.TrimSpace(value.SourceKey)}, "|") + } + sum := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(sum[:]) +} + +func toBillingDomain(value billingModel) account.Billing { + var history []account.BillingHistoryEntry + _ = json.Unmarshal([]byte(value.HistoryJSON), &history) + result := toRoutingBillingDomain(value) + result.History = history + return result +} + +func toRoutingBillingDomain(value billingModel) account.Billing { + return account.Billing{AccountID: value.AccountID, PlanCode: value.PlanCode, PlanName: value.PlanName, MonthlyLimit: value.MonthlyLimit, Used: value.Used, OnDemandCap: value.OnDemandCap, OnDemandUsed: value.OnDemandUsed, PrepaidBalance: value.PrepaidBalance, CreditUsagePercent: value.CreditUsagePercent, IsUnifiedBillingUser: value.IsUnifiedBillingUser, OnDemandEnabled: value.OnDemandEnabled, TopUpMethod: value.TopUpMethod, UsagePeriodType: value.UsagePeriodType, UsagePeriodStart: value.UsagePeriodStart, UsagePeriodEnd: value.UsagePeriodEnd, BillingPeriodStart: value.BillingPeriodStart, BillingPeriodEnd: value.BillingPeriodEnd, SyncedAt: value.SyncedAt} +} + +func toModelDomain(value modelRouteModel) model.Route { + return model.Route{ID: value.ID, PublicID: value.PublicID, Provider: account.Provider(value.Provider), UpstreamModel: value.UpstreamModel, Capability: model.Capability(value.Capability), Origin: model.Origin(value.Origin), Enabled: value.Enabled, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt} +} + +func toClientKeyDomain(value clientKeyModel, allowedModels []uint64) clientkey.Key { + providerScope, _ := clientkey.NormalizeProviderScope(clientkey.ProviderScope(value.ProviderScopeMask)) + tierScope, _ := clientkey.NormalizeTierScope(clientkey.TierScope(value.TierScopeMask)) + return clientkey.Key{ + ID: value.ID, Name: value.Name, Prefix: value.Prefix, SecretHash: value.SecretHash, EncryptedSecret: value.EncryptedSecret, + Enabled: value.Enabled, ExpiresAt: value.ExpiresAt, RPMLimit: value.RPMLimit, MaxConcurrent: value.MaxConcurrent, + BillingLimitUSDTicks: value.BillingLimitUSDTicks, BilledUsageUSDTicks: value.BilledUsageUSDTicks, ReservedUsageUSDTicks: value.ReservedUsageUSDTicks, + AllowModelAliases: value.AllowModelAliases, AllowedModels: allowedModels, + ProviderScope: providerScope, TierScope: tierScope, + LastUsedAt: value.LastUsedAt, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func toAuditDomain(value requestAuditModel) audit.Record { + return audit.Record{ + ID: value.ID, EventID: value.EventID, RequestID: value.RequestID, ClientKeyID: value.ClientKeyID, ClientKeyName: value.ClientKeyName, + ModelRouteID: value.ModelRouteID, ModelPublicID: value.ModelPublicID, ModelUpstreamModel: value.ModelUpstreamModel, + Provider: value.Provider, Operation: audit.Operation(value.Operation), UsageSource: audit.UsageSource(value.UsageSource), + AccountID: value.AccountID, AccountName: value.AccountName, + EgressNodeID: value.EgressNodeID, EgressNodeName: value.EgressNodeName, EgressScope: value.EgressScope, EgressMode: audit.EgressMode(value.EgressMode), + StatusCode: value.StatusCode, Streaming: value.Streaming, + MediaInputImages: value.MediaInputImages, MediaOutputImages: value.MediaOutputImages, MediaOutputSeconds: value.MediaOutputSeconds, + InputTokens: value.InputTokens, CachedInputTokens: value.CachedInputTokens, OutputTokens: value.OutputTokens, + ReasoningTokens: value.ReasoningTokens, TotalTokens: value.TotalTokens, CostInUSDTicks: value.CostInUSDTicks, + EstimatedCostInUSDTicks: value.EstimatedCostInUSDTicks, PricingModel: value.PricingModel, PricingVersion: value.PricingVersion, + NumSourcesUsed: value.NumSourcesUsed, NumServerSideToolsUsed: value.NumServerSideToolsUsed, + ContextInputTokens: value.ContextInputTokens, ContextOutputTokens: value.ContextOutputTokens, FirstTokenMS: value.FirstTokenMS, DurationMS: value.DurationMS, + ErrorCode: value.ErrorCode, AttemptCount: value.AttemptCount, CreatedAt: value.CreatedAt, + } +} + +func toAuditAttemptDomain(value requestAuditAttemptModel) (audit.Attempt, error) { + var responseHeaders map[string][]string + if err := json.Unmarshal([]byte(value.ResponseHeadersJSON), &responseHeaders); err != nil { + return audit.Attempt{}, err + } + var errorChain []audit.ErrorFrame + if err := json.Unmarshal([]byte(value.ErrorChainJSON), &errorChain); err != nil { + return audit.Attempt{}, err + } + return audit.Attempt{ + ID: value.ID, + AuditID: value.AuditID, + Number: value.Number, + Source: audit.AttemptSource(value.Source), + Stage: value.Stage, + AccountID: value.AccountID, + AccountName: value.AccountName, + Method: value.Method, + RequestPath: value.RequestPath, + UpstreamURL: value.UpstreamURL, + StartedAt: value.StartedAt, + DurationMS: value.DurationMS, + UpstreamStatusCode: value.UpstreamStatusCode, + UpstreamStatus: value.UpstreamStatus, + ResponseHeaders: responseHeaders, + ResponseBody: value.ResponseBody, + ResponseBodyTruncated: value.ResponseBodyTruncated, + TransportError: value.TransportError, + ErrorChain: errorChain, + }, nil +} diff --git a/backend/internal/infra/persistence/relational/media_repository.go b/backend/internal/infra/persistence/relational/media_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..33b6f3fcf88967cbcf9b08e2164ea274806f8bd8 --- /dev/null +++ b/backend/internal/infra/persistence/relational/media_repository.go @@ -0,0 +1,496 @@ +package relational + +import ( + "context" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +type MediaJobRepository struct{ db *Database } + +type MediaAssetRepository struct{ db *Database } + +func NewMediaJobRepository(db *Database) *MediaJobRepository { return &MediaJobRepository{db: db} } + +func NewMediaAssetRepository(db *Database) *MediaAssetRepository { + return &MediaAssetRepository{db: db} +} + +func (r *MediaAssetRepository) CreateMediaAsset(ctx context.Context, value media.Asset) error { + row := mediaAssetModel{ + ID: value.ID, Kind: value.Kind, StorageKey: value.StorageKey, MIMEType: value.MIMEType, + SizeBytes: value.SizeBytes, SHA256: value.SHA256, CreatedAt: value.CreatedAt, + } + return r.db.db.WithContext(ctx).Create(&row).Error +} + +func (r *MediaAssetRepository) GetMediaAsset(ctx context.Context, id string) (media.Asset, error) { + var row mediaAssetModel + if err := r.db.db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err != nil { + return media.Asset{}, mapError(err) + } + return media.Asset{ + ID: row.ID, Kind: row.Kind, StorageKey: row.StorageKey, MIMEType: row.MIMEType, + SizeBytes: row.SizeBytes, SHA256: row.SHA256, CreatedAt: row.CreatedAt, + }, nil +} + +// ListMediaAssets 通过字段投影返回符合筛选条件的稳定分页结果。 +// 默认仅列出图片,避免管理端图库混入视频资产。 +func (r *MediaAssetRepository) ListMediaAssets(ctx context.Context, input repository.MediaAssetListQuery) ([]media.Asset, int64, error) { + query := r.db.db.WithContext(ctx).Model(&mediaAssetModel{}).Where("kind = ?", "image") + if search := strings.TrimSpace(input.Page.Search); search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(id) LIKE ? OR LOWER(kind) LIKE ? OR LOWER(mime_type) LIKE ? OR LOWER(sha256) LIKE ?", pattern, pattern, pattern, pattern) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []mediaAssetModel + if err := query.Select("id", "kind", "mime_type", "size_bytes", "sha256", "created_at").Order("created_at DESC, id DESC").Offset(input.Page.Offset).Limit(input.Page.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + values := make([]media.Asset, 0, len(rows)) + for _, row := range rows { + values = append(values, media.Asset{ + ID: row.ID, Kind: row.Kind, StorageKey: row.StorageKey, MIMEType: row.MIMEType, + SizeBytes: row.SizeBytes, SHA256: row.SHA256, CreatedAt: row.CreatedAt, + }) + } + return values, total, nil +} + +// SummarizeMediaAssets 通过单次聚合查询返回图片数量和全部媒体(含视频)存储占用。 +func (r *MediaAssetRepository) SummarizeMediaAssets(ctx context.Context) (repository.MediaAssetStats, error) { + var stats repository.MediaAssetStats + err := r.db.db.WithContext(ctx).Model(&mediaAssetModel{}). + Select("COALESCE(SUM(CASE WHEN kind = 'image' THEN 1 ELSE 0 END), 0) AS total_images, COALESCE(SUM(size_bytes), 0) AS total_bytes"). + Scan(&stats).Error + return stats, err +} + +func (r *MediaAssetRepository) TotalMediaAssetBytes(ctx context.Context) (int64, error) { + var total int64 + err := r.db.db.WithContext(ctx).Model(&mediaAssetModel{}).Select("COALESCE(SUM(size_bytes), 0)").Scan(&total).Error + return total, err +} + +func (r *MediaAssetRepository) ListOldestMediaAssets(ctx context.Context, offset, limit int) ([]media.Asset, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + var rows []mediaAssetModel + if err := r.db.db.WithContext(ctx).Order("created_at ASC, id ASC").Offset(offset).Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + values := make([]media.Asset, 0, len(rows)) + for _, row := range rows { + values = append(values, media.Asset{ + ID: row.ID, Kind: row.Kind, StorageKey: row.StorageKey, MIMEType: row.MIMEType, + SizeBytes: row.SizeBytes, SHA256: row.SHA256, CreatedAt: row.CreatedAt, + }) + } + return values, nil +} + +func (r *MediaAssetRepository) DeleteMediaAsset(ctx context.Context, id string) error { + result := r.db.db.WithContext(ctx).Where("id = ?", id).Delete(&mediaAssetModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// ListProtectedMediaAssetIDs 返回不可清理的资产:进行中视频任务结果、未消费且未过期的上传票据。 +func (r *MediaAssetRepository) ListProtectedMediaAssetIDs(ctx context.Context) (map[string]struct{}, error) { + protected := make(map[string]struct{}) + var jobAssetIDs []string + if err := r.db.db.WithContext(ctx).Model(&mediaJobModel{}). + Where("result_asset_id <> '' AND status IN ?", []string{string(media.StatusQueued), string(media.StatusInProgress)}). + Pluck("result_asset_id", &jobAssetIDs).Error; err != nil { + return nil, err + } + for _, id := range jobAssetIDs { + if id != "" { + protected[id] = struct{}{} + } + } + var ticketAssetIDs []string + now := time.Now().UTC() + if err := r.db.db.WithContext(ctx).Model(&mediaUploadTicketModel{}). + Where("consumed_at IS NULL AND expires_at > ?", now). + Pluck("asset_id", &ticketAssetIDs).Error; err != nil { + return nil, err + } + for _, id := range ticketAssetIDs { + if id != "" { + protected[id] = struct{}{} + } + } + return protected, nil +} + +type MediaUploadTicketRepository struct{ db *Database } + +func NewMediaUploadTicketRepository(db *Database) *MediaUploadTicketRepository { + return &MediaUploadTicketRepository{db: db} +} + +func (r *MediaUploadTicketRepository) CreateUploadTicket(ctx context.Context, ticket repository.MediaUploadTicket) error { + row := mediaUploadTicketModel{ + TokenHash: ticket.TokenHash, AssetID: ticket.AssetID, JobID: ticket.JobID, + MaxBytes: ticket.MaxBytes, AllowedMIME: ticket.AllowedMIME, + ExpiresAt: ticket.ExpiresAt, ConsumedAt: ticket.ConsumedAt, CreatedAt: ticket.CreatedAt, + } + return r.db.db.WithContext(ctx).Create(&row).Error +} + +func (r *MediaUploadTicketRepository) GetUploadTicketByHash(ctx context.Context, tokenHash string) (repository.MediaUploadTicket, error) { + var row mediaUploadTicketModel + if err := r.db.db.WithContext(ctx).Where("token_hash = ?", tokenHash).First(&row).Error; err != nil { + return repository.MediaUploadTicket{}, mapError(err) + } + return ticketToDomain(row), nil +} + +func (r *MediaUploadTicketRepository) ConsumeUploadTicket(ctx context.Context, tokenHash string, now time.Time) (repository.MediaUploadTicket, bool, error) { + var ticket repository.MediaUploadTicket + consumed := false + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&mediaUploadTicketModel{}). + Where("token_hash = ? AND consumed_at IS NULL AND expires_at > ?", tokenHash, now). + Update("consumed_at", now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + var row mediaUploadTicketModel + if err := tx.Where("token_hash = ?", tokenHash).First(&row).Error; err != nil { + return err + } + ticket = ticketToDomain(row) + consumed = true + return nil + }) + if err != nil { + return repository.MediaUploadTicket{}, false, err + } + return ticket, consumed, nil +} + +// ReleaseUploadTicket 撤销一次尚未落资产的消费,使同一 token 可再次 PUT。 +func (r *MediaUploadTicketRepository) ReleaseUploadTicket(ctx context.Context, tokenHash string) (bool, error) { + // 使用 map 写入 NULL:GORM Update(column, nil) 可能被忽略。 + result := r.db.db.WithContext(ctx).Model(&mediaUploadTicketModel{}). + Where("token_hash = ? AND consumed_at IS NOT NULL", tokenHash). + Updates(map[string]any{"consumed_at": nil}) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// DeleteUploadTicketByHash 按 token_hash 精确删除票据;目标不存在时幂等返回 nil。 +func (r *MediaUploadTicketRepository) DeleteUploadTicketByHash(ctx context.Context, tokenHash string) error { + tokenHash = strings.TrimSpace(tokenHash) + if tokenHash == "" { + return nil + } + return r.db.db.WithContext(ctx).Where("token_hash = ?", tokenHash).Delete(&mediaUploadTicketModel{}).Error +} + +// DeleteUploadTicketsByJobID 删除任务关联的上传票据,终止后续延迟上传。 +func (r *MediaUploadTicketRepository) DeleteUploadTicketsByJobID(ctx context.Context, jobID string) error { + jobID = strings.TrimSpace(jobID) + if jobID == "" { + return nil + } + return r.db.db.WithContext(ctx).Where("job_id = ?", jobID).Delete(&mediaUploadTicketModel{}).Error +} + +// DeleteExpiredUploadTickets 删除已过期票据行(已消费或未消费均可);未过期票据与媒体资产不受影响。 +func (r *MediaUploadTicketRepository) DeleteExpiredUploadTickets(ctx context.Context, before time.Time, limit int) (int64, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + var hashes []string + if err := r.db.db.WithContext(ctx).Model(&mediaUploadTicketModel{}). + Where("expires_at < ?", before). + Order("expires_at ASC").Limit(limit).Pluck("token_hash", &hashes).Error; err != nil { + return 0, err + } + if len(hashes) == 0 { + return 0, nil + } + result := r.db.db.WithContext(ctx).Where("token_hash IN ?", hashes).Delete(&mediaUploadTicketModel{}) + return result.RowsAffected, result.Error +} + +func (r *MediaUploadTicketRepository) BindJobResultAsset(ctx context.Context, jobID, assetID string) error { + result := r.db.db.WithContext(ctx).Model(&mediaJobModel{}). + Where("id = ?", jobID). + Updates(map[string]any{"result_asset_id": assetID, "updated_at": time.Now().UTC()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func ticketToDomain(row mediaUploadTicketModel) repository.MediaUploadTicket { + return repository.MediaUploadTicket{ + TokenHash: row.TokenHash, AssetID: row.AssetID, JobID: row.JobID, + MaxBytes: row.MaxBytes, AllowedMIME: row.AllowedMIME, + ExpiresAt: row.ExpiresAt, ConsumedAt: row.ConsumedAt, CreatedAt: row.CreatedAt, + } +} + +func (r *MediaJobRepository) CreateMediaJob(ctx context.Context, value media.Job) error { + return r.db.db.WithContext(ctx).Create(mediaJobFromDomain(value)).Error +} + +func (r *MediaJobRepository) GetMediaJob(ctx context.Context, id string, clientKeyID uint64) (media.Job, error) { + var row mediaJobModel + // Public polling and content download do not consume the potentially large, + // immutable reference payload. Keep it off this hot path. + if err := r.db.db.WithContext(ctx).Omit("input_json").Where("id = ? AND client_key_id = ?", id, clientKeyID).First(&row).Error; err != nil { + return media.Job{}, mapError(err) + } + return mediaJobToDomain(row), nil +} + +// GetMediaJobsByIDs 返回管理端明确选择的完整视频任务。 +func (r *MediaJobRepository) GetMediaJobsByIDs(ctx context.Context, ids []string) ([]media.Job, error) { + if len(ids) == 0 { + return []media.Job{}, nil + } + var rows []mediaJobModel + if err := r.db.db.WithContext(ctx).Omit("input_json").Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + values := make([]media.Job, 0, len(rows)) + for _, row := range rows { + values = append(values, mediaJobToDomain(row)) + } + return values, nil +} + +func (r *MediaJobRepository) UpdateMediaJob(ctx context.Context, value media.Job) error { + updates := mediaJobFromDomain(value) + query := r.db.db.WithContext(ctx).Model(&mediaJobModel{}).Where("id = ?", value.ID) + if value.ClaimToken != "" { + query = query.Where("claim_token = ?", value.ClaimToken) + } + // InputJSON and InputImageCount are immutable creation metadata. Progress and + // terminal updates must not resend a multi-megabyte Base64 payload. + result := query.Select("request_id", "client_key_name", "account_id", "account_name", "egress_node_id", "egress_node_name", "egress_scope", "egress_mode", "provider", "model", "model_route_id", "upstream_model", "prompt", "seconds", "size", "quality", "status", "progress", "upstream_url", "result_asset_id", "content_type", "error_code", "error_message", "lease_until", "claim_token", "updated_at", "completed_at", "usage_recorded_at").Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// DeleteMediaJob 仅删除终态任务,避免管理端与视频 Worker 并发修改同一任务。 +func (r *MediaJobRepository) DeleteMediaJob(ctx context.Context, id string) error { + result := r.db.db.WithContext(ctx). + Where("id = ? AND status IN ?", id, []media.Status{media.StatusCompleted, media.StatusFailed}). + Delete(&mediaJobModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// ListMediaJobs 通过固定搜索字段和排序白名单返回稳定分页结果。 +func (r *MediaJobRepository) ListMediaJobs(ctx context.Context, input repository.MediaJobListQuery) ([]media.Job, int64, error) { + query := r.db.db.WithContext(ctx).Model(&mediaJobModel{}) + if input.Filter.Status != "" { + query = query.Where("status = ?", input.Filter.Status) + } + if search := strings.TrimSpace(input.Page.Search); search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(id) LIKE ? OR LOWER(prompt) LIKE ? OR LOWER(model) LIKE ? OR LOWER(account_name) LIKE ? OR LOWER(client_key_name) LIKE ?", pattern, pattern, pattern, pattern, pattern) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []mediaJobModel + query = applyStableSort(query, input.Page.Sort, map[string]sortSpec{ + "prompt": {expression: "LOWER(prompt)"}, + "model": {expression: "LOWER(model)"}, + "status": {expression: "status"}, + "progress": {expression: "progress", defaultDirection: repository.SortDescending}, + "spec": {expression: "LOWER(size) || ' ' || LOWER(quality)"}, + "account": {expression: "LOWER(account_name)"}, + "createdAt": {expression: "created_at", defaultDirection: repository.SortDescending}, + "completedAt": {expression: "completed_at", nullsLast: true, defaultDirection: repository.SortDescending}, + }, sortSpec{expression: "created_at", defaultDirection: repository.SortDescending}, "id") + if err := query.Select( + "id", "client_key_name", "account_name", "model", "prompt", "seconds", "size", "quality", + "status", "progress", "result_asset_id", "error_message", "created_at", "completed_at", + ).Offset(input.Page.Offset).Limit(input.Page.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + values := make([]media.Job, 0, len(rows)) + for _, row := range rows { + values = append(values, mediaJobToDomain(row)) + } + return values, total, nil +} + +// SummarizeMediaJobs 通过单次条件聚合查询统计全部任务状态。 +func (r *MediaJobRepository) SummarizeMediaJobs(ctx context.Context) (repository.MediaJobStats, error) { + var stats repository.MediaJobStats + err := r.db.db.WithContext(ctx).Model(&mediaJobModel{}).Select(` + COUNT(*) AS total_jobs, + COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS completed, + COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS failed, + COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS in_progress, + COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS queued`, + media.StatusCompleted, media.StatusFailed, media.StatusInProgress, media.StatusQueued, + ).Scan(&stats).Error + return stats, err +} + +// ListUnrecordedTerminalMediaJobs 返回尚未完成审计写入的成功或失败任务。 +func (r *MediaJobRepository) ListUnrecordedTerminalMediaJobs(ctx context.Context, limit int) ([]media.Job, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + var rows []mediaJobModel + if err := r.db.db.WithContext(ctx).Omit("input_json").Where("status IN ? AND usage_recorded_at IS NULL", []media.Status{media.StatusCompleted, media.StatusFailed}).Order("completed_at ASC, id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + values := make([]media.Job, 0, len(rows)) + for _, row := range rows { + values = append(values, mediaJobToDomain(row)) + } + return values, nil +} + +func (r *MediaJobRepository) MarkMediaJobUsageRecorded(ctx context.Context, id string, recordedAt time.Time) error { + terminalStatuses := []media.Status{media.StatusCompleted, media.StatusFailed} + // Once the durable audit owns the input-image count, terminal jobs no longer + // need raw Base64 references. Compact them in the same idempotent update. + result := r.db.db.WithContext(ctx).Model(&mediaJobModel{}). + Where("id = ? AND status IN ? AND usage_recorded_at IS NULL", id, terminalStatuses). + Updates(map[string]any{"usage_recorded_at": recordedAt, "input_json": "{}"}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + var count int64 + if err := r.db.db.WithContext(ctx).Model(&mediaJobModel{}).Where("id = ? AND status IN ? AND usage_recorded_at IS NOT NULL", id, terminalStatuses).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return repository.ErrNotFound + } + } + return nil +} + +func (r *MediaJobRepository) ListRecoverableMediaJobs(ctx context.Context, limit int) ([]media.Job, error) { + var rows []mediaJobModel + now := time.Now().UTC() + // Recovery only enqueues IDs. The bounded worker reads one complete payload + // after it atomically claims the job. + if err := r.db.db.WithContext(ctx).Select("id").Where("status = ? OR (status = ? AND (lease_until IS NULL OR lease_until <= ?))", media.StatusQueued, media.StatusInProgress, now).Order("created_at ASC, id ASC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + values := make([]media.Job, 0, len(rows)) + for _, row := range rows { + values = append(values, mediaJobToDomain(row)) + } + return values, nil +} + +// TryClaimMediaJob 原子认领新任务或租约已过期的任务,避免多实例重复执行。 +func (r *MediaJobRepository) TryClaimMediaJob(ctx context.Context, id string, now, leaseUntil time.Time, claimToken string) (media.Job, bool, error) { + if claimToken == "" { + return media.Job{}, false, repository.ErrConflict + } + result := r.db.db.WithContext(ctx).Model(&mediaJobModel{}). + Where("id = ? AND (status = ? OR (status = ? AND (lease_until IS NULL OR lease_until <= ?)))", id, media.StatusQueued, media.StatusInProgress, now). + Updates(map[string]any{"status": media.StatusInProgress, "lease_until": leaseUntil, "claim_token": claimToken, "updated_at": now}) + if result.Error != nil { + return media.Job{}, false, result.Error + } + if result.RowsAffected == 0 { + return media.Job{}, false, nil + } + var row mediaJobModel + if err := r.db.db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err != nil { + return media.Job{}, false, mapError(err) + } + return mediaJobToDomain(row), true, nil +} + +func mediaJobFromDomain(value media.Job) *mediaJobModel { + return &mediaJobModel{ + ID: value.ID, RequestID: value.RequestID, ClientKeyID: value.ClientKeyID, ClientKeyName: value.ClientKeyName, + AccountID: mediaJobAccountID(value.AccountID), AccountName: value.AccountName, + EgressNodeID: value.EgressNodeID, EgressNodeName: value.EgressNodeName, EgressScope: value.EgressScope, EgressMode: value.EgressMode, + Provider: value.Provider, + Model: value.Model, ModelRouteID: value.ModelRouteID, UpstreamModel: value.UpstreamModel, + Prompt: value.Prompt, Seconds: value.Seconds, Size: value.Size, Quality: value.Quality, + Status: string(value.Status), Progress: value.Progress, InputJSON: value.InputJSON, InputImageCount: mediaJobInputImageCount(value.InputImageCount), UpstreamURL: value.UpstreamURL, + ResultAssetID: value.ResultAssetID, ContentType: value.ContentType, ErrorCode: value.ErrorCode, ErrorMessage: value.ErrorMessage, + LeaseUntil: value.LeaseUntil, ClaimToken: value.ClaimToken, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + CompletedAt: value.CompletedAt, UsageRecordedAt: value.UsageRecordedAt, + } +} + +func mediaJobToDomain(row mediaJobModel) media.Job { + var accountID uint64 + if row.AccountID != nil { + accountID = *row.AccountID + } + var inputImageCount int + if row.InputImageCount != nil { + inputImageCount = *row.InputImageCount + } + return media.Job{ + ID: row.ID, RequestID: row.RequestID, ClientKeyID: row.ClientKeyID, ClientKeyName: row.ClientKeyName, + AccountID: accountID, AccountName: row.AccountName, + EgressNodeID: row.EgressNodeID, EgressNodeName: row.EgressNodeName, EgressScope: row.EgressScope, EgressMode: row.EgressMode, + Provider: row.Provider, + Model: row.Model, ModelRouteID: row.ModelRouteID, UpstreamModel: row.UpstreamModel, + Prompt: row.Prompt, Seconds: row.Seconds, Size: row.Size, Quality: row.Quality, + Status: media.Status(row.Status), Progress: row.Progress, InputJSON: row.InputJSON, InputImageCount: inputImageCount, UpstreamURL: row.UpstreamURL, + ResultAssetID: row.ResultAssetID, ContentType: row.ContentType, ErrorCode: row.ErrorCode, ErrorMessage: row.ErrorMessage, + LeaseUntil: row.LeaseUntil, ClaimToken: row.ClaimToken, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + CompletedAt: row.CompletedAt, UsageRecordedAt: row.UsageRecordedAt, + } +} + +func mediaJobInputImageCount(value int) *int { return &value } + +func mediaJobAccountID(value uint64) *uint64 { + if value == 0 { + return nil + } + return &value +} diff --git a/backend/internal/infra/persistence/relational/media_repository_test.go b/backend/internal/infra/persistence/relational/media_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7881ce6d41a903cc3dd52076c0193d04589e1241 --- /dev/null +++ b/backend/internal/infra/persistence/relational/media_repository_test.go @@ -0,0 +1,454 @@ +package relational + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +func TestMediaJobRepositoryListMediaJobsPaginatesAndFilters(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, + AuthType: accountdomain.AuthTypeSSO, + WebTier: accountdomain.WebTierBasic, + Name: "media-list-account", + SourceKey: "media-list-account", + EncryptedAccessToken: testEncryptedToken, + AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "media-list-key", Prefix: "media-list-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + + jobRepo := NewMediaJobRepository(database) + now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + jobs := []mediadomain.Job{ + testMediaJob("media_job_completed_old", accountValue.ID, key.ID, mediadomain.StatusCompleted, now.Add(-4*time.Hour)), + testMediaJob("media_job_queued_mid", accountValue.ID, key.ID, mediadomain.StatusQueued, now.Add(-3*time.Hour)), + testMediaJob("media_job_failed_newer", accountValue.ID, key.ID, mediadomain.StatusFailed, now.Add(-2*time.Hour)), + testMediaJob("media_job_completed_new", accountValue.ID, key.ID, mediadomain.StatusCompleted, now.Add(-time.Hour)), + } + jobs[0].Prompt = "A quiet harbor" + jobs[0].ResultAssetID = "vid_media_list_00000001" + jobs[1].Prompt = "Northern lights" + jobs[2].Prompt = "Desert sunrise" + jobs[3].Prompt = "City skyline" + for _, job := range jobs { + if err := jobRepo.CreateMediaJob(ctx, job); err != nil { + t.Fatal(err) + } + } + + firstPage, total, err := jobRepo.ListMediaJobs(ctx, repository.MediaJobListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 2}, + }) + if err != nil { + t.Fatal(err) + } + if total != 4 { + t.Fatalf("total = %d", total) + } + assertMediaJobIDs(t, firstPage, "media_job_completed_new", "media_job_failed_newer") + + secondPage, total, err := jobRepo.ListMediaJobs(ctx, repository.MediaJobListQuery{ + Page: repository.PageQuery{Offset: 2, Limit: 2}, + }) + if err != nil { + t.Fatal(err) + } + if total != 4 { + t.Fatalf("second page total = %d", total) + } + assertMediaJobIDs(t, secondPage, "media_job_queued_mid", "media_job_completed_old") + + completed, total, err := jobRepo.ListMediaJobs(ctx, repository.MediaJobListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 10}, + Filter: repository.MediaJobListFilter{Status: string(mediadomain.StatusCompleted)}, + }) + if err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("completed total = %d", total) + } + assertMediaJobIDs(t, completed, "media_job_completed_new", "media_job_completed_old") + if completed[1].ResultAssetID != jobs[0].ResultAssetID { + t.Fatalf("completed asset ID = %q", completed[1].ResultAssetID) + } + + searched, total, err := jobRepo.ListMediaJobs(ctx, repository.MediaJobListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 1, Search: "northern"}, + }) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Fatalf("searched total = %d", total) + } + assertMediaJobIDs(t, searched, "media_job_queued_mid") + + sorted, total, err := jobRepo.ListMediaJobs(ctx, repository.MediaJobListQuery{ + Page: repository.PageQuery{ + Offset: 0, + Limit: 4, + Sort: repository.SortQuery{Field: "prompt", Direction: repository.SortAscending}, + }, + }) + if err != nil { + t.Fatal(err) + } + if total != 4 { + t.Fatalf("sorted total = %d", total) + } + assertMediaJobIDs(t, sorted, "media_job_completed_old", "media_job_completed_new", "media_job_failed_newer", "media_job_queued_mid") + + stats, err := jobRepo.SummarizeMediaJobs(ctx) + if err != nil { + t.Fatal(err) + } + if stats.TotalJobs != 4 || stats.Completed != 2 || stats.Failed != 1 || stats.InProgress != 0 || stats.Queued != 1 { + t.Fatalf("stats = %#v", stats) + } +} + +func TestMediaJobRepositoryKeepsLargeInputOffHotPaths(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "large-input-account", SourceKey: "large-input-account", + EncryptedAccessToken: testEncryptedToken, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "large-input-key", Prefix: "large-input-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + repository := NewMediaJobRepository(database) + now := time.Now().UTC() + job := testMediaJob("media_job_large_input", accountValue.ID, key.ID, mediadomain.StatusQueued, now) + job.InputJSON = `{"image_urls":["` + strings.Repeat("A", (1<<20)+1) + `"]}` + job.InputImageCount = 1 + if err := repository.CreateMediaJob(ctx, job); err != nil { + t.Fatal(err) + } + + polled, err := repository.GetMediaJob(ctx, job.ID, key.ID) + if err != nil || polled.InputJSON != "" || polled.InputImageCount != 1 { + t.Fatalf("poll projection input len=%d count=%d err=%v", len(polled.InputJSON), polled.InputImageCount, err) + } + recoverable, err := repository.ListRecoverableMediaJobs(ctx, 1000) + if err != nil || len(recoverable) != 1 || recoverable[0].ID != job.ID || recoverable[0].InputJSON != "" { + t.Fatalf("recoverable jobs=%#v err=%v", recoverable, err) + } + claimed, ok, err := repository.TryClaimMediaJob(ctx, job.ID, now, now.Add(time.Hour), "claim_large_input_1234") + if err != nil || !ok || claimed.InputJSON != job.InputJSON || claimed.InputImageCount != 1 { + t.Fatalf("claimed input len=%d count=%d ok=%v err=%v", len(claimed.InputJSON), claimed.InputImageCount, ok, err) + } + claimed.Progress = 20 + claimed.InputJSON = `{"image_urls":["should-not-overwrite"]}` + if err := repository.UpdateMediaJob(ctx, claimed); err != nil { + t.Fatal(err) + } + var storedInput string + if err := database.db.WithContext(ctx).Model(&mediaJobModel{}).Where("id = ?", job.ID).Pluck("input_json", &storedInput).Error; err != nil || storedInput != job.InputJSON { + t.Fatalf("progress update changed immutable input len=%d err=%v", len(storedInput), err) + } + completedAt := now.Add(time.Minute) + claimed.Status, claimed.Progress, claimed.CompletedAt = mediadomain.StatusCompleted, 100, &completedAt + if err := repository.UpdateMediaJob(ctx, claimed); err != nil { + t.Fatal(err) + } + terminal, err := repository.ListUnrecordedTerminalMediaJobs(ctx, 200) + if err != nil || len(terminal) != 1 || terminal[0].InputJSON != "" || terminal[0].InputImageCount != 1 { + t.Fatalf("terminal jobs=%#v err=%v", terminal, err) + } + if err := repository.MarkMediaJobUsageRecorded(ctx, job.ID, completedAt); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&mediaJobModel{}).Where("id = ?", job.ID).Pluck("input_json", &storedInput).Error; err != nil || storedInput != "{}" { + t.Fatalf("recorded terminal input=%q err=%v", storedInput, err) + } +} + +func TestAccountDeleteDetachesTerminalMediaJobsAndRejectsActiveJobs(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + accountValue, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "media-delete-account", SourceKey: "media-delete-account", + EncryptedAccessToken: testEncryptedToken, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "media-delete-key", Prefix: "media-delete-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + job := testMediaJob("media_job_account_delete", accountValue.ID, key.ID, mediadomain.StatusCompleted, time.Now().UTC()) + job.AccountName = accountValue.Name + jobs := NewMediaJobRepository(database) + if err := jobs.CreateMediaJob(ctx, job); err != nil { + t.Fatal(err) + } + + if err := accounts.Delete(ctx, accountValue.ID); err != nil { + t.Fatalf("delete account with terminal media job: %v", err) + } + stored, err := jobs.GetMediaJobsByIDs(ctx, []string{job.ID}) + if err != nil || len(stored) != 1 || stored[0].AccountID != 0 || stored[0].AccountName != accountValue.Name { + t.Fatalf("detached terminal job = %#v, error = %v", stored, err) + } + + activeAccount, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "media-active-account", SourceKey: "media-active-account", + EncryptedAccessToken: testEncryptedToken, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + activeJob := testMediaJob("media_job_account_active", activeAccount.ID, key.ID, mediadomain.StatusInProgress, time.Now().UTC()) + if err := jobs.CreateMediaJob(ctx, activeJob); err != nil { + t.Fatal(err) + } + if err := accounts.Delete(ctx, activeAccount.ID); !errors.Is(err, repository.ErrConflict) || !strings.Contains(err.Error(), "进行中") { + t.Fatalf("delete account with active media job error = %v", err) + } + if _, err := accounts.Get(ctx, activeAccount.ID); err != nil { + t.Fatalf("active job conflict removed account: %v", err) + } +} + +func TestMediaAssetRepositoryListMediaAssetsPaginatesAndCounts(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + assetRepo := NewMediaAssetRepository(database) + + stats, err := assetRepo.SummarizeMediaAssets(ctx) + if err != nil { + t.Fatal(err) + } + if stats.TotalImages != 0 || stats.TotalBytes != 0 { + t.Fatalf("initial stats = %#v", stats) + } + + now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + assets := []mediadomain.Asset{ + testMediaAsset("media_asset_0001", "media/asset-0001.png", now.Add(-3*time.Hour)), + testMediaAsset("media_asset_0002", "media/asset-0002.png", now.Add(-2*time.Hour)), + testMediaAsset("media_asset_0003", "media/asset-0003.png", now.Add(-time.Hour)), + } + for _, asset := range assets { + if err := assetRepo.CreateMediaAsset(ctx, asset); err != nil { + t.Fatal(err) + } + } + + firstPage, total, err := assetRepo.ListMediaAssets(ctx, repository.MediaAssetListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 2}, + }) + if err != nil { + t.Fatal(err) + } + if total != 3 { + t.Fatalf("total = %d", total) + } + assertMediaAssetIDs(t, firstPage, "media_asset_0003", "media_asset_0002") + + secondPage, total, err := assetRepo.ListMediaAssets(ctx, repository.MediaAssetListQuery{ + Page: repository.PageQuery{Offset: 2, Limit: 2}, + }) + if err != nil { + t.Fatal(err) + } + if total != 3 { + t.Fatalf("second page total = %d", total) + } + assertMediaAssetIDs(t, secondPage, "media_asset_0001") + + searched, total, err := assetRepo.ListMediaAssets(ctx, repository.MediaAssetListQuery{ + Page: repository.PageQuery{Offset: 0, Limit: 1, Search: "0001"}, + }) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Fatalf("searched total = %d", total) + } + assertMediaAssetIDs(t, searched, "media_asset_0001") + + stats, err = assetRepo.SummarizeMediaAssets(ctx) + if err != nil { + t.Fatal(err) + } + if stats.TotalImages != 3 || stats.TotalBytes != 3*1024 { + t.Fatalf("stats = %#v", stats) + } +} + +func testMediaJob(id string, accountID, clientKeyID uint64, status mediadomain.Status, createdAt time.Time) mediadomain.Job { + job := mediadomain.Job{ + ID: id, + RequestID: "request-" + id, + ClientKeyID: clientKeyID, + ClientKeyName: "media-list-key", + AccountID: accountID, + AccountName: "media-list-account", + Provider: "grok_web", + Model: "grok-imagine-video", + ModelRouteID: 1, + UpstreamModel: "grok-imagine-video-upstream", + Prompt: "test prompt", + Seconds: 8, + Size: "16:9", + Quality: "720p", + Status: status, + InputJSON: `{}`, + CreatedAt: createdAt, + UpdatedAt: createdAt, + } + if status == mediadomain.StatusCompleted || status == mediadomain.StatusFailed { + job.Progress = 100 + completedAt := createdAt.Add(time.Minute) + job.CompletedAt = &completedAt + } + return job +} + +func TestMediaUploadTicketRepositoryDeleteUploadTicketByHash(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewMediaUploadTicketRepository(database) + now := time.Now().UTC() + hash := strings.Repeat("ab", 32) + otherHash := strings.Repeat("cd", 32) + if err := repo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hash, AssetID: "vid_delete_by_hash_1", JobID: "job_delete_by_hash", + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + if err := repo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: otherHash, AssetID: "vid_delete_by_hash_2", JobID: "job_delete_keep", + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + + if err := repo.DeleteUploadTicketByHash(ctx, hash); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := repo.GetUploadTicketByHash(ctx, hash); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("deleted ticket still readable: %v", err) + } + // 精确删除不得影响其他票据。 + if _, err := repo.GetUploadTicketByHash(ctx, otherHash); err != nil { + t.Fatalf("other ticket removed: %v", err) + } + // 幂等:再次删除缺失行成功。 + if err := repo.DeleteUploadTicketByHash(ctx, hash); err != nil { + t.Fatalf("idempotent delete: %v", err) + } + if err := repo.DeleteUploadTicketByHash(ctx, ""); err != nil { + t.Fatalf("empty hash delete: %v", err) + } +} + +func TestMediaUploadTicketRepositoryConsumeRollsBackWhenTicketReadFails(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewMediaUploadTicketRepository(database) + now := time.Now().UTC() + hash := strings.Repeat("ef", 32) + if err := repo.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hash, AssetID: "vid_consume_rollback_1", JobID: "job_consume_rollback", + MaxBytes: 1024, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + + injected := errors.New("injected ticket read failure") + callbackName := "test:fail_consumed_ticket_read" + failed := false + if err := database.db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { + if !failed && tx.Statement != nil && tx.Statement.Table == "media_upload_tickets" { + failed = true + _ = tx.AddError(injected) + } + }); err != nil { + t.Fatal(err) + } + + _, consumed, err := repo.ConsumeUploadTicket(ctx, hash, now) + if removeErr := database.db.Callback().Query().Remove(callbackName); removeErr != nil { + t.Fatal(removeErr) + } + if !errors.Is(err, injected) || consumed { + t.Fatalf("consume = (%v, %v), want injected error and consumed=false", consumed, err) + } + if !failed { + t.Fatal("ticket read failure callback did not run") + } + ticket, err := repo.GetUploadTicketByHash(ctx, hash) + if err != nil { + t.Fatal(err) + } + if ticket.ConsumedAt != nil { + t.Fatalf("consume transaction was not rolled back: consumed_at=%v", ticket.ConsumedAt) + } +} + +func testMediaAsset(id, storageKey string, createdAt time.Time) mediadomain.Asset { + return mediadomain.Asset{ + ID: id, + Kind: "image", + StorageKey: storageKey, + MIMEType: "image/png", + SizeBytes: 1024, + SHA256: strings.Repeat("a", 64), + CreatedAt: createdAt, + } +} + +func assertMediaJobIDs(t *testing.T, values []mediadomain.Job, expected ...string) { + t.Helper() + if len(values) != len(expected) { + t.Fatalf("len(values) = %d, expected %d: %#v", len(values), len(expected), values) + } + for index, id := range expected { + if values[index].ID != id { + t.Fatalf("values[%d].ID = %q, expected %q; values = %#v", index, values[index].ID, id, values) + } + } +} + +func assertMediaAssetIDs(t *testing.T, values []mediadomain.Asset, expected ...string) { + t.Helper() + if len(values) != len(expected) { + t.Fatalf("len(values) = %d, expected %d: %#v", len(values), len(expected), values) + } + for index, id := range expected { + if values[index].ID != id { + t.Fatalf("values[%d].ID = %q, expected %q; values = %#v", index, values[index].ID, id, values) + } + } +} diff --git a/backend/internal/infra/persistence/relational/model_capability_repository_test.go b/backend/internal/infra/persistence/relational/model_capability_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..201139de8ce16d95e2ab50ced85e18c01b79a428 --- /dev/null +++ b/backend/internal/infra/persistence/relational/model_capability_repository_test.go @@ -0,0 +1,571 @@ +package relational + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestModelCapabilitiesAggregateAndGateEnabledRoutes(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "capabilities.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + accounts := NewAccountRepository(database) + models := NewModelRepository(database) + first, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "basic", SourceKey: "basic", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + second, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "premium", SourceKey: "premium", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + if err := models.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-basic", "grok-premium"}); err != nil { + t.Fatal(err) + } + + beforeSync, err := models.ListEnabled(ctx) + if err != nil || len(beforeSync) != 0 { + t.Fatalf("before sync = %#v, err = %v", beforeSync, err) + } + now := time.Now().UTC() + if err := models.ReplaceAccountCapabilities(ctx, first.ID, []string{"grok-basic"}, now); err != nil { + t.Fatal(err) + } + if synced, err := models.HasSuccessfulAccountSync(ctx, first.ID); err != nil || !synced { + t.Fatalf("first account sync state = %v, err = %v", synced, err) + } + if err := models.ReplaceAccountCapabilities(ctx, second.ID, []string{"grok-basic", "grok-premium"}, now); err != nil { + t.Fatal(err) + } + + values, total, err := models.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 20}}) + if err != nil || total != 2 { + t.Fatalf("list total = %d, err = %v", total, err) + } + byModel := make(map[string]struct{ supported, synced, total int }) + for _, value := range values { + byModel[value.UpstreamModel] = struct{ supported, synced, total int }{value.SupportedAccounts, value.SyncedAccounts, value.TotalAccounts} + } + if got := byModel["grok-basic"]; got.supported != 2 || got.synced != 2 || got.total != 2 { + t.Fatalf("basic availability = %#v", got) + } + if got := byModel["grok-premium"]; got.supported != 1 || got.synced != 2 || got.total != 2 { + t.Fatalf("premium availability = %#v", got) + } + if err := models.MarkAccountCapabilitySyncFailed(ctx, second.ID, now.Add(30*time.Second), "temporary failure"); err != nil { + t.Fatal(err) + } + if _, err := models.GetByPublicID(ctx, "grok-premium"); err != nil { + t.Fatalf("last successful capability must survive a failed refresh: %v", err) + } + + if err := models.ReplaceAccountCapabilities(ctx, second.ID, []string{"grok-basic"}, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + enabled, err := models.ListEnabled(ctx) + if err != nil || len(enabled) != 1 || enabled[0].UpstreamModel != "grok-basic" { + t.Fatalf("enabled = %#v, err = %v", enabled, err) + } + if _, err := models.GetByPublicID(ctx, "grok-premium"); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("premium route err = %v", err) + } +} + +func TestBuildPaidCapabilitiesAreSharedAcrossActiveSuperAccounts(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + models := NewModelRepository(database) + + createAccount := func(name string) account.Credential { + t.Helper() + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: name, + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + return value + } + observer := createAccount("super-observer") + peer := createAccount("super-peer") + freeObserver := createAccount("free-observer") + freePeer := createAccount("free-peer") + now := time.Now().UTC() + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: observer.ID, MonthlyLimit: 100, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: peer.ID, OnDemandCap: 50, SyncedAt: now}); err != nil { + t.Fatal(err) + } + + const sharedModel = "grok-super-shared" + if err := models.UpsertDiscovered(ctx, account.ProviderBuild, []string{sharedModel, "grok-4.5"}); err != nil { + t.Fatal(err) + } + for accountID, capabilities := range map[uint64][]string{ + observer.ID: {sharedModel, "grok-4.5"}, + peer.ID: {"grok-4.5"}, + freeObserver.ID: {sharedModel}, + freePeer.ID: {"grok-4.5"}, + } { + if err := models.ReplaceAccountCapabilities(ctx, accountID, capabilities, now); err != nil { + t.Fatal(err) + } + } + + route, err := models.GetByProviderUpstream(ctx, account.ProviderBuild, sharedModel) + if err != nil { + t.Fatal(err) + } + route, err = models.Get(ctx, route.ID) + if err != nil { + t.Fatal(err) + } + if route.SupportedAccounts != 3 || route.TotalAccounts != 4 { + t.Fatalf("shared route availability = %#v", route) + } + if _, _, err := models.List(ctx, repository.ModelListQuery{ + Page: repository.PageQuery{Limit: 20, Sort: repository.SortQuery{Field: "accountSupport", Direction: repository.SortDescending}}, + }); err != nil { + t.Fatalf("sort by shared account support: %v", err) + } + candidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, 0, sharedModel, "") + if err != nil { + t.Fatal(err) + } + byID := make(map[uint64]account.RoutingCandidate, len(candidates)) + for _, candidate := range candidates { + byID[candidate.Credential.ID] = candidate + } + for _, accountID := range []uint64{observer.ID, peer.ID, freeObserver.ID} { + if candidate := byID[accountID]; !candidate.ModelCapabilityKnown || !candidate.SupportsModel { + t.Fatalf("account %d should support shared model: %#v", accountID, candidate) + } + } + if candidate := byID[freePeer.ID]; !candidate.ModelCapabilityKnown || candidate.SupportsModel { + t.Fatalf("free peer must keep its own capability snapshot: %#v", candidate) + } + + observer.Enabled = false + if _, err := accounts.Update(ctx, observer); err != nil { + t.Fatal(err) + } + route, err = models.Get(ctx, route.ID) + if err != nil { + t.Fatal(err) + } + if route.SupportedAccounts != 1 || route.TotalAccounts != 3 { + t.Fatalf("disabled observer must not grant shared entitlement: %#v", route) + } + candidates, err = accounts.ListRoutingCandidates(ctx, account.ProviderBuild, 0, sharedModel, "") + if err != nil { + t.Fatal(err) + } + for _, candidate := range candidates { + if candidate.Credential.ID == peer.ID && candidate.SupportsModel { + t.Fatalf("paid peer should lose shared capability without an active paid observer: %#v", candidate) + } + } +} + +func TestPublicModelNameResolvesAcrossAvailableProviders(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + models := NewModelRepository(database) + accounts := NewAccountRepository(database) + + build, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "shared-build", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + console, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, Name: "console", SourceKey: "shared-console", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + for _, providerValue := range []account.Provider{account.ProviderBuild, account.ProviderConsole} { + if err := models.UpsertDiscovered(ctx, providerValue, []string{"grok-shared"}); err != nil { + t.Fatal(err) + } + } + now := time.Now().UTC() + if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"grok-shared"}, now); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, console.ID, []string{"grok-shared"}, now); err != nil { + t.Fatal(err) + } + + routes, err := models.GetByPublicIDCandidates(ctx, "grok-shared") + if err != nil || len(routes) != 2 || routes[0].Provider != account.ProviderBuild || routes[1].Provider != account.ProviderConsole { + t.Fatalf("shared routes = %#v, err = %v", routes, err) + } + explicit, err := models.GetByPublicIDCandidates(ctx, "Console/grok-shared") + if err != nil || len(explicit) != 1 || explicit[0].Provider != account.ProviderConsole { + t.Fatalf("explicit Console route = %#v, err = %v", explicit, err) + } + build.Enabled = false + if _, err := accounts.Update(ctx, build); err != nil { + t.Fatal(err) + } + route, err := models.GetByPublicID(ctx, "grok-shared") + if err != nil || route.Provider != account.ProviderConsole { + t.Fatalf("fallback route = %#v, err = %v", route, err) + } +} + +func TestModelRouteLookupPrioritizesDirectPublicIDOverAlias(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + models := NewModelRepository(database) + + aliasRoute := modelRouteModel{ + PublicID: "Build/grok-legacy-priority", Provider: string(account.ProviderBuild), UpstreamModel: "grok-legacy-priority", + Capability: string(model.CapabilityResponses), Origin: string(model.OriginManual), Enabled: true, + } + directRoute := modelRouteModel{ + PublicID: "Web/grok-priority", Provider: string(account.ProviderWeb), UpstreamModel: "grok-priority", + Capability: string(model.CapabilityChat), Origin: string(model.OriginManual), Enabled: true, + } + if err := database.db.WithContext(ctx).Create(&aliasRoute).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&directRoute).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&modelRouteAliasModel{Alias: "grok-priority", ModelRouteID: aliasRoute.ID, CreatedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + + routes, err := findModelRoutesByPublicID(database.db.WithContext(ctx), "grok-priority") + if err != nil || len(routes) != 2 { + t.Fatalf("routes = %#v, err = %v", routes, err) + } + if routes[0].ID != directRoute.ID || routes[1].ID != aliasRoute.ID { + t.Fatalf("direct/alias priority = %#v", routes) + } + value, err := models.GetByPublicIDIncludingDisabled(ctx, "grok-priority") + if err != nil || value.ID != directRoute.ID { + t.Fatalf("selected direct route = %#v, err = %v", value, err) + } +} + +func TestReplaceProviderRoutesReconcilesStaticCatalog(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + accounts := NewAccountRepository(database) + webAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, Name: "web", SourceKey: "web", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := repo.ReplaceAccountCapabilities(ctx, webAccount.ID, []string{"fast"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + if err := repo.UpsertRoutes(ctx, []model.Route{ + {PublicID: "grok-chat-fast", Provider: account.ProviderWeb, UpstreamModel: "fast", Capability: model.CapabilityChat, Enabled: false}, + {PublicID: "old-obsolete", Provider: account.ProviderWeb, UpstreamModel: "obsolete", Capability: model.CapabilityChat, Enabled: true}, + {PublicID: "build-model", Provider: account.ProviderBuild, UpstreamModel: "build-model", Capability: model.CapabilityResponses, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + var fastBefore, buildBefore modelRouteModel + if err := database.db.WithContext(ctx).Where("provider = ? AND upstream_model = ?", account.ProviderWeb, "fast").First(&fastBefore).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Where("provider = ? AND upstream_model = ?", account.ProviderBuild, "build-model").First(&buildBefore).Error; err != nil { + t.Fatal(err) + } + + if err := repo.ReplaceProviderRoutes(ctx, account.ProviderWeb, []model.Route{ + {PublicID: "grok-chat-fast", Provider: account.ProviderWeb, UpstreamModel: "grok-chat-fast", Capability: model.CapabilityChat, Enabled: true}, + {PublicID: "grok-chat-auto", Provider: account.ProviderWeb, UpstreamModel: "grok-chat-auto", Capability: model.CapabilityChat, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + + var routes []modelRouteModel + if err := database.db.WithContext(ctx).Where("provider = ?", account.ProviderWeb).Order("upstream_model ASC").Find(&routes).Error; err != nil { + t.Fatal(err) + } + if len(routes) != 2 || routes[0].UpstreamModel != "grok-chat-auto" || routes[1].UpstreamModel != "grok-chat-fast" { + t.Fatalf("web routes = %#v", routes) + } + if routes[1].ID != fastBefore.ID || routes[1].PublicID != "Web/grok-chat-fast" || routes[1].Enabled { + t.Fatalf("reconciled fast route = %#v", routes[1]) + } + var capability accountModelCapabilityModel + if err := database.db.WithContext(ctx).Where("account_id = ?", webAccount.ID).First(&capability).Error; err != nil { + t.Fatal(err) + } + if capability.UpstreamModel != "grok-chat-fast" { + t.Fatalf("account capability = %#v", capability) + } + var buildAfter modelRouteModel + if err := database.db.WithContext(ctx).Where("provider = ? AND upstream_model = ?", account.ProviderBuild, "build-model").First(&buildAfter).Error; err != nil { + t.Fatal(err) + } + if buildAfter.ID != buildBefore.ID || buildAfter.PublicID != buildBefore.PublicID { + t.Fatalf("build route changed: before=%#v after=%#v", buildBefore, buildAfter) + } +} + +func TestReplaceProviderRoutesCanRenameUpstreamModels(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + if err := repo.UpsertRoutes(ctx, []model.Route{ + {PublicID: "grok-imagine-image", Provider: account.ProviderWeb, UpstreamModel: "imagine-lite", Capability: model.CapabilityImage, Enabled: true}, + {PublicID: "grok-imagine-image-quality", Provider: account.ProviderWeb, UpstreamModel: "imagine", Capability: model.CapabilityImage, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + var before []modelRouteModel + if err := database.db.WithContext(ctx).Where("provider = ?", account.ProviderWeb).Order("upstream_model ASC").Find(&before).Error; err != nil { + t.Fatal(err) + } + if err := repo.ReplaceProviderRoutes(ctx, account.ProviderWeb, []model.Route{ + {PublicID: "grok-imagine-image", Provider: account.ProviderWeb, UpstreamModel: "grok-imagine-image", Capability: model.CapabilityImage, Enabled: true}, + {PublicID: "grok-imagine-image-quality", Provider: account.ProviderWeb, UpstreamModel: "grok-imagine-image-quality", Capability: model.CapabilityImage, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + var after []modelRouteModel + if err := database.db.WithContext(ctx).Where("provider = ?", account.ProviderWeb).Order("upstream_model ASC").Find(&after).Error; err != nil { + t.Fatal(err) + } + if len(after) != 2 || after[0].UpstreamModel != "grok-imagine-image" || after[0].PublicID != "Web/grok-imagine-image" || after[1].UpstreamModel != "grok-imagine-image-quality" || after[1].PublicID != "Web/grok-imagine-image-quality" { + t.Fatalf("swapped routes = %#v", after) + } + beforeIDs := make(map[string]uint64, len(before)) + for _, route := range before { + beforeIDs[route.PublicID] = route.ID + } + for _, route := range after { + if beforeIDs[route.PublicID] != route.ID { + t.Fatalf("route ID changed for %s: before=%#v after=%#v", route.PublicID, before, after) + } + } +} + +func TestManualModelRouteBindingsAndRediscovery(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + models := NewModelRepository(database) + accounts := NewAccountRepository(database) + first, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "first", SourceKey: "first", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + second, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "second", SourceKey: "second", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + + created, err := models.Create(ctx, model.Route{ + PublicID: "custom-build", Provider: account.ProviderBuild, UpstreamModel: "custom-upstream", + Capability: model.CapabilityResponses, Enabled: true, + }, []uint64{first.ID}) + if err != nil { + t.Fatal(err) + } + if created.Origin != model.OriginManual || len(created.BoundAccountIDs) != 1 || created.BoundAccountIDs[0] != first.ID || created.SupportedAccounts != 1 || created.TotalAccounts != 1 { + t.Fatalf("created route = %#v", created) + } + if _, err := models.GetByPublicID(ctx, created.PublicID); err != nil { + t.Fatalf("bound route must be available without a discovery snapshot: %v", err) + } + candidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, created.ID, created.UpstreamModel, "") + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 || candidates[0].Credential.ID != first.ID || !candidates[0].ModelCapabilityKnown || !candidates[0].SupportsModel { + t.Fatalf("bound candidates = %#v; second=%d", candidates, second.ID) + } + + if err := models.Delete(ctx, created.ID); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, first.ID, []string{created.UpstreamModel}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if err := models.UpsertDiscovered(ctx, account.ProviderBuild, []string{created.UpstreamModel}); err != nil { + t.Fatal(err) + } + recreated, err := models.GetByPublicID(ctx, created.UpstreamModel) + if err != nil { + t.Fatalf("deleted route was not rediscovered: %v", err) + } + if recreated.ID == created.ID || recreated.PublicID != "Build/"+created.UpstreamModel || recreated.Origin != model.OriginDiscovered || len(recreated.BoundAccountIDs) != 0 { + t.Fatalf("recreated route = %#v", recreated) + } +} + +func TestManualWebRouteSurvivesCatalogReconciliation(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + manual, err := repo.Create(ctx, model.Route{ + PublicID: "manual-web", Provider: account.ProviderWeb, UpstreamModel: "manual-web-upstream", + Capability: model.CapabilityChat, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + if err := repo.ReplaceProviderRoutes(ctx, account.ProviderWeb, []model.Route{{ + PublicID: "grok-chat-fast", Provider: account.ProviderWeb, UpstreamModel: "grok-chat-fast", + Capability: model.CapabilityChat, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + value, err := repo.Get(ctx, manual.ID) + if err != nil || value.Origin != model.OriginManual { + t.Fatalf("manual route after catalog reconciliation = %#v, err = %v", value, err) + } +} + +func TestBatchDeleteModelRoutesAllowsRediscovery(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + first, err := repo.Create(ctx, model.Route{ + PublicID: "batch-first", Provider: account.ProviderBuild, UpstreamModel: "batch-upstream-first", + Capability: model.CapabilityResponses, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + second, err := repo.Create(ctx, model.Route{ + PublicID: "batch-second", Provider: account.ProviderBuild, UpstreamModel: "batch-upstream-second", + Capability: model.CapabilityResponses, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + deleted, err := repo.DeleteMany(ctx, []uint64{first.ID, second.ID}) + if err != nil || deleted != 2 { + t.Fatalf("deleted = %d, err = %v", deleted, err) + } + if err := repo.UpsertDiscovered(ctx, account.ProviderBuild, []string{first.UpstreamModel, second.UpstreamModel}); err != nil { + t.Fatal(err) + } + for _, value := range []model.Route{first, second} { + if _, err := repo.Get(ctx, value.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("deleted route %d still exists: %v", value.ID, err) + } + items, total, err := repo.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 10, Search: value.UpstreamModel}}) + if err != nil || total != 1 || len(items) != 1 || items[0].ID == value.ID || items[0].UpstreamModel != value.UpstreamModel { + t.Fatalf("rediscovered route for %s = %#v, total=%d, err=%v", value.UpstreamModel, items, total, err) + } + } +} + +func TestWebRediscoveryRestoresCatalogRouteDefaults(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + value, err := repo.Create(ctx, model.Route{ + PublicID: "grok-imagine-image-edit", Provider: account.ProviderWeb, UpstreamModel: "imagine-image-edit", + Capability: model.CapabilityImageEdit, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + if err := repo.Delete(ctx, value.ID); err != nil { + t.Fatal(err) + } + if err := repo.UpsertDiscovered(ctx, account.ProviderWeb, []string{value.UpstreamModel}); err != nil { + t.Fatal(err) + } + items, total, err := repo.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 10, Search: value.UpstreamModel}}) + if err != nil || total != 1 || len(items) != 1 { + t.Fatalf("rediscovered web route = %#v, total=%d, err=%v", items, total, err) + } + if items[0].PublicID != value.PublicID || items[0].Capability != model.CapabilityImageEdit || items[0].Origin != model.OriginDiscovered { + t.Fatalf("rediscovered web route defaults = %#v", items[0]) + } +} + +func TestBuildVideo15DiscoveredAsVideoCapability(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + accounts := NewAccountRepository(database) + buildAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build-video", SourceKey: "build-video", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + webAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-video", SourceKey: "web-video", + EncryptedAccessToken: testEncryptedToken, Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := repo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-4.5", "grok-imagine-video-1.5"}); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := repo.ReplaceAccountCapabilities(ctx, buildAccount.ID, []string{"grok-4.5", "grok-imagine-video-1.5"}, now); err != nil { + t.Fatal(err) + } + video, err := repo.GetByPublicID(ctx, "Build/grok-imagine-video-1.5") + if err != nil { + t.Fatal(err) + } + if video.Capability != model.CapabilityVideo || video.UpstreamModel != "grok-imagine-video-1.5" || video.Origin != model.OriginDiscovered { + t.Fatalf("build video 1.5 route = %#v", video) + } + chat, err := repo.GetByPublicID(ctx, "Build/grok-4.5") + if err != nil { + t.Fatal(err) + } + if chat.Capability != model.CapabilityResponses { + t.Fatalf("build chat route capability = %s", chat.Capability) + } + // Web 既有 video 分类不受影响。 + if err := repo.UpsertDiscovered(ctx, account.ProviderWeb, []string{"grok-imagine-video"}); err != nil { + t.Fatal(err) + } + if err := repo.ReplaceAccountCapabilities(ctx, webAccount.ID, []string{"grok-imagine-video"}, now); err != nil { + t.Fatal(err) + } + webVideo, err := repo.GetByPublicID(ctx, "grok-imagine-video") + if err != nil || webVideo.Capability != model.CapabilityVideo { + t.Fatalf("web video route = %#v, err = %v", webVideo, err) + } +} diff --git a/backend/internal/infra/persistence/relational/model_multi_public_id_test.go b/backend/internal/infra/persistence/relational/model_multi_public_id_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f6f8ccb843c705f374c4c5855e34cd43a07e8bd8 --- /dev/null +++ b/backend/internal/infra/persistence/relational/model_multi_public_id_test.go @@ -0,0 +1,236 @@ +package relational + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestMultiplePublicIDsCanShareUpstream(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + accounts := NewAccountRepository(database) + buildAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "build", SourceKey: "build-multi-public", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + if err := repo.ReplaceAccountCapabilities(ctx, buildAccount.ID, []string{"grok-4.5"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + discovered, err := repo.Create(ctx, model.Route{ + PublicID: "grok-4.5", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: model.CapabilityResponses, Origin: model.OriginDiscovered, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + aliasA, err := repo.Create(ctx, model.Route{ + PublicID: "gpt-5.4", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + aliasB, err := repo.Create(ctx, model.Route{ + PublicID: "gpt-5.5", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + if _, err := repo.Create(ctx, model.Route{ + PublicID: "gpt-5.5", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, nil); !errors.Is(err, repository.ErrConflict) { + t.Fatalf("duplicate public id error = %v, want conflict", err) + } + + preferred, err := repo.GetByProviderUpstream(ctx, account.ProviderBuild, "grok-4.5") + if err != nil { + t.Fatal(err) + } + if preferred.ID != discovered.ID || preferred.PublicID != "Build/grok-4.5" { + t.Fatalf("preferred route = %#v, want discovered %#v", preferred, discovered) + } + + for _, publicID := range []string{"gpt-5.4", "gpt-5.5", "grok-4.5"} { + route, lookupErr := repo.GetByPublicIDIncludingDisabled(ctx, publicID) + if lookupErr != nil { + t.Fatalf("lookup %s: %v", publicID, lookupErr) + } + if route.UpstreamModel != "grok-4.5" { + t.Fatalf("route %s upstream = %q", publicID, route.UpstreamModel) + } + } + if aliasA.PublicID != "Build/gpt-5.4" || aliasB.PublicID != "Build/gpt-5.5" { + t.Fatalf("alias public ids = %#v %#v", aliasA, aliasB) + } +} + +func TestSharedUpstreamRoutesKeepAccountBindingsIsolated(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + models := NewModelRepository(database) + accounts := NewAccountRepository(database) + createAccount := func(name, sourceKey string) account.Credential { + value, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: name, SourceKey: sourceKey, + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, value.ID, []string{"shared-upstream"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + return value + } + accountA := createAccount("account-a", "shared-binding-a") + accountB := createAccount("account-b", "shared-binding-b") + createRoute := func(publicID string, accountIDs []uint64) model.Route { + route, err := models.Create(ctx, model.Route{ + PublicID: publicID, Provider: account.ProviderBuild, UpstreamModel: "shared-upstream", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, accountIDs) + if err != nil { + t.Fatal(err) + } + return route + } + routeA := createRoute("alias-a", []uint64{accountA.ID}) + routeB := createRoute("alias-b", []uint64{accountB.ID}) + unboundRoute := createRoute("alias-unbound", nil) + + assertCandidates := func(route model.Route, expected ...uint64) { + candidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderBuild, route.ID, route.UpstreamModel, "") + if err != nil { + t.Fatal(err) + } + actual := make(map[uint64]bool, len(candidates)) + for _, candidate := range candidates { + actual[candidate.Credential.ID] = true + } + if len(actual) != len(expected) { + t.Fatalf("route %s candidates = %#v, want %v", route.PublicID, actual, expected) + } + for _, id := range expected { + if !actual[id] { + t.Fatalf("route %s candidates = %#v, missing %d", route.PublicID, actual, id) + } + } + } + assertCandidates(routeA, accountA.ID) + assertCandidates(routeB, accountB.ID) + assertCandidates(unboundRoute, accountA.ID, accountB.ID) +} + +func TestUpsertDiscoveredCreatesCanonicalWhenManualAliasExists(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + + manual, err := repo.Create(ctx, model.Route{ + PublicID: "gpt-5.5", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + if err := repo.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-4.5"}); err != nil { + t.Fatal(err) + } + var rows []modelRouteModel + if err := database.db.WithContext(ctx).Where("provider = ? AND upstream_model = ?", account.ProviderBuild, "grok-4.5").Order("id ASC").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("routes = %#v", rows) + } + if rows[0].ID != manual.ID || rows[0].PublicID != "Build/gpt-5.5" || rows[0].Origin != string(model.OriginManual) { + t.Fatalf("manual route rewritten: %#v", rows[0]) + } + if rows[1].PublicID != "Build/grok-4.5" || rows[1].Origin != string(model.OriginDiscovered) { + t.Fatalf("discovered route missing: %#v", rows[1]) + } +} + +func TestReplaceProviderRoutesKeepsManualAliasesWithSharedUpstream(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + repo := NewModelRepository(database) + + if err := repo.UpsertRoutes(ctx, []model.Route{ + {PublicID: "grok-chat-fast", Provider: account.ProviderWeb, UpstreamModel: "fast", Capability: model.CapabilityChat, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + manual, err := repo.Create(ctx, model.Route{ + PublicID: "gpt-fast", Provider: account.ProviderWeb, UpstreamModel: "fast", + Capability: model.CapabilityChat, Origin: model.OriginManual, Enabled: true, + }, nil) + if err != nil { + t.Fatal(err) + } + var catalogBefore modelRouteModel + if err := database.db.WithContext(ctx).Where("public_id = ?", "Web/grok-chat-fast").First(&catalogBefore).Error; err != nil { + t.Fatal(err) + } + + if err := repo.ReplaceProviderRoutes(ctx, account.ProviderWeb, []model.Route{ + {PublicID: "grok-chat-fast", Provider: account.ProviderWeb, UpstreamModel: "grok-chat-fast", Capability: model.CapabilityChat, Enabled: true}, + }); err != nil { + t.Fatal(err) + } + + var catalogAfter modelRouteModel + if err := database.db.WithContext(ctx).Where("id = ?", catalogBefore.ID).First(&catalogAfter).Error; err != nil { + t.Fatal(err) + } + if catalogAfter.PublicID != "Web/grok-chat-fast" || catalogAfter.UpstreamModel != "grok-chat-fast" { + t.Fatalf("catalog route = %#v", catalogAfter) + } + var manualAfter modelRouteModel + if err := database.db.WithContext(ctx).Where("id = ?", manual.ID).First(&manualAfter).Error; err != nil { + t.Fatal(err) + } + if manualAfter.PublicID != "Web/gpt-fast" || manualAfter.UpstreamModel != "fast" || manualAfter.Origin != string(model.OriginManual) { + t.Fatalf("manual alias rewritten: %#v", manualAfter) + } +} + +func TestDropProviderUpstreamUniqueIndexOnUpgrade(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + if err := database.db.WithContext(ctx).Exec("CREATE UNIQUE INDEX IF NOT EXISTS uidx_provider_upstream ON model_routes(provider, upstream_model)").Error; err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertSQLiteMissingIndexes(t, database, "model_routes", "uidx_provider_upstream") + assertSQLiteIndexes(t, database, "model_routes", "idx_model_routes_provider_upstream") + + repo := NewModelRepository(database) + if _, err := repo.Create(ctx, model.Route{ + PublicID: "shared-a", Provider: account.ProviderBuild, UpstreamModel: "shared-upstream", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, nil); err != nil { + t.Fatal(err) + } + if _, err := repo.Create(ctx, model.Route{ + PublicID: "shared-b", Provider: account.ProviderBuild, UpstreamModel: "shared-upstream", + Capability: model.CapabilityResponses, Origin: model.OriginManual, Enabled: true, + }, nil); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/infra/persistence/relational/model_public_id.go b/backend/internal/infra/persistence/relational/model_public_id.go new file mode 100644 index 0000000000000000000000000000000000000000..e59bc61984b583ecb73871fb88af70b2e25035ae --- /dev/null +++ b/backend/internal/infra/persistence/relational/model_public_id.go @@ -0,0 +1,103 @@ +package relational + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +type modelPublicIDMigration struct { + ID uint64 + Previous string + Current string +} + +// ensureCanonicalModelPublicIDs 原位迁移内部路由 ID,保留路由主键和所有下游授权关系。 +func (d *Database) ensureCanonicalModelPublicIDs(ctx context.Context) error { + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var rows []modelRouteModel + if err := tx.Order("id ASC").Find(&rows).Error; err != nil { + return err + } + migrations := make([]modelPublicIDMigration, 0, len(rows)) + for _, row := range rows { + providerValue := account.Provider(row.Provider) + publicID, ok := modeldomain.NormalizePublicID(providerValue, row.PublicID) + if !ok { + return fmt.Errorf("模型路由 %d 的公开 ID %q 无法规范化到 %s", row.ID, row.PublicID, providerValue) + } + if publicID == row.PublicID { + continue + } + migrations = append(migrations, modelPublicIDMigration{ID: row.ID, Previous: row.PublicID, Current: publicID}) + } + for _, migration := range migrations { + if err := ensureModelPublicIDNotAlias(tx, migration.Current, migration.ID); err != nil { + return err + } + if err := preserveModelRouteAlias(tx, migration.Previous, migration.ID); err != nil { + return err + } + } + // 全部先移到不冲突的临时命名空间,允许 A/B 两条路由交换或释放目标名称。 + for _, migration := range migrations { + temporary := fmt.Sprintf("__grok2api_model_namespace_%d", migration.ID) + if err := tx.Model(&modelRouteModel{}).Where("id = ?", migration.ID).Update("public_id", temporary).Error; err != nil { + return mapError(err) + } + } + for _, migration := range migrations { + if err := tx.Model(&modelRouteModel{}).Where("id = ?", migration.ID).Update("public_id", migration.Current).Error; err != nil { + return fmt.Errorf("迁移模型路由 %d 到 %q: %w", migration.ID, migration.Current, mapError(err)) + } + } + return nil + }) +} + +func preserveModelRouteAlias(tx *gorm.DB, alias string, routeID uint64) error { + alias = strings.TrimSpace(alias) + if alias == "" || routeID == 0 { + return nil + } + var route modelRouteModel + if err := tx.Where("public_id = ? AND id <> ?", alias, routeID).First(&route).Error; err == nil { + return fmt.Errorf("%w: 模型兼容名称 %q 与路由 %d 的规范名称冲突", repository.ErrConflict, alias, route.ID) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var existing modelRouteAliasModel + err := tx.Where("alias = ?", alias).First(&existing).Error + if err == nil { + if existing.ModelRouteID == routeID { + return nil + } + return fmt.Errorf("%w: 模型兼容名称 %q 已绑定路由 %d", repository.ErrConflict, alias, existing.ModelRouteID) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&modelRouteAliasModel{Alias: alias, ModelRouteID: routeID}).Error +} + +func ensureModelPublicIDNotAlias(tx *gorm.DB, publicID string, routeID uint64) error { + var alias modelRouteAliasModel + query := tx.Where("alias = ?", publicID) + if routeID != 0 { + query = query.Where("model_route_id <> ?", routeID) + } + err := query.First(&alias).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + return fmt.Errorf("%w: 模型公开 ID %q 已被路由 %d 保留为兼容名称", repository.ErrConflict, publicID, alias.ModelRouteID) +} diff --git a/backend/internal/infra/persistence/relational/model_public_id_test.go b/backend/internal/infra/persistence/relational/model_public_id_test.go new file mode 100644 index 0000000000000000000000000000000000000000..48cbc9e031df9e8134a230c6e2733710ed61b61c --- /dev/null +++ b/backend/internal/infra/persistence/relational/model_public_id_test.go @@ -0,0 +1,81 @@ +package relational + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +func TestModelNamespaceMigrationPreservesAliasesRouteIDsAndKeyPermissions(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + now := time.Now().UTC() + build := modelRouteModel{ + PublicID: "grok-4.3", Provider: string(account.ProviderBuild), UpstreamModel: "grok-4.3", + Capability: string(modeldomain.CapabilityResponses), Origin: string(modeldomain.OriginDiscovered), Enabled: true, + } + console := modelRouteModel{ + PublicID: "grok-4.3-console", Provider: string(account.ProviderConsole), UpstreamModel: "grok-4.3", + Capability: string(modeldomain.CapabilityResponses), Origin: string(modeldomain.OriginCatalog), Enabled: true, + } + if err := database.db.WithContext(ctx).Create(&build).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&console).Error; err != nil { + t.Fatal(err) + } + key := clientKeyModel{ + Name: "namespace-migration", Prefix: "namespace", SecretHash: strings.Repeat("a", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, CreatedAt: now, UpdatedAt: now, + } + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&clientKeyModelPermission{ClientKeyID: key.ID, ModelRouteID: console.ID}).Error; err != nil { + t.Fatal(err) + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewModelRepository(database) + buildAfter, err := repository.GetByPublicIDIncludingDisabled(ctx, "Build/grok-4.3") + if err != nil || buildAfter.ID != build.ID { + t.Fatalf("build route after migration = %#v, err = %v", buildAfter, err) + } + legacyBuild, err := repository.GetByPublicIDIncludingDisabled(ctx, "grok-4.3") + if err != nil || legacyBuild.ID != build.ID { + t.Fatalf("legacy Build alias = %#v, err = %v", legacyBuild, err) + } + consoleAfter, err := repository.GetByPublicIDIncludingDisabled(ctx, "Console/grok-4.3-console") + if err != nil || consoleAfter.ID != console.ID { + t.Fatalf("console route after migration = %#v, err = %v", consoleAfter, err) + } + if err := repository.ReplaceProviderRoutes(ctx, account.ProviderConsole, []modeldomain.Route{{ + PublicID: "Console/grok-4.3", Provider: account.ProviderConsole, UpstreamModel: "grok-4.3", + Capability: modeldomain.CapabilityResponses, Enabled: true, + }}); err != nil { + t.Fatal(err) + } + consoleAfter, err = repository.GetByPublicIDIncludingDisabled(ctx, "Console/grok-4.3") + if err != nil || consoleAfter.ID != console.ID { + t.Fatalf("canonical Console route = %#v, err = %v", consoleAfter, err) + } + for _, alias := range []string{"grok-4.3-console", "Console/grok-4.3-console"} { + value, lookupErr := repository.GetByPublicIDIncludingDisabled(ctx, alias) + if lookupErr != nil || value.ID != console.ID { + t.Fatalf("Console alias %q = %#v, err = %v", alias, value, lookupErr) + } + } + var permission clientKeyModelPermission + if err := database.db.WithContext(ctx).Where("client_key_id = ? AND model_route_id = ?", key.ID, console.ID).First(&permission).Error; err != nil { + t.Fatalf("client-key permission did not survive model rename: %v", err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("namespace migration is not idempotent: %v", err) + } +} diff --git a/backend/internal/infra/persistence/relational/model_repository.go b/backend/internal/infra/persistence/relational/model_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..bef04481646051951dc8401248cbea11fe26f6e2 --- /dev/null +++ b/backend/internal/infra/persistence/relational/model_repository.go @@ -0,0 +1,966 @@ +package relational + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type ModelRepository struct { + db *Database + observer repository.InvalidationObserver +} + +const availableRoutePredicate = ` + EXISTS ( + SELECT 1 FROM provider_accounts account + WHERE account.provider = model_routes.provider + AND account.enabled = ? + AND account.auth_status = ? + AND ( + EXISTS ( + SELECT 1 FROM model_route_accounts binding + WHERE binding.model_route_id = model_routes.id + AND binding.account_id = account.id + ) + OR ( + NOT EXISTS (SELECT 1 FROM model_route_accounts binding WHERE binding.model_route_id = model_routes.id) + AND EXISTS ( + SELECT 1 FROM account_model_capabilities capability + WHERE capability.account_id = account.id + AND capability.upstream_model = model_routes.upstream_model + ) + ) + ) + ) +` + +// Build Super 共享能力:Billing paid 或 build_super_entitled;仅 grok_build。 +const modelAccountBuildSuperPredicate = `(EXISTS (SELECT 1 FROM account_billing_snapshots billing WHERE billing.account_id = account.id AND ` + accountPaidBillingSignals + `) OR (account.provider = 'grok_build' AND account.build_super_entitled = TRUE))` +const modelPeerBuildSuperPredicate = `(EXISTS (SELECT 1 FROM account_billing_snapshots billing WHERE billing.account_id = peer.id AND ` + accountPaidBillingSignals + `) OR (peer.provider = 'grok_build' AND peer.build_super_entitled = TRUE))` + +const modelSharedPaidBuildSupportSortExpression = `(model_routes.provider = 'grok_build' + AND ` + modelAccountBuildSuperPredicate + ` + AND EXISTS ( + SELECT 1 + FROM provider_accounts peer + JOIN account_model_capabilities peer_capability ON peer_capability.account_id = peer.id AND peer_capability.upstream_model = model_routes.upstream_model + WHERE peer.provider = model_routes.provider + AND peer.enabled = TRUE + AND peer.auth_status = 'active' + AND ` + modelPeerBuildSuperPredicate + ` + ))` + +const modelSharedPaidBuildSupportAvailabilityExpression = `(route.provider = 'grok_build' + AND ` + modelAccountBuildSuperPredicate + ` + AND EXISTS ( + SELECT 1 + FROM provider_accounts peer + JOIN account_model_capabilities peer_capability ON peer_capability.account_id = peer.id AND peer_capability.upstream_model = route.upstream_model + WHERE peer.provider = route.provider + AND peer.enabled = TRUE + AND peer.auth_status = 'active' + AND ` + modelPeerBuildSuperPredicate + ` + ))` + +// These predicates mirror the gateway's client-key scope classification. They +// are used only by the admin model picker to avoid presenting routes that the +// selected account scope can never serve. +const modelAccountBuildFreePredicate = `(account.provider = 'grok_build' + AND NOT ` + modelAccountBuildSuperPredicate + ` + AND ( + EXISTS (SELECT 1 FROM account_quota_recovery recovery WHERE recovery.account_id = account.id AND recovery.kind = 'free') + OR LOWER(TRIM(account.observed_model)) LIKE '%-build-free' + OR EXISTS (SELECT 1 FROM account_billing_snapshots billing WHERE billing.account_id = account.id AND ` + accountFreeBillingSignal + `) + ))` + +const modelAccountBuildSuperTierPredicate = `(account.provider = 'grok_build' AND ` + modelAccountBuildSuperPredicate + `)` + +const modelAccountWebFreePredicate = `(account.provider = 'grok_web' + AND EXISTS (SELECT 1 FROM web_account_profiles profile WHERE profile.account_id = account.id AND profile.tier = 'basic'))` + +const modelAccountWebSuperPredicate = `(account.provider = 'grok_web' + AND EXISTS (SELECT 1 FROM web_account_profiles profile WHERE profile.account_id = account.id AND profile.tier IN ('super', 'heavy')))` + +const modelSharedPaidBuildScopeExpression = `(model_routes.provider = 'grok_build' + AND ` + modelAccountBuildSuperPredicate + ` + AND EXISTS ( + SELECT 1 + FROM provider_accounts peer + JOIN account_model_capabilities peer_capability ON peer_capability.account_id = peer.id AND peer_capability.upstream_model = model_routes.upstream_model + WHERE peer.provider = model_routes.provider + AND ` + modelPeerBuildSuperPredicate + ` + ))` + +const modelRouteAccountCapabilityPredicate = `( + EXISTS ( + SELECT 1 FROM model_route_accounts binding + WHERE binding.model_route_id = model_routes.id + AND binding.account_id = account.id + ) + OR ( + NOT EXISTS (SELECT 1 FROM model_route_accounts binding WHERE binding.model_route_id = model_routes.id) + AND ( + EXISTS ( + SELECT 1 FROM account_model_capabilities capability + WHERE capability.account_id = account.id + AND capability.upstream_model = model_routes.upstream_model + ) + OR ` + modelSharedPaidBuildScopeExpression + ` + ) + ) +)` + +const modelAvailableRouteAccountCapabilityPredicate = `( + EXISTS ( + SELECT 1 FROM model_route_accounts binding + WHERE binding.model_route_id = model_routes.id + AND binding.account_id = account.id + ) + OR ( + NOT EXISTS (SELECT 1 FROM model_route_accounts binding WHERE binding.model_route_id = model_routes.id) + AND ( + EXISTS ( + SELECT 1 FROM account_model_capabilities capability + WHERE capability.account_id = account.id + AND capability.upstream_model = model_routes.upstream_model + ) + OR ` + modelSharedPaidBuildSupportSortExpression + ` + ) + ) +)` + +func modelTierAvailabilityPredicate(tiers []string) string { + return modelTierAvailabilityPredicateWithAvailability(tiers, false) +} + +func modelTierAvailabilityPredicateWithAvailability(tiers []string, activeOnly bool) string { + parts := make([]string, 0, len(tiers)) + for _, tier := range tiers { + switch tier { + case "free": + parts = append(parts, modelAccountBuildFreePredicate, modelAccountWebFreePredicate) + case "super": + parts = append(parts, modelAccountBuildSuperTierPredicate, modelAccountWebSuperPredicate) + } + } + if len(parts) == 0 { + return "" + } + accountPredicate := "" + capabilityPredicate := modelRouteAccountCapabilityPredicate + if activeOnly { + accountPredicate = " AND account.enabled = TRUE AND account.auth_status = 'active'" + capabilityPredicate = modelAvailableRouteAccountCapabilityPredicate + } + return `(model_routes.provider = 'grok_console' OR EXISTS ( + SELECT 1 FROM provider_accounts account + WHERE account.provider = model_routes.provider + ` + accountPredicate + ` + AND (` + strings.Join(parts, " OR ") + `) + AND ` + capabilityPredicate + ` + ))` +} + +const ( + modelProviderPriorityExpression = "CASE model_routes.provider WHEN 'grok_build' THEN 0 WHEN 'grok_web' THEN 1 WHEN 'grok_console' THEN 2 ELSE 3 END" + modelSupportSortExpression = `(SELECT COUNT(*) FROM provider_accounts account WHERE account.provider = model_routes.provider AND account.enabled = TRUE AND account.auth_status = 'active' AND (EXISTS (SELECT 1 FROM model_route_accounts binding WHERE binding.model_route_id = model_routes.id AND binding.account_id = account.id) OR (NOT EXISTS (SELECT 1 FROM model_route_accounts binding WHERE binding.model_route_id = model_routes.id) AND (EXISTS (SELECT 1 FROM account_model_capabilities capability WHERE capability.account_id = account.id AND capability.upstream_model = model_routes.upstream_model) OR ` + modelSharedPaidBuildSupportSortExpression + `))))` + modelSyncedSortExpression = `(SELECT MAX(sync.last_success_at) FROM provider_accounts account JOIN account_model_sync_states sync ON sync.account_id = account.id WHERE account.provider = model_routes.provider AND account.enabled = TRUE AND account.auth_status = 'active')` +) + +func NewModelRepository(db *Database) *ModelRepository { return &ModelRepository{db: db} } + +func (r *ModelRepository) SetInvalidationObserver(observer repository.InvalidationObserver) { + r.observer = observer +} + +func (r *ModelRepository) notifyInvalidation(ctx context.Context, event repository.InvalidationEvent) { + if r.observer != nil { + r.observer(ctx, event) + } +} + +func (r *ModelRepository) List(ctx context.Context, input repository.ModelListQuery) ([]model.Route, int64, error) { + var total int64 + query := r.db.db.WithContext(ctx).Model(&modelRouteModel{}) + if input.Filter.ActiveScope { + query = r.availableRoutes(query) + } + if search := strings.TrimSpace(input.Page.Search); search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(public_id) LIKE ? OR LOWER(upstream_model) LIKE ?", pattern, pattern) + } + if input.Filter.Provider != "" { + query = query.Where("provider = ?", input.Filter.Provider) + } + if len(input.Filter.Providers) > 0 { + query = query.Where("provider IN ?", input.Filter.Providers) + } + tierPredicate := modelTierAvailabilityPredicate(input.Filter.Tiers) + if input.Filter.ActiveScope { + tierPredicate = modelTierAvailabilityPredicateWithAvailability(input.Filter.Tiers, true) + } + if tierPredicate != "" { + query = query.Where(tierPredicate) + } + if input.Filter.Enabled != nil { + query = query.Where("enabled = ?", *input.Filter.Enabled) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []modelRouteModel + query = applyStableSort(query, input.Page.Sort, map[string]sortSpec{ + "publicId": {expression: "LOWER(model_routes.public_id)"}, + "upstreamModel": {expression: "LOWER(model_routes.upstream_model)"}, + "status": {expression: "CASE WHEN model_routes.enabled = TRUE THEN 0 ELSE 1 END"}, + "provider": {expression: "model_routes.provider"}, + "accountSupport": {expression: modelSupportSortExpression, defaultDirection: repository.SortDescending}, + "lastSyncedAt": {expression: modelSyncedSortExpression, nullsLast: true, defaultDirection: repository.SortDescending}, + }, sortSpec{expression: "model_routes.created_at", defaultDirection: repository.SortDescending}, "model_routes.id") + if err := query.Offset(input.Page.Offset).Limit(input.Page.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + values := mapModelRows(rows) + if err := r.annotateAvailability(ctx, values); err != nil { + return nil, 0, err + } + return values, total, nil +} + +func (r *ModelRepository) ListEnabled(ctx context.Context) ([]model.Route, error) { + var rows []modelRouteModel + if err := r.availableRoutes(r.db.db.WithContext(ctx)).Where("enabled = ?", true).Order("public_id ASC, id ASC").Find(&rows).Error; err != nil { + return nil, err + } + values := mapModelRows(rows) + if err := r.annotateAvailability(ctx, values); err != nil { + return nil, err + } + return values, nil +} + +func (r *ModelRepository) ListEnabledForScope(ctx context.Context, filter repository.ModelListFilter) ([]model.Route, error) { + query := r.availableRoutes(r.db.db.WithContext(ctx)).Where("enabled = ?", true) + if len(filter.Providers) > 0 { + query = query.Where("provider IN ?", filter.Providers) + } + if tierPredicate := modelTierAvailabilityPredicateWithAvailability(filter.Tiers, true); tierPredicate != "" { + query = query.Where(tierPredicate) + } + var rows []modelRouteModel + if err := query.Order("public_id ASC, id ASC").Find(&rows).Error; err != nil { + return nil, err + } + values := mapModelRows(rows) + if err := r.annotateAvailability(ctx, values); err != nil { + return nil, err + } + return values, nil +} + +// ListConfiguredEnabled 返回所有已启用配置,包括暂时没有可用账号的路由,供 readiness 展示部分故障。 +func (r *ModelRepository) ListConfiguredEnabled(ctx context.Context) ([]model.Route, error) { + var rows []modelRouteModel + if err := r.db.db.WithContext(ctx).Where("enabled = ?", true).Order("public_id ASC, id ASC").Find(&rows).Error; err != nil { + return nil, err + } + values := mapModelRows(rows) + if err := r.annotateAvailability(ctx, values); err != nil { + return nil, err + } + return values, nil +} + +func (r *ModelRepository) Get(ctx context.Context, id uint64) (model.Route, error) { + var row modelRouteModel + if err := r.db.db.WithContext(ctx).First(&row, id).Error; err != nil { + return model.Route{}, mapError(err) + } + values := []model.Route{toModelDomain(row)} + if err := r.annotateAvailability(ctx, values); err != nil { + return model.Route{}, err + } + return values[0], nil +} + +func (r *ModelRepository) GetByPublicID(ctx context.Context, publicID string) (model.Route, error) { + values, err := r.GetByPublicIDCandidates(ctx, publicID) + if err != nil { + return model.Route{}, mapError(err) + } + return values[0], nil +} + +// GetByPublicIDCandidates 返回同一下游模型名称当前可用的全部来源路由。 +// 返回顺序遵循 Build、Web、Console 的稳定 Provider 顺序。 +func (r *ModelRepository) GetByPublicIDCandidates(ctx context.Context, publicID string) ([]model.Route, error) { + db := r.availableRoutes(r.db.db.WithContext(ctx)).Where("enabled = ?", true) + rows, err := findModelRoutesByPublicID(db, publicID) + if err != nil { + return nil, mapError(err) + } + return mapModelRows(rows), nil +} + +func (r *ModelRepository) GetByPublicIDIncludingDisabled(ctx context.Context, publicID string) (model.Route, error) { + db := r.db.db.WithContext(ctx) + rows, err := findModelRoutesByPublicID(db, publicID) + if err != nil { + return model.Route{}, mapError(err) + } + return toModelDomain(rows[0]), nil +} + +func findModelRoutesByPublicID(db *gorm.DB, publicID string) ([]modelRouteModel, error) { + candidates := model.PublicIDCandidates(publicID) + alias := strings.TrimSpace(publicID) + query := db.Session(&gorm.Session{}) + if len(candidates) > 0 { + query = query.Where(` + (model_routes.public_id IN ? OR EXISTS ( + SELECT 1 FROM model_route_aliases alias + WHERE alias.model_route_id = model_routes.id AND alias.alias = ? + )) + `, candidates, alias).Clauses(clause.OrderBy{Expression: clause.Expr{ + SQL: "CASE WHEN model_routes.public_id IN ? THEN 0 ELSE 1 END, " + modelProviderPriorityExpression + ", model_routes.id ASC", + Vars: []any{candidates}, + }}) + } else { + query = query.Where(` + EXISTS ( + SELECT 1 FROM model_route_aliases alias + WHERE alias.model_route_id = model_routes.id AND alias.alias = ? + ) + `, alias).Order(modelProviderPriorityExpression + ", model_routes.id ASC") + } + var rows []modelRouteModel + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, gorm.ErrRecordNotFound + } + return rows, nil +} + +func (r *ModelRepository) GetByProviderUpstream(ctx context.Context, provider account.Provider, upstreamModel string) (model.Route, error) { + var rows []modelRouteModel + if err := r.availableRoutes(r.db.db.WithContext(ctx)).Where("provider = ? AND upstream_model = ? AND enabled = ?", provider, upstreamModel, true).Find(&rows).Error; err != nil { + return model.Route{}, mapError(err) + } + if len(rows) == 0 { + return model.Route{}, repository.ErrNotFound + } + return toModelDomain(preferProviderUpstreamRoute(provider, upstreamModel, rows)), nil +} + +// preferProviderUpstreamRoute 在同一上游存在多条对外名路由时选出稳定代表路由。 +func preferProviderUpstreamRoute(provider account.Provider, upstreamModel string, rows []modelRouteModel) modelRouteModel { + preferred := rows[0] + localID, _ := discoveredRouteDefaults(provider, upstreamModel) + canonical, hasCanonical := model.NormalizePublicID(provider, localID) + for _, row := range rows { + if hasCanonical && row.PublicID == canonical { + return row + } + originPreferred := row.Origin == string(model.OriginDiscovered) || row.Origin == string(model.OriginCatalog) + currentPreferred := preferred.Origin == string(model.OriginDiscovered) || preferred.Origin == string(model.OriginCatalog) + switch { + case originPreferred && !currentPreferred: + preferred = row + case originPreferred == currentPreferred && row.ID < preferred.ID: + preferred = row + } + } + return preferred +} + +func (r *ModelRepository) ReplaceAccountCapabilities(ctx context.Context, accountID uint64, upstreamModels []string, syncedAt time.Time) error { + unique := make(map[string]struct{}, len(upstreamModels)) + rows := make([]accountModelCapabilityModel, 0, len(upstreamModels)) + for _, value := range upstreamModels { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := unique[value]; ok { + continue + } + unique[value] = struct{}{} + rows = append(rows, accountModelCapabilityModel{AccountID: accountID, UpstreamModel: value}) + } + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("account_id = ?", accountID).Delete(&accountModelCapabilityModel{}).Error; err != nil { + return err + } + if len(rows) > 0 { + if err := tx.CreateInBatches(rows, 200).Error; err != nil { + return err + } + } + state := accountModelSyncStateModel{AccountID: accountID, LastAttemptAt: syncedAt, LastSuccessAt: &syncedAt} + return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "account_id"}}, DoUpdates: clause.AssignmentColumns([]string{"last_attempt_at", "last_success_at", "last_error"})}).Create(&state).Error + }) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationAccountCapabilityChanged, AccountID: accountID}) + } + return err +} + +func (r *ModelRepository) MarkAccountCapabilitySyncFailed(ctx context.Context, accountID uint64, attemptedAt time.Time, message string) error { + state := accountModelSyncStateModel{AccountID: accountID, LastAttemptAt: attemptedAt, LastError: truncate(message, 512)} + return r.db.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "account_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"last_attempt_at", "last_error"}), + }).Create(&state).Error +} + +func (r *ModelRepository) HasSuccessfulAccountSync(ctx context.Context, accountID uint64) (bool, error) { + var row struct{ AccountID uint64 } + err := r.db.db.WithContext(ctx).Model(&accountModelSyncStateModel{}). + Select("account_id"). + Where("account_id = ? AND last_success_at IS NOT NULL", accountID). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return row.AccountID > 0, err +} + +// ListStaleAccountSyncIDs 返回模型能力快照缺失或过期的启用账号,不扫描已禁用账号。 +func (r *ModelRepository) ListStaleAccountSyncIDs(ctx context.Context, before time.Time, limit int) ([]uint64, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + var ids []uint64 + err := r.db.db.WithContext(ctx). + Table("provider_accounts AS account"). + Select("account.id"). + Joins("LEFT JOIN account_model_sync_states AS sync ON sync.account_id = account.id"). + Where("account.enabled = ? AND account.auth_status = ?", true, account.AuthStatusActive). + Where("sync.last_success_at IS NULL OR sync.last_success_at < ?", before.UTC()). + Order("sync.last_success_at ASC, account.id ASC"). + Limit(limit). + Scan(&ids).Error + return ids, err +} + +func (r *ModelRepository) UpsertDiscovered(ctx context.Context, provider account.Provider, upstreamModels []string) error { + changed := false + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing []modelRouteModel + if err := tx.Where("provider = ?", provider).Find(&existing).Error; err != nil { + return err + } + publicIDs := make(map[string]bool, len(existing)) + for _, row := range existing { + publicIDs[row.PublicID] = true + } + rows := make([]modelRouteModel, 0, len(upstreamModels)) + for _, upstreamModel := range upstreamModels { + localID, capability := discoveredRouteDefaults(provider, upstreamModel) + publicID, ok := model.NormalizePublicID(provider, localID) + if !ok { + return fmt.Errorf("Provider %s 发现了无效模型 ID %q", provider, localID) + } + // 仅按规范 public_id 幂等;允许手动别名路由与发现路由共享同一上游。 + if publicIDs[publicID] { + continue + } + if err := ensureModelPublicIDNotAlias(tx, publicID, 0); err != nil { + return err + } + publicIDs[publicID] = true + rows = append(rows, modelRouteModel{PublicID: publicID, Provider: string(provider), UpstreamModel: upstreamModel, Capability: string(capability), Origin: string(model.OriginDiscovered), Enabled: true}) + } + if len(rows) > 0 { + // 多实例可能同时发现同一规范 public_id;public_id 唯一约束负责最终幂等。 + result := tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(rows, 200) + changed = result.Error == nil && result.RowsAffected > 0 + return result.Error + } + return nil + }) + if err == nil && changed { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: provider}) + } + return err +} + +func discoveredRouteDefaults(provider account.Provider, upstreamModel string) (string, model.Capability) { + switch provider { + case account.ProviderWeb: + switch upstreamModel { + case "grok-imagine-image", "grok-imagine-image-quality": + return upstreamModel, model.CapabilityImage + case "imagine-image-edit": + return "grok-imagine-image-edit", model.CapabilityImageEdit + case "grok-imagine-video": + return upstreamModel, model.CapabilityVideo + default: + return upstreamModel, model.CapabilityChat + } + case account.ProviderBuild: + if upstreamModel == "grok-imagine-video-1.5" { + return upstreamModel, model.CapabilityVideo + } + return upstreamModel, model.CapabilityResponses + default: + return upstreamModel, model.CapabilityResponses + } +} + +func (r *ModelRepository) UpsertRoutes(ctx context.Context, values []model.Route) error { + providers := make(map[account.Provider]struct{}, len(values)) + for _, value := range values { + providers[value.Provider] = struct{}{} + } + changedProviders := make(map[account.Provider]struct{}, len(providers)) + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, original := range values { + value := original + publicID, ok := model.NormalizePublicID(value.Provider, value.PublicID) + if !ok { + return fmt.Errorf("模型路由目录包含无效公开 ID %q", value.PublicID) + } + value.PublicID = publicID + if strings.TrimSpace(value.PublicID) == "" || strings.TrimSpace(value.UpstreamModel) == "" || value.Provider == "" || value.Capability == "" { + return fmt.Errorf("模型路由目录包含无效条目") + } + var existing modelRouteModel + err := tx.Where("public_id = ?", value.PublicID).First(&existing).Error + if err == nil { + continue + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err := ensureModelPublicIDNotAlias(tx, value.PublicID, 0); err != nil { + return err + } + fallbackOrigin := model.OriginDiscovered + if value.Provider == account.ProviderWeb { + fallbackOrigin = model.OriginCatalog + } + row := modelRouteModel{PublicID: value.PublicID, Provider: string(value.Provider), UpstreamModel: value.UpstreamModel, Capability: string(value.Capability), Origin: string(normalizeRouteOrigin(value.Origin, fallbackOrigin)), Enabled: value.Enabled} + if err := tx.Create(&row).Error; err != nil { + return mapError(err) + } + changedProviders[value.Provider] = struct{}{} + } + return nil + }) + if err == nil { + for provider := range changedProviders { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: provider}) + } + } + return err +} + +func (r *ModelRepository) ReplaceProviderRoutes(ctx context.Context, provider account.Provider, values []model.Route) error { + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + normalizedValues := make([]model.Route, len(values)) + for index, value := range values { + publicID, ok := model.NormalizePublicID(provider, value.PublicID) + if !ok { + return fmt.Errorf("模型路由目录包含无效公开 ID %q", value.PublicID) + } + value.PublicID = publicID + if strings.TrimSpace(value.PublicID) == "" || strings.TrimSpace(value.UpstreamModel) == "" || value.Provider != provider || value.Capability == "" { + return fmt.Errorf("模型路由目录包含无效条目") + } + normalizedValues[index] = value + } + values = normalizedValues + var existing []modelRouteModel + if err := tx.Where("provider = ?", provider).Find(&existing).Error; err != nil { + return err + } + + byPublicID := make(map[string]modelRouteModel, len(existing)) + byUpstreamNonManual := make(map[string][]modelRouteModel, len(existing)) + for _, row := range existing { + byPublicID[row.PublicID] = row + if row.Origin == string(model.OriginManual) { + continue + } + byUpstreamNonManual[row.UpstreamModel] = append(byUpstreamNonManual[row.UpstreamModel], row) + } + matched := make(map[int]modelRouteModel, len(values)) + usedIDs := make(map[uint64]bool, len(values)) + for index, value := range values { + if row, ok := byPublicID[value.PublicID]; ok && !usedIDs[row.ID] { + matched[index] = row + usedIDs[row.ID] = true + continue + } + // 仅回退匹配非手动路由,避免把手动别名路由改写成目录项。 + candidates := byUpstreamNonManual[value.UpstreamModel] + var chosen modelRouteModel + found := false + for _, candidate := range candidates { + if usedIDs[candidate.ID] { + continue + } + if !found || candidate.ID < chosen.ID { + chosen = candidate + found = true + } + } + if found { + matched[index] = chosen + usedIDs[chosen.ID] = true + } + } + for _, row := range existing { + if usedIDs[row.ID] || row.Origin == string(model.OriginManual) { + continue + } + if err := tx.Delete(&modelRouteModel{}, row.ID).Error; err != nil { + return err + } + } + for index, value := range values { + routeID := uint64(0) + if row, ok := matched[index]; ok { + routeID = row.ID + } + if err := ensureModelPublicIDNotAlias(tx, value.PublicID, routeID); err != nil { + return err + } + } + // Temporary values allow public IDs or upstream identifiers to be swapped while + // stable route IDs and key permissions survive. + for index, row := range matched { + if row.PublicID != values[index].PublicID { + if err := preserveModelRouteAlias(tx, row.PublicID, row.ID); err != nil { + return err + } + } + } + for _, row := range matched { + updates := map[string]any{ + "public_id": fmt.Sprintf("__grok2api_reconcile_%d", row.ID), + "upstream_model": fmt.Sprintf("__grok2api_upstream_reconcile_%d", row.ID), + } + if err := tx.Model(&modelRouteModel{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { + return mapError(err) + } + } + for index, value := range values { + updates := map[string]any{ + "public_id": value.PublicID, + "upstream_model": value.UpstreamModel, + "capability": value.Capability, + "origin": model.OriginCatalog, + } + if row, ok := matched[index]; ok { + if err := tx.Model(&modelRouteModel{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { + return mapError(err) + } + if row.UpstreamModel != value.UpstreamModel { + if err := renameAccountModelCapability(tx, provider, row.UpstreamModel, value.UpstreamModel); err != nil { + return err + } + } + continue + } + row := modelRouteModel{PublicID: value.PublicID, Provider: string(provider), UpstreamModel: value.UpstreamModel, Capability: string(value.Capability), Origin: string(model.OriginCatalog), Enabled: value.Enabled} + if err := tx.Create(&row).Error; err != nil { + return mapError(err) + } + } + return nil + }) + if err == nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: provider}) + } + return err +} + +func renameAccountModelCapability(tx *gorm.DB, provider account.Provider, oldModel, newModel string) error { + providerAccounts := tx.Model(&accountModel{}).Select("id").Where("provider = ?", provider) + duplicates := tx.Model(&accountModelCapabilityModel{}). + Select("account_id"). + Where("upstream_model = ? AND account_id IN (?)", newModel, providerAccounts) + if err := tx.Where("upstream_model = ? AND account_id IN (?) AND account_id IN (?)", oldModel, providerAccounts, duplicates). + Delete(&accountModelCapabilityModel{}).Error; err != nil { + return err + } + return tx.Model(&accountModelCapabilityModel{}). + Where("upstream_model = ? AND account_id IN (?)", oldModel, providerAccounts). + Update("upstream_model", newModel).Error +} + +func (r *ModelRepository) Create(ctx context.Context, value model.Route, accountIDs []uint64) (model.Route, error) { + publicID, ok := model.NormalizePublicID(value.Provider, value.PublicID) + if !ok { + return model.Route{}, fmt.Errorf("模型路由公开 ID 无效") + } + value.PublicID = publicID + row := modelRouteModel{ + PublicID: value.PublicID, Provider: string(value.Provider), UpstreamModel: value.UpstreamModel, + Capability: string(value.Capability), Origin: string(model.OriginManual), Enabled: value.Enabled, + } + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := ensureModelPublicIDNotAlias(tx, value.PublicID, 0); err != nil { + return err + } + if err := tx.Create(&row).Error; err != nil { + return mapError(err) + } + if err := replaceModelRouteAccounts(tx, row.ID, accountIDs); err != nil { + return err + } + return nil + }) + if err != nil { + return model.Route{}, err + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: value.Provider, UpstreamModel: value.UpstreamModel}) + if len(accountIDs) > 0 { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationModelBindingChanged, Provider: value.Provider, UpstreamModel: value.UpstreamModel}) + } + return r.Get(ctx, row.ID) +} + +func (r *ModelRepository) Update(ctx context.Context, value model.Route, accountIDs *[]uint64) (model.Route, error) { + var storedProvider account.Provider + var storedUpstreamModel string + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing modelRouteModel + if err := tx.Where("id = ?", value.ID).First(&existing).Error; err != nil { + return mapError(err) + } + storedProvider = account.Provider(existing.Provider) + storedUpstreamModel = existing.UpstreamModel + publicID, ok := model.NormalizePublicID(storedProvider, value.PublicID) + if !ok { + return fmt.Errorf("模型路由公开 ID 无效") + } + value.PublicID = publicID + if err := ensureModelPublicIDNotAlias(tx, value.PublicID, existing.ID); err != nil { + return err + } + if existing.PublicID != value.PublicID { + if err := preserveModelRouteAlias(tx, existing.PublicID, existing.ID); err != nil { + return err + } + } + result := tx.Model(&modelRouteModel{}).Where("id = ?", value.ID).Updates(map[string]any{ + "public_id": value.PublicID, + "enabled": value.Enabled, + }) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + var count int64 + if err := tx.Model(&modelRouteModel{}).Where("id = ?", value.ID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return repository.ErrNotFound + } + } + if accountIDs != nil { + return replaceModelRouteAccounts(tx, value.ID, *accountIDs) + } + return nil + }) + if err != nil { + return model.Route{}, err + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: storedProvider, UpstreamModel: storedUpstreamModel}) + if accountIDs != nil { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationModelBindingChanged, Provider: storedProvider, UpstreamModel: storedUpstreamModel}) + } + return r.Get(ctx, value.ID) +} + +func (r *ModelRepository) Delete(ctx context.Context, id uint64) error { + var existing modelRouteModel + if err := r.db.db.WithContext(ctx).First(&existing, id).Error; err != nil { + return mapError(err) + } + result := r.db.db.WithContext(ctx).Delete(&modelRouteModel{}, id) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: account.Provider(existing.Provider), UpstreamModel: existing.UpstreamModel}) + return nil +} + +func (r *ModelRepository) DeleteMany(ctx context.Context, ids []uint64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var existing []modelRouteModel + if err := r.db.db.WithContext(ctx).Where("id IN ?", ids).Find(&existing).Error; err != nil { + return 0, err + } + result := r.db.db.WithContext(ctx).Where("id IN ?", ids).Delete(&modelRouteModel{}) + if result.Error == nil && result.RowsAffected > 0 { + providers := make(map[account.Provider]struct{}, len(existing)) + for _, row := range existing { + providers[account.Provider(row.Provider)] = struct{}{} + } + for provider := range providers { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: provider}) + } + } + return result.RowsAffected, mapError(result.Error) +} + +func replaceModelRouteAccounts(tx *gorm.DB, routeID uint64, accountIDs []uint64) error { + if err := tx.Where("model_route_id = ?", routeID).Delete(&modelRouteAccountModel{}).Error; err != nil { + return err + } + if len(accountIDs) == 0 { + return nil + } + rows := make([]modelRouteAccountModel, 0, len(accountIDs)) + for _, accountID := range accountIDs { + rows = append(rows, modelRouteAccountModel{ModelRouteID: routeID, AccountID: accountID}) + } + return tx.CreateInBatches(rows, 200).Error +} + +func (r *ModelRepository) UpdateManyEnabled(ctx context.Context, ids []uint64, enabled bool) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + providers := make(map[account.Provider]struct{}) + var updated int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing []modelRouteModel + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&existing).Error; err != nil { + return err + } + for _, row := range existing { + providers[account.Provider(row.Provider)] = struct{}{} + } + result := tx.Model(&modelRouteModel{}).Where("id IN ? AND enabled <> ?", ids, enabled).Update("enabled", enabled) + updated = result.RowsAffected + return result.Error + }) + if err == nil && updated > 0 { + for provider := range providers { + r.notifyInvalidation(ctx, repository.InvalidationEvent{Kind: repository.InvalidationRouteChanged, Provider: provider}) + } + } + return updated, err +} + +func (r *ModelRepository) availableRoutes(query *gorm.DB) *gorm.DB { + return query.Where(availableRoutePredicate, true, account.AuthStatusActive) +} + +func (r *ModelRepository) annotateAvailability(ctx context.Context, values []model.Route) error { + if len(values) == 0 { + return nil + } + ids := make([]uint64, 0, len(values)) + for _, value := range values { + ids = append(ids, value.ID) + } + type availabilityRow struct { + RouteID uint64 + SupportedAccounts int + SyncedAccounts int + TotalAccounts int + LastSyncedUnix sql.NullInt64 + } + var rows []availabilityRow + lastSyncedExpression := "MAX(unixepoch(sync.last_success_at))" + if r.db.dialect == "postgres" { + lastSyncedExpression = "CAST(MAX(EXTRACT(EPOCH FROM sync.last_success_at)) AS BIGINT)" + } + err := r.db.db.WithContext(ctx).Raw(fmt.Sprintf(` + SELECT route.id AS route_id, + CASE WHEN COUNT(DISTINCT binding.account_id) > 0 + THEN COUNT(DISTINCT CASE WHEN account.enabled = TRUE AND account.auth_status = ? AND binding.account_id IS NOT NULL THEN account.id END) + ELSE COUNT(DISTINCT CASE WHEN account.enabled = TRUE AND account.auth_status = ? AND (capability.account_id IS NOT NULL OR `+modelSharedPaidBuildSupportAvailabilityExpression+`) THEN account.id END) + END AS supported_accounts, + CASE WHEN COUNT(DISTINCT binding.account_id) > 0 + THEN COUNT(DISTINCT CASE WHEN account.enabled = TRUE AND account.auth_status = ? AND binding.account_id IS NOT NULL AND sync.last_success_at IS NOT NULL THEN account.id END) + ELSE COUNT(DISTINCT CASE WHEN account.enabled = TRUE AND account.auth_status = ? AND sync.last_success_at IS NOT NULL THEN account.id END) + END AS synced_accounts, + CASE WHEN COUNT(DISTINCT binding.account_id) > 0 + THEN COUNT(DISTINCT binding.account_id) + ELSE COUNT(DISTINCT CASE WHEN account.enabled = TRUE AND account.auth_status = ? THEN account.id END) + END AS total_accounts, + %s AS last_synced_unix + FROM model_routes route + LEFT JOIN provider_accounts account ON account.provider = route.provider + LEFT JOIN model_route_accounts binding ON binding.model_route_id = route.id AND binding.account_id = account.id + LEFT JOIN account_model_sync_states sync ON sync.account_id = account.id + LEFT JOIN account_model_capabilities capability ON capability.account_id = account.id AND capability.upstream_model = route.upstream_model + WHERE route.id IN ? + GROUP BY route.id + `, lastSyncedExpression), account.AuthStatusActive, account.AuthStatusActive, account.AuthStatusActive, account.AuthStatusActive, account.AuthStatusActive, ids).Scan(&rows).Error + if err != nil { + return err + } + var bindings []modelRouteAccountModel + if err := r.db.db.WithContext(ctx).Where("model_route_id IN ?", ids).Order("model_route_id ASC, account_id ASC").Find(&bindings).Error; err != nil { + return err + } + boundByRoute := make(map[uint64][]uint64, len(values)) + for _, binding := range bindings { + boundByRoute[binding.ModelRouteID] = append(boundByRoute[binding.ModelRouteID], binding.AccountID) + } + byID := make(map[uint64]availabilityRow, len(rows)) + for _, row := range rows { + byID[row.RouteID] = row + } + for index := range values { + row := byID[values[index].ID] + values[index].SupportedAccounts = row.SupportedAccounts + values[index].SyncedAccounts = row.SyncedAccounts + values[index].TotalAccounts = row.TotalAccounts + values[index].BoundAccountIDs = boundByRoute[values[index].ID] + if row.LastSyncedUnix.Valid { + lastSyncedAt := time.Unix(row.LastSyncedUnix.Int64, 0).UTC() + values[index].LastSyncedAt = &lastSyncedAt + } + } + return nil +} + +func truncate(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func mapModelRows(rows []modelRouteModel) []model.Route { + out := make([]model.Route, 0, len(rows)) + for _, row := range rows { + out = append(out, toModelDomain(row)) + } + return out +} + +func normalizeRouteOrigin(value, fallback model.Origin) model.Origin { + if value == model.OriginCatalog || value == model.OriginDiscovered || value == model.OriginManual { + return value + } + return fallback +} diff --git a/backend/internal/infra/persistence/relational/model_scope_filter_test.go b/backend/internal/infra/persistence/relational/model_scope_filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..05941a191e722e69176b17656641a9862204c862 --- /dev/null +++ b/backend/internal/infra/persistence/relational/model_scope_filter_test.go @@ -0,0 +1,156 @@ +package relational + +import ( + "context" + "os" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestModelListFiltersByClientKeyProviderAndTierScope(t *testing.T) { + database := openTestDatabase(t) + assertModelListFiltersByClientKeyProviderAndTierScope(t, database) +} + +func TestPostgresModelListFiltersByClientKeyProviderAndTierScope(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 5, 2) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Errorf("close PostgreSQL model scope database: %v", err) + } + }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertModelListFiltersByClientKeyProviderAndTierScope(t, database) +} + +func assertModelListFiltersByClientKeyProviderAndTierScope(t *testing.T, database *Database) { + t.Helper() + ctx := context.Background() + repo := NewModelRepository(database) + + now := time.Now().UTC() + accounts := []accountModel{ + {IdentityKey: testIdentityKey("scope-build-free"), Provider: string(account.ProviderBuild), Name: "build-free", SourceKey: "build-free", ObservedModel: "grok-build-free", Enabled: true, AuthStatus: string(account.AuthStatusActive), Priority: 1}, + {IdentityKey: testIdentityKey("scope-build-super"), Provider: string(account.ProviderBuild), Name: "build-super", SourceKey: "build-super", Enabled: true, AuthStatus: string(account.AuthStatusActive), Priority: 1}, + {IdentityKey: testIdentityKey("scope-web-free"), Provider: string(account.ProviderWeb), Name: "web-free", SourceKey: "web-free", Enabled: true, AuthStatus: string(account.AuthStatusActive), Priority: 1}, + {IdentityKey: testIdentityKey("scope-web-super"), Provider: string(account.ProviderWeb), Name: "web-super", SourceKey: "web-super", Enabled: true, AuthStatus: string(account.AuthStatusActive), Priority: 1}, + {IdentityKey: testIdentityKey("scope-console"), Provider: string(account.ProviderConsole), Name: "console", SourceKey: "console", Enabled: true, AuthStatus: string(account.AuthStatusActive), Priority: 1}, + } + for index := range accounts { + if err := database.db.WithContext(ctx).Create(&accounts[index]).Error; err != nil { + t.Fatal(err) + } + } + accountIDs := make([]uint64, 0, len(accounts)) + for _, value := range accounts { + accountIDs = append(accountIDs, value.ID) + } + if err := database.db.WithContext(ctx).Create(&billingModel{AccountID: accounts[1].ID, PlanName: "SuperGrokPro", SyncedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webAccountProfileModel{AccountID: accounts[2].ID, Tier: "basic", SyncedAt: &now}).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webAccountProfileModel{AccountID: accounts[3].ID, Tier: "super", SyncedAt: &now}).Error; err != nil { + t.Fatal(err) + } + + routes := []modeldomain.Route{ + {PublicID: "Build/scope-free", Provider: account.ProviderBuild, UpstreamModel: "scope-free", Capability: modeldomain.CapabilityResponses, Enabled: true}, + {PublicID: "Build/scope-super", Provider: account.ProviderBuild, UpstreamModel: "scope-super", Capability: modeldomain.CapabilityResponses, Enabled: true}, + {PublicID: "scope-web-free", Provider: account.ProviderWeb, UpstreamModel: "scope-web-free", Capability: modeldomain.CapabilityChat, Enabled: true}, + {PublicID: "scope-web-super", Provider: account.ProviderWeb, UpstreamModel: "scope-web-super", Capability: modeldomain.CapabilityChat, Enabled: true}, + {PublicID: "scope-console", Provider: account.ProviderConsole, UpstreamModel: "scope-console", Capability: modeldomain.CapabilityResponses, Enabled: true}, + } + routeIDs := make([]uint64, 0, len(routes)) + for index := range routes { + created, err := repo.Create(ctx, routes[index], nil) + if err != nil { + t.Fatal(err) + } + routeIDs = append(routeIDs, created.ID) + } + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := repo.DeleteMany(cleanupCtx, routeIDs); err != nil { + t.Errorf("delete scoped model routes: %v", err) + } + if err := database.db.WithContext(cleanupCtx).Where("id IN ?", accountIDs).Delete(&accountModel{}).Error; err != nil { + t.Errorf("delete scoped model accounts: %v", err) + } + }) + capabilities := []accountModelCapabilityModel{ + {AccountID: accounts[0].ID, UpstreamModel: "scope-free"}, + {AccountID: accounts[1].ID, UpstreamModel: "scope-super"}, + {AccountID: accounts[2].ID, UpstreamModel: "scope-web-free"}, + {AccountID: accounts[3].ID, UpstreamModel: "scope-web-super"}, + {AccountID: accounts[4].ID, UpstreamModel: "scope-console"}, + } + if err := database.db.WithContext(ctx).Create(&capabilities).Error; err != nil { + t.Fatal(err) + } + + list := func(tiers []string, providers []string) map[string]bool { + values, _, err := repo.List(ctx, repository.ModelListQuery{Page: repository.PageQuery{Limit: 20}, Filter: repository.ModelListFilter{Providers: providers, Tiers: tiers}}) + if err != nil { + t.Fatal(err) + } + result := make(map[string]bool, len(values)) + for _, value := range values { + result[value.PublicID] = true + } + return result + } + + free := list([]string{"free"}, nil) + if len(free) != 3 || !free["Build/scope-free"] || !free["Web/scope-web-free"] || !free["Console/scope-console"] || free["Build/scope-super"] || free["Web/scope-web-super"] { + t.Fatalf("free scope models = %#v", free) + } + super := list([]string{"super"}, nil) + if len(super) != 3 || !super["Build/scope-super"] || !super["Web/scope-web-super"] || !super["Console/scope-console"] || super["Build/scope-free"] || super["Web/scope-web-free"] { + t.Fatalf("super scope models = %#v", super) + } + buildFree := list([]string{"free"}, []string{"grok_build"}) + if len(buildFree) != 1 || !buildFree["Build/scope-free"] { + t.Fatalf("build/free scope models = %#v", buildFree) + } + + enabledForScope := func(tiers []string, providers []string) map[string]bool { + values, err := repo.ListEnabledForScope(ctx, repository.ModelListFilter{Providers: providers, Tiers: tiers}) + if err != nil { + t.Fatal(err) + } + result := make(map[string]bool, len(values)) + for _, value := range values { + result[value.PublicID] = true + } + return result + } + enabledBuildFree := enabledForScope([]string{"free"}, []string{"grok_build"}) + if len(enabledBuildFree) != 1 || !enabledBuildFree["Build/scope-free"] { + t.Fatalf("enabled build/free scope models = %#v", enabledBuildFree) + } + if err := database.db.WithContext(ctx).Model(&accountModel{}).Where("id IN ?", []uint64{accounts[1].ID, accounts[3].ID}).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + enabledSuper := enabledForScope([]string{"super"}, nil) + if len(enabledSuper) != 1 || !enabledSuper["Console/scope-console"] { + t.Fatalf("inactive Super accounts must not advertise routes: %#v", enabledSuper) + } +} diff --git a/backend/internal/infra/persistence/relational/models.go b/backend/internal/infra/persistence/relational/models.go new file mode 100644 index 0000000000000000000000000000000000000000..da97864527174140c2687e31d49d6ff3380f98e1 --- /dev/null +++ b/backend/internal/infra/persistence/relational/models.go @@ -0,0 +1,548 @@ +package relational + +import "time" + +type adminModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"size:100;uniqueIndex;not null;check:chk_admins_username,length(trim(username)) BETWEEN 1 AND 100"` + PasswordHash string `gorm:"size:255;not null"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (adminModel) TableName() string { return "admins" } + +type adminSessionModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + AdminID uint64 `gorm:"not null"` + RefreshTokenHash string `gorm:"size:64;uniqueIndex;not null;check:chk_admin_sessions_token_hash,length(refresh_token_hash) = 64"` + ExpiresAt time.Time `gorm:"not null"` + LastUsedAt *time.Time + CreatedAt time.Time `gorm:"not null"` + Admin *adminModel `gorm:"foreignKey:AdminID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (adminSessionModel) TableName() string { return "admin_sessions" } + +type accountModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + IdentityKey string `gorm:"size:64;uniqueIndex;not null;check:chk_accounts_identity_key,length(identity_key) = 64"` + Provider string `gorm:"size:32;not null;check:chk_accounts_provider,provider IN ('grok_build','grok_web','grok_console');index:idx_accounts_provider_source,priority:1"` + Name string `gorm:"size:160;not null;check:chk_accounts_name,length(trim(name)) BETWEEN 1 AND 160"` + Email string `gorm:"size:255;check:chk_accounts_email,length(email) <= 255"` + UserID string `gorm:"size:255;check:chk_accounts_user_id,length(user_id) <= 255"` + TeamID string `gorm:"size:255;check:chk_accounts_team_id,length(team_id) <= 255"` + SourceKey string `gorm:"size:512;not null;check:chk_accounts_source_key,length(trim(source_key)) BETWEEN 1 AND 512;index:idx_accounts_provider_source,priority:2"` + Enabled bool `gorm:"not null"` + AuthStatus string `gorm:"size:32;not null;check:chk_accounts_auth_status,auth_status IN ('active','reauthRequired')"` + // ReauthMarkedAt 进入 reauthRequired 的时刻;active 时为 NULL。 + ReauthMarkedAt *time.Time + Priority int `gorm:"not null;default:1"` + MaxConcurrent int `gorm:"not null;default:8;check:chk_accounts_max_concurrent,max_concurrent BETWEEN 1 AND 256"` + MinimumRemaining float64 `gorm:"not null;check:chk_accounts_minimum_remaining,minimum_remaining >= 0"` + FailureCount int `gorm:"not null;check:chk_accounts_failure_count,failure_count >= 0"` + CooldownUntil *time.Time + LastError string `gorm:"size:512;check:chk_accounts_last_error,length(last_error) <= 512"` + LastUsedAt *time.Time + ObservedModel string `gorm:"size:255;check:chk_accounts_observed_model,length(observed_model) <= 255"` + ObservedModelAt *time.Time + // BuildAPIFallback 仅对 grok_build 有意义:XAI 推理回退标记;其他 Provider 保持 false。 + BuildAPIFallback bool `gorm:"not null;default:false"` + // BuildRouteMode 仅控制 grok_build 推理地址;其它 Provider 固定 auto。 + BuildRouteMode string `gorm:"size:16;not null;default:auto;check:chk_accounts_build_route_mode,build_route_mode IN ('auto','build','xai')"` + // BuildSuperEntitled 仅对 grok_build 有意义:管理员确认的 Super/1.5 entitlement;其他 Provider 保持 false。 + BuildSuperEntitled bool `gorm:"not null;default:false"` + // EgressNodeID is nullable so existing accounts retain the legacy pool + // routing behavior until an administrator explicitly assigns a node. + EgressNodeID *uint64 `gorm:"index:idx_accounts_egress_node"` + EgressAssignmentMode string `gorm:"size:16;not null;default:'';check:chk_accounts_egress_assignment_mode,egress_assignment_mode IN ('','manual','auto')"` + EgressAssignedAt *time.Time + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Credential *accountCredentialModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + WebProfile *webAccountProfileModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + EgressNode *egressNodeModel `gorm:"foreignKey:EgressNodeID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` +} + +func (accountModel) TableName() string { return "provider_accounts" } + +type accountCredentialModel struct { + AccountID uint64 `gorm:"primaryKey"` + AuthType string `gorm:"size:16;not null;check:chk_account_credentials_auth_type,auth_type IN ('oauth','sso')"` + ClientID string `gorm:"size:255;check:chk_account_credentials_client_id,length(client_id) <= 255"` + EncryptedPrimary string `gorm:"type:text;not null;default:'';check:chk_account_credentials_secret,((auth_type = 'oauth' AND (encrypted_primary <> '' OR encrypted_refresh <> '')) OR (auth_type = 'sso' AND encrypted_primary <> '' AND encrypted_refresh = '')) AND length(encrypted_primary) <= 65536 AND length(encrypted_refresh) <= 65536"` + EncryptedRefresh string `gorm:"type:text;not null;default:''"` + EncryptedCloudflareCookie string `gorm:"type:text;not null;default:'';check:chk_account_credentials_cf_cookie,length(encrypted_cloudflare_cookie) <= 65536"` + ExpiresAt *time.Time + RefreshDueAt *time.Time + LastRefreshAt *time.Time + RefreshFailures int `gorm:"not null;default:0;check:chk_account_credentials_refresh_failures,refresh_failures >= 0"` + LastRefreshErrorStatus int `gorm:"not null;default:0;check:chk_account_credentials_refresh_error_status,last_refresh_error_status >= 0"` + LastRefreshError string `gorm:"size:100;not null;default:'';check:chk_account_credentials_refresh_error,length(last_refresh_error) <= 100"` + LastRefreshErrorMessage string `gorm:"size:512;not null;default:'';check:chk_account_credentials_refresh_error_message,length(last_refresh_error_message) <= 512"` + LastRefreshErrorResponse string `gorm:"size:4096;not null;default:'';check:chk_account_credentials_refresh_error_response,length(last_refresh_error_response) <= 4096"` + RefreshPermanent bool `gorm:"not null;default:false"` + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (accountCredentialModel) TableName() string { return "account_credentials" } + +type accountProviderLinkModel struct { + WebAccountID uint64 `gorm:"primaryKey"` + BuildAccountID uint64 `gorm:"uniqueIndex;not null;check:chk_account_provider_links_distinct,web_account_id <> build_account_id"` + CreatedAt time.Time `gorm:"not null"` + WebAccount *accountModel `gorm:"foreignKey:WebAccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + BuildAccount *accountModel `gorm:"foreignKey:BuildAccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (accountProviderLinkModel) TableName() string { return "account_provider_links" } + +type webConsoleAccountLinkModel struct { + WebAccountID uint64 `gorm:"primaryKey"` + ConsoleAccountID uint64 `gorm:"uniqueIndex;not null;check:chk_web_console_account_links_distinct,web_account_id <> console_account_id"` + CreatedAt time.Time `gorm:"not null"` + WebAccount *accountModel `gorm:"foreignKey:WebAccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + ConsoleAccount *accountModel `gorm:"foreignKey:ConsoleAccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (webConsoleAccountLinkModel) TableName() string { return "web_console_account_links" } + +type webAccountProfileModel struct { + AccountID uint64 `gorm:"primaryKey"` + Tier string `gorm:"size:16;not null;check:chk_web_account_profiles_tier,tier IN ('auto','basic','super','heavy')"` + SyncedAt *time.Time + NSFWEnabledAt *time.Time + TermsAcceptedAt *time.Time + TermsAcceptedVersion int `gorm:"not null;default:0"` + BirthDateSetAt *time.Time + EgressIdentity string `gorm:"size:128;not null;default:'';check:chk_web_account_profiles_egress_identity,length(egress_identity) <= 128"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (webAccountProfileModel) TableName() string { return "web_account_profiles" } + +type quotaWindowModel struct { + AccountID uint64 `gorm:"primaryKey"` + Mode string `gorm:"size:64;primaryKey;not null;check:chk_account_quota_windows_mode,length(trim(mode)) BETWEEN 1 AND 64"` + Remaining int `gorm:"not null;check:chk_account_quota_windows_remaining,remaining >= 0"` + Total int `gorm:"not null;check:chk_account_quota_windows_total,total >= 0"` + UsagePercent float64 `gorm:"not null;default:0;check:chk_account_quota_windows_usage_percent,usage_percent >= 0 AND usage_percent <= 100"` + BreakdownJSON string `gorm:"type:text;not null;default:'[]';check:chk_account_quota_windows_breakdown,length(breakdown_json) <= 8192"` + WindowSeconds int `gorm:"not null;check:chk_account_quota_windows_window,window_seconds >= 0"` + ResetAt *time.Time + SyncedAt *time.Time + Source string `gorm:"size:16;not null;check:chk_account_quota_windows_source,source IN ('default','estimated','upstream')"` + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (quotaWindowModel) TableName() string { return "account_quota_windows" } + +type billingModel struct { + AccountID uint64 `gorm:"primaryKey"` + PlanCode string `gorm:"size:100;check:chk_billing_plan_code,length(plan_code) <= 100"` + PlanName string `gorm:"size:160;check:chk_billing_plan_name,length(plan_name) <= 160"` + MonthlyLimit float64 `gorm:"not null;default:0;check:chk_billing_monthly_limit,monthly_limit >= 0"` + Used float64 `gorm:"not null;default:0;check:chk_billing_used,used >= 0"` + OnDemandCap float64 `gorm:"not null;default:0;check:chk_billing_on_demand_cap,on_demand_cap >= 0"` + OnDemandUsed float64 `gorm:"not null;default:0;check:chk_billing_on_demand_used,on_demand_used >= 0"` + PrepaidBalance float64 `gorm:"not null;default:0;check:chk_billing_prepaid_balance,prepaid_balance >= 0"` + CreditUsagePercent float64 `gorm:"not null;default:0;check:chk_billing_credit_usage_percent,credit_usage_percent >= 0"` + IsUnifiedBillingUser bool `gorm:"not null;default:false"` + OnDemandEnabled *bool + TopUpMethod string `gorm:"size:100;check:chk_billing_top_up_method,length(top_up_method) <= 100"` + UsagePeriodType string `gorm:"size:100;check:chk_billing_usage_period_type,length(usage_period_type) <= 100"` + UsagePeriodStart string `gorm:"size:64;check:chk_billing_usage_period_start,length(usage_period_start) <= 64"` + UsagePeriodEnd string `gorm:"size:64;check:chk_billing_usage_period_end,length(usage_period_end) <= 64"` + BillingPeriodStart string `gorm:"size:64;check:chk_billing_period_start,length(billing_period_start) <= 64"` + BillingPeriodEnd string `gorm:"size:64;check:chk_billing_period_end,length(billing_period_end) <= 64"` + HistoryJSON string `gorm:"type:text;not null;default:'[]';check:chk_billing_history_json_length,length(history_json) <= 1048576"` + SyncedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (billingModel) TableName() string { return "account_billing_snapshots" } + +type quotaRecoveryModel struct { + AccountID uint64 `gorm:"primaryKey"` + Kind string `gorm:"size:16;not null;check:chk_quota_recovery_kind,kind IN ('free','paid')"` + Status string `gorm:"size:32;not null;check:chk_quota_recovery_status,status IN ('exhausted','probing')"` + ConfirmedUsed int64 `gorm:"not null;default:0;check:chk_quota_recovery_used,confirmed_used >= 0"` + ConfirmedLimit int64 `gorm:"not null;default:0;check:chk_quota_recovery_limit,confirmed_limit >= 0"` + ExhaustedAt *time.Time + NextProbeAt *time.Time + LastConfirmedAt *time.Time + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (quotaRecoveryModel) TableName() string { return "account_quota_recovery" } + +type modelRouteModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + PublicID string `gorm:"size:255;uniqueIndex;not null;check:chk_model_routes_public_id,length(trim(public_id)) BETWEEN 1 AND 255"` + Provider string `gorm:"size:32;index:idx_model_routes_provider_upstream;not null;check:chk_model_routes_provider,provider IN ('grok_build','grok_web','grok_console')"` + UpstreamModel string `gorm:"size:255;index:idx_model_routes_provider_upstream;not null;check:chk_model_routes_upstream_model,length(trim(upstream_model)) BETWEEN 1 AND 255"` + Capability string `gorm:"size:32;not null;check:chk_model_routes_capability,capability IN ('responses','chat','image','image_edit','video')"` + Origin string `gorm:"size:32;not null;default:discovered;check:chk_model_routes_origin,origin IN ('catalog','discovered','manual')"` + Enabled bool `gorm:"not null"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (modelRouteModel) TableName() string { return "model_routes" } + +// modelRouteAliasModel 保留升级或人工重命名前的公开模型 ID,使外部客户端可以平滑迁移。 +type modelRouteAliasModel struct { + Alias string `gorm:"size:255;primaryKey;check:chk_model_route_aliases_alias,length(trim(alias)) BETWEEN 1 AND 255"` + ModelRouteID uint64 `gorm:"not null;index"` + CreatedAt time.Time `gorm:"not null"` + ModelRoute *modelRouteModel `gorm:"foreignKey:ModelRouteID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (modelRouteAliasModel) TableName() string { return "model_route_aliases" } + +type modelRouteAccountModel struct { + ModelRouteID uint64 `gorm:"primaryKey"` + AccountID uint64 `gorm:"primaryKey"` + ModelRoute *modelRouteModel `gorm:"foreignKey:ModelRouteID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (modelRouteAccountModel) TableName() string { return "model_route_accounts" } + +type accountModelCapabilityModel struct { + AccountID uint64 `gorm:"primaryKey"` + UpstreamModel string `gorm:"size:255;primaryKey;not null;check:chk_account_model_capabilities_model,length(trim(upstream_model)) BETWEEN 1 AND 255"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (accountModelCapabilityModel) TableName() string { return "account_model_capabilities" } + +type accountModelSyncStateModel struct { + AccountID uint64 `gorm:"primaryKey"` + LastAttemptAt time.Time `gorm:"not null"` + LastSuccessAt *time.Time + LastError string `gorm:"size:512;check:chk_account_model_sync_states_error,length(last_error) <= 512"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (accountModelSyncStateModel) TableName() string { return "account_model_sync_states" } + +type accountModelQuotaBlockModel struct { + AccountID uint64 `gorm:"primaryKey"` + UpstreamModel string `gorm:"size:255;primaryKey;not null;check:chk_account_model_quota_blocks_model,length(trim(upstream_model)) BETWEEN 1 AND 255"` + Reason string `gorm:"size:100;not null;check:chk_account_model_quota_blocks_reason,length(trim(reason)) BETWEEN 1 AND 100"` + CooldownUntil time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (accountModelQuotaBlockModel) TableName() string { return "account_model_quota_blocks" } + +type clientKeyModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:160;not null;check:chk_client_keys_name,length(trim(name)) BETWEEN 1 AND 160"` + Prefix string `gorm:"size:32;uniqueIndex;not null;check:chk_client_keys_prefix,length(prefix) BETWEEN 1 AND 32"` + SecretHash string `gorm:"size:64;not null;check:chk_client_keys_secret_hash,length(secret_hash) = 64"` + EncryptedSecret string `gorm:"type:text;not null;check:chk_client_keys_encrypted_secret,length(trim(encrypted_secret)) BETWEEN 1 AND 4096"` + Enabled bool `gorm:"not null"` + ExpiresAt *time.Time + RPMLimit int `gorm:"not null;default:120;check:chk_client_keys_rpm,rpm_limit BETWEEN 0 AND 100000"` + MaxConcurrent int `gorm:"not null;default:8;check:chk_client_keys_max_concurrent,max_concurrent BETWEEN 0 AND 1024"` + BillingLimitUSDTicks int64 `gorm:"not null;default:0;check:chk_client_keys_billing_limit,billing_limit_usd_ticks BETWEEN 0 AND 9000000000000000"` + BilledUsageUSDTicks int64 `gorm:"not null;default:0;check:chk_client_keys_billed_usage,billed_usage_usd_ticks >= 0"` + ReservedUsageUSDTicks int64 `gorm:"not null;default:0;check:chk_client_keys_reserved_usage,reserved_usage_usd_ticks >= 0"` + // AllowModelAliases defaults false so existing keys keep a clean base-model list. + AllowModelAliases bool `gorm:"not null;default:false"` + ProviderScopeMask uint8 `gorm:"not null;default:7;check:chk_client_keys_provider_scope,provider_scope_mask BETWEEN 1 AND 7"` + TierScopeMask uint8 `gorm:"not null;default:7;check:chk_client_keys_tier_scope,tier_scope_mask IN (1,2,3,7)"` + LastUsedAt *time.Time + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (clientKeyModel) TableName() string { return "client_keys" } + +type clientKeyModelPermission struct { + ClientKeyID uint64 `gorm:"primaryKey"` + ModelRouteID uint64 `gorm:"primaryKey"` + ClientKey *clientKeyModel `gorm:"foreignKey:ClientKeyID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + ModelRoute *modelRouteModel `gorm:"foreignKey:ModelRouteID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (clientKeyModelPermission) TableName() string { return "client_key_models" } + +type billingReservationModel struct { + EventID string `gorm:"size:64;primaryKey;check:chk_billing_reservations_event_id,length(event_id) BETWEEN 16 AND 64"` + ClientKeyID uint64 `gorm:"not null;check:chk_billing_reservations_client_key_id,client_key_id > 0"` + Amount int64 `gorm:"not null;check:chk_billing_reservations_amount,amount > 0"` + ExpiresAt time.Time `gorm:"not null"` + CreatedAt time.Time `gorm:"not null"` + ClientKey *clientKeyModel `gorm:"foreignKey:ClientKeyID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (billingReservationModel) TableName() string { return "billing_reservations" } + +type requestAuditModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + EventID string `gorm:"size:64;check:chk_request_audits_event_id,event_id = '' OR length(event_id) BETWEEN 16 AND 64"` + RequestID string `gorm:"size:64;not null;check:chk_request_audits_request_id,length(request_id) BETWEEN 1 AND 64"` + ClientKeyID uint64 `gorm:"not null;check:chk_request_audits_client_key_id,client_key_id > 0"` + ClientKeyName string `gorm:"size:160;check:chk_request_audits_client_key_name,length(client_key_name) <= 160"` + ModelRouteID uint64 `gorm:"not null;check:chk_request_audits_model_route_id,model_route_id > 0"` + ModelPublicID string `gorm:"size:255;check:chk_request_audits_model_public_id,length(model_public_id) <= 255"` + ModelUpstreamModel string `gorm:"size:255;check:chk_request_audits_model_upstream_model,length(model_upstream_model) <= 255"` + Provider string `gorm:"size:32;not null;check:chk_request_audits_provider,provider IN ('grok_build','grok_web','grok_console')"` + Operation string `gorm:"size:32;not null;check:chk_request_audits_operation,operation IN ('responses','compaction','chat','messages','image','image_edit','video')"` + UsageSource string `gorm:"size:16;not null;check:chk_request_audits_usage_source,usage_source IN ('upstream','estimated','none')"` + AccountID *uint64 `gorm:"check:chk_request_audits_account_id,account_id IS NULL OR account_id > 0"` + AccountName string `gorm:"size:160;check:chk_request_audits_account_name,length(account_name) <= 160"` + EgressNodeID *uint64 `gorm:"check:chk_request_audits_egress_node_id,egress_node_id IS NULL OR egress_node_id > 0"` + EgressNodeName string `gorm:"size:160;not null;default:'';check:chk_request_audits_egress_node_name,length(egress_node_name) <= 160"` + EgressScope string `gorm:"size:32;not null;default:'';check:chk_request_audits_egress_scope,egress_scope IN ('','grok_build','grok_web','grok_console','grok_web_asset')"` + EgressMode string `gorm:"size:16;not null;default:'';check:chk_request_audits_egress_mode,egress_mode IN ('','direct','proxy')"` + StatusCode int `gorm:"not null;check:chk_request_audits_status_code,status_code BETWEEN 100 AND 599"` + Streaming bool `gorm:"not null;default:false"` + MediaInputImages int64 `gorm:"not null;default:0"` + MediaOutputImages int64 `gorm:"not null;default:0"` + MediaOutputSeconds int64 `gorm:"not null;default:0"` + InputTokens int64 `gorm:"not null;default:0;check:chk_request_audits_metrics,media_input_images >= 0 AND media_output_images >= 0 AND media_output_seconds >= 0 AND input_tokens >= 0 AND cached_input_tokens >= 0 AND output_tokens >= 0 AND reasoning_tokens >= 0 AND total_tokens >= 0 AND cost_in_usd_ticks >= 0 AND estimated_cost_in_usd_ticks >= 0 AND num_sources_used >= 0 AND num_server_side_tools_used >= 0 AND context_input_tokens >= 0 AND context_output_tokens >= 0 AND duration_ms >= 0"` + CachedInputTokens int64 `gorm:"not null;default:0"` + OutputTokens int64 `gorm:"not null;default:0"` + ReasoningTokens int64 `gorm:"not null;default:0"` + TotalTokens int64 `gorm:"not null;default:0"` + CostInUSDTicks int64 `gorm:"not null;default:0"` + EstimatedCostInUSDTicks int64 `gorm:"not null;default:0"` + PricingModel string `gorm:"size:100;check:chk_request_audits_pricing_model,length(pricing_model) <= 100"` + PricingVersion string `gorm:"size:20;check:chk_request_audits_pricing_version,length(pricing_version) <= 20"` + NumSourcesUsed int64 `gorm:"not null;default:0"` + NumServerSideToolsUsed int64 `gorm:"not null;default:0"` + ContextInputTokens int64 `gorm:"not null;default:0"` + ContextOutputTokens int64 `gorm:"not null;default:0"` + FirstTokenMS *int64 `gorm:"column:first_token_ms"` + DurationMS int64 `gorm:"not null;default:0"` + ErrorCode string `gorm:"size:100;check:chk_request_audits_error_code,length(error_code) <= 100"` + AttemptCount int `gorm:"not null;default:0;check:chk_request_audits_attempt_count,attempt_count >= 0"` + CreatedAt time.Time `gorm:"not null"` +} + +func (requestAuditModel) TableName() string { return "request_audits" } + +type requestAuditAttemptModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + AuditID uint64 `gorm:"not null;uniqueIndex:uidx_audit_attempt_number;check:chk_request_audit_attempts_audit_id,audit_id > 0"` + Number int `gorm:"not null;uniqueIndex:uidx_audit_attempt_number;check:chk_request_audit_attempts_number,number > 0"` + Source string `gorm:"size:32;not null;check:chk_request_audit_attempts_source,source IN ('upstream_http','gateway_transport','credential')"` + Stage string `gorm:"size:64;not null;check:chk_request_audit_attempts_stage,length(trim(stage)) BETWEEN 1 AND 64"` + AccountID *uint64 `gorm:"check:chk_request_audit_attempts_account_id,account_id IS NULL OR account_id > 0"` + AccountName string `gorm:"size:160;not null;default:'';check:chk_request_audit_attempts_account_name,length(account_name) <= 160"` + Method string `gorm:"size:16;not null;default:'';check:chk_request_audit_attempts_method,length(method) <= 16"` + RequestPath string `gorm:"type:text;not null;default:'';check:chk_request_audit_attempts_request_path,length(request_path) <= 2048"` + UpstreamURL string `gorm:"type:text;not null;default:'';check:chk_request_audit_attempts_upstream_url,length(upstream_url) <= 4096"` + StartedAt time.Time `gorm:"not null"` + DurationMS int64 `gorm:"not null;default:0;check:chk_request_audit_attempts_duration,duration_ms >= 0"` + UpstreamStatusCode *int `gorm:"check:chk_request_audit_attempts_status,upstream_status_code IS NULL OR upstream_status_code BETWEEN 100 AND 599"` + UpstreamStatus string `gorm:"size:128;not null;default:'';check:chk_request_audit_attempts_status_text,length(upstream_status) <= 128"` + ResponseHeadersJSON string `gorm:"type:text;not null;default:'{}';check:chk_request_audit_attempts_headers,length(response_headers_json) <= 32768"` + ResponseBody []byte `gorm:"check:chk_request_audit_attempts_body,length(response_body) <= 65536"` + ResponseBodyTruncated bool `gorm:"not null;default:false"` + TransportError string `gorm:"type:text;not null;default:'';check:chk_request_audit_attempts_transport_error,length(transport_error) <= 2048"` + ErrorChainJSON string `gorm:"type:text;not null;default:'[]';check:chk_request_audit_attempts_error_chain,length(error_chain_json) <= 32768"` + Audit *requestAuditModel `gorm:"foreignKey:AuditID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (requestAuditAttemptModel) TableName() string { return "request_audit_attempts" } + +type responseOwnershipModel struct { + ResponseID string `gorm:"size:255;primaryKey;check:chk_response_ownership_id,length(response_id) BETWEEN 1 AND 255"` + AccountID uint64 `gorm:"not null"` + ClientKeyID uint64 `gorm:"not null"` + Provider string `gorm:"size:32;not null;check:chk_response_ownership_provider,provider IN ('grok_build','grok_web','grok_console')"` + PromptCacheKey string `gorm:"size:64;not null;default:'';check:chk_response_ownership_cache_key,length(prompt_cache_key) <= 64"` + ReasoningReplayKey string `gorm:"size:64;not null;default:'';check:chk_response_ownership_replay_key,length(reasoning_replay_key) <= 64"` + ExpiresAt time.Time `gorm:"not null;check:chk_response_ownership_expiry,expires_at > created_at"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + ClientKey *clientKeyModel `gorm:"foreignKey:ClientKeyID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (responseOwnershipModel) TableName() string { return "response_ownership" } + +type webResponseStateModel struct { + ResponseID string `gorm:"size:255;primaryKey;check:chk_web_response_states_id,length(response_id) BETWEEN 1 AND 255"` + AccountID uint64 `gorm:"not null;check:chk_web_response_states_account_id,account_id > 0"` + ConversationID string `gorm:"size:255;not null;check:chk_web_response_states_conversation,length(trim(conversation_id)) BETWEEN 1 AND 255"` + UpstreamParentResponseID string `gorm:"size:255;not null;check:chk_web_response_states_parent,length(trim(upstream_parent_response_id)) BETWEEN 1 AND 255"` + ResponseJSON string `gorm:"type:text;not null;check:chk_web_response_states_json,length(response_json) <= 16777216"` + Status string `gorm:"size:32;not null;check:chk_web_response_states_status,status IN ('in_progress','completed','failed','cancelled')"` + ExpiresAt time.Time `gorm:"not null;check:chk_web_response_states_expiry,expires_at > created_at"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (webResponseStateModel) TableName() string { return "web_response_states" } + +type mediaJobModel struct { + ID string `gorm:"size:64;primaryKey;check:chk_media_jobs_id,length(id) BETWEEN 1 AND 64"` + RequestID string `gorm:"size:64;not null;check:chk_media_jobs_request_id,length(request_id) BETWEEN 1 AND 64"` + ClientKeyID uint64 `gorm:"not null;check:chk_media_jobs_client_key_id,client_key_id > 0"` + ClientKeyName string `gorm:"size:160;not null;default:'';check:chk_media_jobs_client_key_name,length(client_key_name) <= 160"` + AccountID *uint64 `gorm:"check:chk_media_jobs_account_id,account_id IS NULL OR account_id > 0"` + AccountName string `gorm:"size:160;not null;default:'';check:chk_media_jobs_account_name,length(account_name) <= 160"` + EgressNodeID *uint64 `gorm:"check:chk_media_jobs_egress_node_id,egress_node_id IS NULL OR egress_node_id > 0"` + EgressNodeName string `gorm:"size:160;not null;default:'';check:chk_media_jobs_egress_node_name,length(egress_node_name) <= 160"` + EgressScope string `gorm:"size:32;not null;default:'';check:chk_media_jobs_egress_scope,egress_scope IN ('','grok_web','grok_build')"` + EgressMode string `gorm:"size:16;not null;default:'';check:chk_media_jobs_egress_mode,egress_mode IN ('','direct','proxy')"` + Provider string `gorm:"size:32;not null;check:chk_media_jobs_provider,provider IN ('grok_web','grok_build')"` + Model string `gorm:"size:255;not null;check:chk_media_jobs_model,length(trim(model)) BETWEEN 1 AND 255"` + ModelRouteID uint64 `gorm:"not null;check:chk_media_jobs_model_route_id,model_route_id > 0"` + UpstreamModel string `gorm:"size:255;not null;check:chk_media_jobs_upstream_model,length(trim(upstream_model)) BETWEEN 1 AND 255"` + Prompt string `gorm:"type:text;not null;check:chk_media_jobs_prompt,length(prompt) BETWEEN 0 AND 100000"` + Seconds int `gorm:"not null;check:chk_media_jobs_seconds,seconds BETWEEN 1 AND 15"` + Size string `gorm:"size:32;not null;check:chk_media_jobs_size,length(trim(size)) BETWEEN 1 AND 32"` + Quality string `gorm:"size:32;not null;check:chk_media_jobs_quality,length(trim(quality)) BETWEEN 1 AND 32"` + Status string `gorm:"size:32;not null;check:chk_media_jobs_status,status IN ('queued','in_progress','completed','failed')"` + Progress int `gorm:"not null;check:chk_media_jobs_progress,progress BETWEEN 0 AND 100"` + // InputJSON limit 33554432 must stay equal to media.MaxInputJSONBytes (GORM tags require literals). + InputJSON string `gorm:"type:text;not null;default:'{}';check:chk_media_jobs_input_json,length(input_json) <= 33554432"` + // InputImageCount upper bound 8 must stay equal to media.MaxInputImages. + InputImageCount *int `gorm:"check:chk_media_jobs_input_image_count,input_image_count IS NULL OR input_image_count BETWEEN 0 AND 8"` + UpstreamURL string `gorm:"type:text;not null;default:'';check:chk_media_jobs_upstream_url,length(upstream_url) <= 8192"` + ResultAssetID string `gorm:"size:64;not null;default:'';check:chk_media_jobs_result_asset_id,result_asset_id = '' OR length(trim(result_asset_id)) BETWEEN 16 AND 64"` + ContentType string `gorm:"size:128;not null;default:'';check:chk_media_jobs_content_type,length(content_type) <= 128"` + ErrorCode string `gorm:"size:100;not null;default:'';check:chk_media_jobs_error_code,length(error_code) <= 100"` + ErrorMessage string `gorm:"size:512;not null;default:'';check:chk_media_jobs_error_message,length(error_message) <= 512"` + LeaseUntil *time.Time + ClaimToken string `gorm:"size:64;not null;default:'';check:chk_media_jobs_claim_token,claim_token = '' OR length(claim_token) BETWEEN 16 AND 64"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + CompletedAt *time.Time + UsageRecordedAt *time.Time + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` + ClientKey *clientKeyModel `gorm:"foreignKey:ClientKeyID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` +} + +func (mediaJobModel) TableName() string { return "media_jobs" } + +// MaxVideoAssetBytes 是本地视频对象与上传接收的安全体积上限(256 MiB)。 +const MaxVideoAssetBytes = 256 << 20 + +type mediaAssetModel struct { + ID string `gorm:"size:64;primaryKey;check:chk_media_assets_id,length(trim(id)) BETWEEN 16 AND 64"` + Kind string `gorm:"size:16;not null;check:chk_media_assets_kind,kind IN ('image','video')"` + StorageKey string `gorm:"size:512;not null;uniqueIndex;check:chk_media_assets_storage_key,length(trim(storage_key)) BETWEEN 1 AND 512"` + MIMEType string `gorm:"size:64;not null;check:chk_media_assets_mime,mime_type IN ('image/jpeg','image/png','image/webp','image/gif','video/mp4','video/webm','video/quicktime')"` + SizeBytes int64 `gorm:"not null;check:chk_media_assets_size,size_bytes > 0 AND size_bytes <= 268435456"` + SHA256 string `gorm:"size:64;not null;check:chk_media_assets_sha,length(sha256) = 64"` + CreatedAt time.Time `gorm:"not null"` +} + +func (mediaAssetModel) TableName() string { return "media_assets" } + +// mediaUploadTicketModel 保存上游视频 PUT 票据的不可逆摘要与一次性消费状态。 +// 明文 token 永不落库;校验时对路径 token 再哈希比对。 +type mediaUploadTicketModel struct { + TokenHash string `gorm:"size:64;primaryKey;check:chk_media_upload_tickets_token_hash,length(token_hash) = 64"` + AssetID string `gorm:"size:64;not null;uniqueIndex;check:chk_media_upload_tickets_asset_id,length(trim(asset_id)) BETWEEN 16 AND 64"` + JobID string `gorm:"size:64;not null;index;check:chk_media_upload_tickets_job_id,length(trim(job_id)) BETWEEN 1 AND 64"` + MaxBytes int64 `gorm:"not null;check:chk_media_upload_tickets_max_bytes,max_bytes > 0 AND max_bytes <= 268435456"` + AllowedMIME string `gorm:"size:128;not null;default:'video/mp4';check:chk_media_upload_tickets_mime,length(trim(allowed_mime)) BETWEEN 1 AND 128"` + ExpiresAt time.Time `gorm:"not null"` + ConsumedAt *time.Time + CreatedAt time.Time `gorm:"not null"` +} + +func (mediaUploadTicketModel) TableName() string { return "media_upload_tickets" } + +type runtimeSettingsModel struct { + Key string `gorm:"size:64;primaryKey;check:chk_runtime_settings_key,length(trim(key)) BETWEEN 1 AND 64"` + ValueJSON string `gorm:"type:text;not null;check:chk_runtime_settings_json_length,length(value_json) <= 1048576"` + Revision uint64 `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (runtimeSettingsModel) TableName() string { return "runtime_settings" } + +type egressSubscriptionSourceModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:160;not null;uniqueIndex;check:chk_egress_subscription_sources_name,length(trim(name)) BETWEEN 1 AND 160"` + Scope string `gorm:"size:32;not null;check:chk_egress_subscription_sources_scope,scope IN ('grok_build','grok_web','grok_console','grok_web_asset')"` + Enabled bool `gorm:"not null;default:true"` + EncryptedURL string `gorm:"type:text;not null;default:'';check:chk_egress_subscription_sources_url,length(encrypted_url) <= 65536"` + RefreshIntervalSeconds int `gorm:"not null;default:900;check:chk_egress_subscription_sources_refresh,refresh_interval_seconds BETWEEN 60 AND 86400"` + DefaultAccountCapacity int `gorm:"not null;default:0;check:chk_egress_subscription_sources_capacity,default_account_capacity BETWEEN 0 AND 100000"` + LastSyncedAt *time.Time + NextSyncAt *time.Time `gorm:"index:idx_egress_subscription_sources_due"` + LastSyncImported int `gorm:"not null;default:0;check:chk_egress_subscription_sources_imported,last_sync_imported >= 0"` + LastSyncError string `gorm:"size:512;not null;default:'';check:chk_egress_subscription_sources_error,length(last_sync_error) <= 512"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (egressSubscriptionSourceModel) TableName() string { return "egress_subscription_sources" } + +type egressNodeModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:160;not null;check:chk_egress_nodes_name,length(trim(name)) BETWEEN 1 AND 160"` + Scope string `gorm:"size:32;not null;check:chk_egress_nodes_specific_scope,scope IN ('grok_build','grok_web','grok_console','grok_web_asset')"` + Enabled bool `gorm:"not null;default:true"` + ProxyPool bool `gorm:"not null;default:false"` + SourceID *uint64 `gorm:"uniqueIndex:uidx_egress_nodes_source_key,priority:1;index:idx_egress_nodes_source;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` + SourceKey string `gorm:"size:64;not null;default:'';uniqueIndex:uidx_egress_nodes_source_key,priority:2;check:chk_egress_nodes_source_key,length(source_key) <= 64"` + AccountCapacity int `gorm:"not null;default:0;check:chk_egress_nodes_capacity,account_capacity BETWEEN 0 AND 100000"` + EncryptedProxyURL string `gorm:"type:text;not null;default:'';check:chk_egress_nodes_proxy_url,length(encrypted_proxy_url) <= 65536"` + UserAgent string `gorm:"size:512;not null;default:'';check:chk_egress_nodes_user_agent,length(user_agent) <= 512"` + EncryptedCloudflareCookie string `gorm:"type:text;not null;default:'';check:chk_egress_nodes_cf_cookie,length(encrypted_cloudflare_cookie) <= 65536"` + ClearanceRefreshedAt *time.Time + ClearanceFingerprint string `gorm:"size:64;not null;default:'';check:chk_egress_nodes_clearance_fingerprint,length(clearance_fingerprint) IN (0, 64)"` + ClearanceBindingFingerprint string `gorm:"size:64;not null;default:'';check:chk_egress_nodes_clearance_binding_fingerprint,length(clearance_binding_fingerprint) IN (0, 64)"` + Health float64 `gorm:"not null;default:1;check:chk_egress_nodes_health,health >= 0 AND health <= 1"` + FailureCount int `gorm:"not null;default:0;check:chk_egress_nodes_failures,failure_count >= 0"` + CooldownUntil *time.Time + LastError string `gorm:"size:512;check:chk_egress_nodes_last_error,length(last_error) <= 512"` + ProbeStatus string `gorm:"size:16;not null;default:unknown;check:chk_egress_nodes_probe_status,probe_status IN ('unknown','healthy','unhealthy')"` + LastProbedAt *time.Time + ProbeLatencyMS int `gorm:"not null;default:0;check:chk_egress_nodes_probe_latency,probe_latency_ms >= 0"` + ExitIP string `gorm:"size:64;not null;default:'';check:chk_egress_nodes_exit_ip,length(exit_ip) <= 64"` + ProbeError string `gorm:"size:512;not null;default:'';check:chk_egress_nodes_probe_error,length(probe_error) <= 512"` + ProbeProvider string `gorm:"size:16;not null;default:'';check:chk_egress_nodes_probe_provider,probe_provider IN ('','ipinfo','cloudflare')"` + IPv4ProbeStatus string `gorm:"column:ipv4_probe_status;size:16;not null;default:unknown;check:chk_egress_nodes_ipv4_probe_status,ipv4_probe_status IN ('unknown','healthy','unhealthy')"` + IPv4LastProbedAt *time.Time `gorm:"column:ipv4_last_probed_at"` + IPv4ProbeLatencyMS int `gorm:"column:ipv4_probe_latency_ms;not null;default:0;check:chk_egress_nodes_ipv4_probe_latency,ipv4_probe_latency_ms >= 0"` + IPv4ExitIP string `gorm:"column:ipv4_exit_ip;size:64;not null;default:'';check:chk_egress_nodes_ipv4_exit_ip,length(ipv4_exit_ip) <= 64"` + IPv4ProbeError string `gorm:"column:ipv4_probe_error;size:512;not null;default:'';check:chk_egress_nodes_ipv4_probe_error,length(ipv4_probe_error) <= 512"` + IPv6ProbeStatus string `gorm:"column:ipv6_probe_status;size:16;not null;default:unknown;check:chk_egress_nodes_ipv6_probe_status,ipv6_probe_status IN ('unknown','healthy','unhealthy')"` + IPv6LastProbedAt *time.Time `gorm:"column:ipv6_last_probed_at"` + IPv6ProbeLatencyMS int `gorm:"column:ipv6_probe_latency_ms;not null;default:0;check:chk_egress_nodes_ipv6_probe_latency,ipv6_probe_latency_ms >= 0"` + IPv6ExitIP string `gorm:"column:ipv6_exit_ip;size:64;not null;default:'';check:chk_egress_nodes_ipv6_exit_ip,length(ipv6_exit_ip) <= 64"` + IPv6ProbeError string `gorm:"column:ipv6_probe_error;size:512;not null;default:'';check:chk_egress_nodes_ipv6_probe_error,length(ipv6_probe_error) <= 512"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Source *egressSubscriptionSourceModel `gorm:"foreignKey:SourceID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` +} + +func (egressNodeModel) TableName() string { return "egress_nodes" } + +type egressOperationsConfigModel struct { + ID uint64 `gorm:"primaryKey;check:chk_egress_operations_config_id,id = 1"` + ProbeProvider string `gorm:"size:16;not null;default:cloudflare;check:chk_egress_operations_config_probe_provider,probe_provider IN ('ipinfo','cloudflare')"` + ProbeIntervalSeconds int `gorm:"not null;default:900;check:chk_egress_operations_config_probe_interval,probe_interval_seconds BETWEEN 60 AND 86400"` + AutoAssignEnabled bool `gorm:"not null;default:false"` + AutoBalanceEnabled bool `gorm:"not null;default:false"` + AssignmentIntervalSeconds int `gorm:"not null;default:300;check:chk_egress_operations_config_assignment_interval,assignment_interval_seconds BETWEEN 60 AND 86400"` + BuildFallbackMode string `gorm:"size:16;not null;default:none"` + BuildFallbackNodeID uint64 `gorm:"not null;default:0"` + WebFallbackMode string `gorm:"size:16;not null;default:none"` + WebFallbackNodeID uint64 `gorm:"not null;default:0"` + ConsoleFallbackMode string `gorm:"size:16;not null;default:none"` + ConsoleFallbackNodeID uint64 `gorm:"not null;default:0"` + WebAssetFallbackMode string `gorm:"size:16;not null;default:none"` + WebAssetFallbackNodeID uint64 `gorm:"not null;default:0"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (egressOperationsConfigModel) TableName() string { return "egress_operations_config" } diff --git a/backend/internal/infra/persistence/relational/postgres_account_batch_update_test.go b/backend/internal/infra/persistence/relational/postgres_account_batch_update_test.go new file mode 100644 index 0000000000000000000000000000000000000000..37af7c1b7041cd75c5a52bec88074a54e44aa117 --- /dev/null +++ b/backend/internal/infra/persistence/relational/postgres_account_batch_update_test.go @@ -0,0 +1,44 @@ +package relational + +import ( + "context" + "os" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestPostgresAccountBatchUpdateBeyondAdminPageLimit(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx := context.Background() + database, err := OpenPostgres(ctx, dsn, 5, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + rows := seedBatchUpdateAccounts(t, database, 2001) + ids := accountModelIDs(rows) + maxConcurrent := 3 + + updated, err := NewAccountRepository(database).UpdateMany(ctx, account.ProviderBuild, ids, repository.AccountUpdates{MaxConcurrent: &maxConcurrent}) + if err != nil { + t.Fatal(err) + } + if updated != int64(len(ids)) { + t.Fatalf("updated = %d, want %d", updated, len(ids)) + } + var count int64 + if err := database.db.Model(&accountModel{}).Where("id IN ? AND max_concurrent = ?", ids, maxConcurrent).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != int64(len(ids)) { + t.Fatalf("stored count = %d, want %d", count, len(ids)) + } +} diff --git a/backend/internal/infra/persistence/relational/postgres_integration_test.go b/backend/internal/infra/persistence/relational/postgres_integration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2666f1135ac96336819900c436e6ece0764d82fb --- /dev/null +++ b/backend/internal/infra/persistence/relational/postgres_integration_test.go @@ -0,0 +1,1123 @@ +package relational + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "net/url" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + auditapp "github.com/chenyme/grok2api/backend/internal/application/audit" + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +const testPostgresAdminDSNEnv = "TEST_POSTGRES_ADMIN_DSN" + +func TestMain(m *testing.M) { + cleanup, err := configureTemporaryPostgresIntegrationDatabase() + if err != nil { + fmt.Fprintln(os.Stderr, "configure temporary PostgreSQL integration database:", err) + os.Exit(1) + } + code := m.Run() + if cleanup != nil { + if err := cleanup(); err != nil { + fmt.Fprintln(os.Stderr, "drop temporary PostgreSQL integration database:", err) + code = 1 + } + } + os.Exit(code) +} + +func configureTemporaryPostgresIntegrationDatabase() (func() error, error) { + if os.Getenv("TEST_POSTGRES_DSN") != "" { + return nil, nil + } + adminDSN := os.Getenv(testPostgresAdminDSNEnv) + if adminDSN == "" { + return nil, nil + } + parsed, err := url.Parse(adminDSN) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("%s must be a PostgreSQL URL", testPostgresAdminDSNEnv) + } + decodedQuery, decodeErr := url.QueryUnescape(parsed.RawQuery) + if decodeErr != nil { + return nil, fmt.Errorf("decode PostgreSQL URL query: %w", decodeErr) + } + parsed.RawQuery = decodedQuery + adminDSN = parsed.String() + name := fmt.Sprintf("grok2api_phase0_%d", time.Now().UTC().UnixNano()) + admin, err := gorm.Open(postgres.Open(adminDSN), &gorm.Config{}) + if err != nil { + return nil, err + } + adminSQL, err := admin.DB() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + identifier := quotePostgresTestIdentifier(name) + if err := admin.WithContext(ctx).Exec("CREATE DATABASE " + identifier).Error; err != nil { + _ = adminSQL.Close() + return nil, err + } + parsed.Path = "/" + name + if err := os.Setenv("TEST_POSTGRES_DSN", parsed.String()); err != nil { + _ = admin.WithContext(ctx).Exec("DROP DATABASE IF EXISTS " + identifier).Error + _ = adminSQL.Close() + return nil, err + } + return func() error { + defer adminSQL.Close() + defer os.Unsetenv("TEST_POSTGRES_DSN") + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + if err := admin.WithContext(cleanupCtx).Exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ? AND pid <> pg_backend_pid()", name).Error; err != nil { + return err + } + return admin.WithContext(cleanupCtx).Exec("DROP DATABASE IF EXISTS " + identifier).Error + }, nil +} + +func quotePostgresTestIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} + +func TestPostgresConcurrentSchemaInitializationUsesMigrationLock(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + databases := make([]*Database, 2) + for index := range databases { + database, err := OpenPostgres(ctx, dsn, 1, 1) + if err != nil { + t.Fatal(err) + } + databases[index] = database + defer database.Close() + } + start := make(chan struct{}) + errorsCh := make(chan error, len(databases)) + var wait sync.WaitGroup + for _, database := range databases { + wait.Add(1) + go func(value *Database) { + defer wait.Done() + <-start + errorsCh <- value.InitializeSchema(ctx) + }(database) + } + close(start) + wait.Wait() + close(errorsCh) + for err := range errorsCh { + if err != nil { + t.Fatal(err) + } + } +} + +func TestPostgresAccountLinkMutationLockSerializesTransactions(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 2, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + first := database.db.WithContext(ctx).Begin() + if first.Error != nil { + t.Fatal(first.Error) + } + defer first.Rollback() + if err := lockAccountLinkMutation(first); err != nil { + t.Fatal(err) + } + + started := make(chan struct{}) + result := make(chan error, 1) + go func() { + second := database.db.WithContext(ctx).Begin() + if second.Error != nil { + result <- second.Error + return + } + close(started) + err := lockAccountLinkMutation(second) + if rollbackErr := second.Rollback().Error; err == nil { + err = rollbackErr + } + result <- err + }() + <-started + select { + case err := <-result: + t.Fatalf("second account-link mutation was not serialized: %v", err) + case <-time.After(150 * time.Millisecond): + } + if err := first.Rollback().Error; err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(accountLinkLockTimeout + time.Second): + t.Fatal("account-link mutation did not resume after advisory lock release") + } +} + +func TestPostgresAccountLinkMutationLockHasBoundedWait(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 2, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + first := database.db.WithContext(ctx).Begin() + if first.Error != nil { + t.Fatal(first.Error) + } + defer first.Rollback() + if err := lockAccountLinkMutation(first); err != nil { + t.Fatal(err) + } + + second := database.db.WithContext(ctx).Begin() + if second.Error != nil { + t.Fatal(second.Error) + } + defer second.Rollback() + startedAt := time.Now() + err = lockAccountLinkMutationWithTimeout(second, 100*time.Millisecond) + if !errors.Is(err, repository.ErrConflict) { + t.Fatalf("bounded link-mutation lock error = %v", err) + } + if elapsed := time.Since(startedAt); elapsed < 75*time.Millisecond || elapsed > time.Second { + t.Fatalf("bounded link-mutation lock elapsed = %s", elapsed) + } +} + +func assertPostgresAccountLinkMutationWaits(t *testing.T, ctx context.Context, database *Database, operation func() error) { + t.Helper() + guard := database.db.WithContext(ctx).Begin() + if guard.Error != nil { + t.Fatal(guard.Error) + } + defer guard.Rollback() + if err := lockAccountLinkMutation(guard); err != nil { + t.Fatal(err) + } + + result := make(chan error, 1) + go func() { result <- operation() }() + select { + case err := <-result: + t.Fatalf("account-link mutation bypassed advisory lock: %v", err) + case <-time.After(150 * time.Millisecond): + } + if err := guard.Rollback().Error; err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(accountLinkLockTimeout + time.Second): + t.Fatal("account-link mutation did not resume after advisory lock release") + } +} + +func TestPostgresRepositoriesIntegration(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx := context.Background() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keyRepository := NewClientKeyRepository(database) + poolKey, err := keyRepository.Create(ctx, clientkey.Key{Name: "postgres-account-scope", Prefix: "postgres-account-scope", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true}) + if err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Migrator().DropColumn(&clientKeyModel{}, "ProviderScopeMask"); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Migrator().DropColumn(&clientKeyModel{}, "TierScopeMask"); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + loadedPoolKey, err := keyRepository.Get(ctx, poolKey.ID) + if err != nil || loadedPoolKey.ProviderScope != clientkey.ProviderScopeAll || loadedPoolKey.TierScope != clientkey.TierScopeAll { + t.Fatalf("postgres migrated client key account scope = %+v, err = %v", loadedPoolKey.AccountScope(), err) + } + loadedPoolKey.ProviderScope = clientkey.ProviderScopeBuild | clientkey.ProviderScopeWeb + loadedPoolKey.TierScope = clientkey.TierScopeSuper + loadedPoolKey, err = keyRepository.Update(ctx, loadedPoolKey) + if err != nil || loadedPoolKey.ProviderScope != clientkey.ProviderScopeBuild|clientkey.ProviderScopeWeb || loadedPoolKey.TierScope != clientkey.TierScopeSuper { + t.Fatalf("postgres updated client key account scope = %+v, err = %v", loadedPoolKey.AccountScope(), err) + } + if err := keyRepository.Delete(ctx, poolKey.ID); err != nil { + t.Fatal(err) + } + verifyPostgresMediaJobInputConstraintUpgrade(t, ctx, database) + repository := NewAccountRepository(database) + created, wasCreated, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, Name: "postgres", SourceKey: "postgres-integration-" + time.Now().UTC().Format("150405.000000"), + EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil || !wasCreated || created.ID == 0 { + t.Fatalf("account = %#v, created = %v, err = %v", created, wasCreated, err) + } + loaded, err := repository.Get(ctx, created.ID) + if err != nil || loaded.SourceKey != created.SourceKey { + t.Fatalf("loaded = %#v, err = %v", loaded, err) + } + if err := repository.Delete(ctx, created.ID); err != nil { + t.Fatal(err) + } + firstTokenMS := int64(100) + auditCreatedAt := time.Now().UTC() + auditRepository := NewAuditRepository(database) + if err := auditRepository.Create(ctx, audit.Record{ + RequestID: "postgres-first-token-" + auditCreatedAt.Format("150405.000000000"), ClientKeyID: 1, ModelRouteID: 1, + Provider: "grok_build", Operation: audit.OperationResponses, UsageSource: audit.UsageSourceUpstream, + StatusCode: 200, Streaming: true, OutputTokens: 100, TotalTokens: 100, FirstTokenMS: &firstTokenMS, DurationMS: 1100, CreatedAt: auditCreatedAt, + }); err != nil { + t.Fatal(err) + } + auditRows, _, err := auditRepository.List(ctx, 0, 1) + if err != nil || len(auditRows) != 1 || auditRows[0].FirstTokenMS == nil || *auditRows[0].FirstTokenMS != firstTokenMS { + t.Fatalf("postgres first-token audit = %#v, err = %v", auditRows, err) + } + boundaries := testDashboardBoundaries(auditCreatedAt.Add(-time.Hour), time.Hour, 2) + dashboardSnapshot, err := NewDashboardRepository(database).Snapshot(ctx, testDashboardWindow(boundaries), auditCreatedAt.Add(time.Hour)) + if err != nil || dashboardSnapshot.Usage.FirstTokenSamples != 1 || dashboardSnapshot.Usage.ThroughputTokens != 100 || dashboardSnapshot.Usage.GenerationTotalMS != 1000 { + t.Fatalf("postgres performance aggregate = %#v, err = %v", dashboardSnapshot.Usage, err) + } + + unique := time.Now().UTC().Format("20060102150405.000000000") + digestBytes := sha256.Sum256([]byte(unique)) + digest := hex.EncodeToString(digestBytes[:]) + identity := "sso_" + digest[:32] + userID := "postgres-linked-" + unique + web, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "postgres-web", SourceKey: "sso:" + digest, + UserID: userID, EgressIdentity: identity, EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + build, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "postgres-build", SourceKey: "postgres-build-" + unique, + UserID: userID, EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + console, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "postgres-console", SourceKey: "console-sso:" + digest, + EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + assertPostgresAccountLinkMutationWaits(t, ctx, database, func() error { + return repository.ReconcileProviderLinks(ctx, web.ID) + }) + assertPostgresAccountLinkMutationWaits(t, ctx, database, func() error { + return repository.LinkWebToBuild(ctx, web.ID, build.ID) + }) + web, err = repository.Get(ctx, web.ID) + if err != nil || len(web.LinkedAccounts) != 2 { + t.Fatalf("postgres linked accounts = %#v, err = %v", web.LinkedAccounts, err) + } + otherConsole, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "postgres-console-conflict", SourceKey: "console-conflict-" + unique, + EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webConsoleAccountLinkModel{ + WebAccountID: web.ID, ConsoleAccountID: otherConsole.ID, CreatedAt: time.Now().UTC(), + }).Error; err == nil { + t.Fatal("postgres web/console one-to-one constraint was not enforced") + } + if err := repository.Delete(ctx, web.ID); err != nil { + t.Fatal(err) + } + for _, id := range []uint64{build.ID, console.ID} { + linked, getErr := repository.Get(ctx, id) + if getErr != nil { + t.Fatalf("deleting Web removed linked account %d: %v", id, getErr) + } + if len(linked.LinkedAccounts) != 0 { + t.Fatalf("deleting Web retained links for account %d: %#v", id, linked.LinkedAccounts) + } + } + for _, model := range []any{&accountProviderLinkModel{}, &webConsoleAccountLinkModel{}} { + var remainingLinks int64 + if err := database.db.WithContext(ctx).Model(model).Where("web_account_id = ?", web.ID).Count(&remainingLinks).Error; err != nil || remainingLinks != 0 { + t.Fatalf("postgres Web relation cascade model=%T count=%d err=%v", model, remainingLinks, err) + } + } + for _, id := range []uint64{build.ID, console.ID, otherConsole.ID} { + if err := repository.Delete(ctx, id); err != nil { + t.Fatal(err) + } + } +} + +func TestPostgresMigratesLegacyClientKeyAccountPoolToScopes(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx := context.Background() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewClientKeyRepository(database) + value, err := repository.Create(ctx, clientkey.Key{Name: "postgres-legacy-pool", Prefix: "postgres-legacy-pool", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true}) + if err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Exec("ALTER TABLE client_keys DROP COLUMN provider_scope_mask, DROP COLUMN tier_scope_mask").Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Exec("ALTER TABLE client_keys ADD COLUMN account_pool text NOT NULL DEFAULT 'all' CHECK (account_pool IN ('all','free','super'))").Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Table("client_keys").Where("id = ?", value.ID).Update("account_pool", "free").Error; err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + stored, err := repository.Get(ctx, value.ID) + if err != nil || stored.ProviderScope != clientkey.ProviderScopeAll || stored.TierScope != clientkey.TierScopeFree { + t.Fatalf("migrated PostgreSQL account scope = %+v, err = %v", stored.AccountScope(), err) + } + if err := repository.Delete(ctx, value.ID); err != nil { + t.Fatal(err) + } +} + +func TestPostgresUnhealthyEgressCleanupUsesBothAddressFamilies(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 5, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + tx := database.db.WithContext(ctx).Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + testDatabase := &Database{db: tx, dialect: database.dialect} + + nodes := NewEgressRepository(testDatabase) + accounts := NewAccountRepository(testDatabase) + cipher := egressOperationsCipher(t) + prefix := "postgres-cleanup-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + dualStackFailure := createHealthyEgressNode(t, ctx, nodes, cipher, prefix+"-dual", 0) + singleStackAvailable := createHealthyEgressNode(t, ctx, nodes, cipher, prefix+"-single", 0) + setEgressProbeFamilies(t, ctx, nodes, dualStackFailure, egressdomain.ProbeStatusUnhealthy, egressdomain.ProbeStatusUnhealthy) + setEgressProbeFamilies(t, ctx, nodes, singleStackAvailable, egressdomain.ProbeStatusHealthy, egressdomain.ProbeStatusUnhealthy) + + credential := createEgressOperationsAccount(t, ctx, accounts, prefix+"-account") + if _, err := accounts.UpdateEgressBindings(ctx, account.ProviderBuild, []uint64{credential.ID}, &dualStackFailure.ID, account.EgressAssignmentManual, time.Now().UTC()); err != nil { + t.Fatal(err) + } + service := egressapp.NewService(nodes, cipher, "test-browser", accounts) + deleted, err := service.DeleteUnhealthy(ctx) + if err != nil { + t.Fatal(err) + } + if deleted < 1 { + t.Fatalf("deleted = %d", deleted) + } + if _, err := nodes.GetEgressNode(ctx, dualStackFailure.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("dual-stack unhealthy node still exists: %v", err) + } + if _, err := nodes.GetEgressNode(ctx, singleStackAvailable.ID); err != nil { + t.Fatalf("single-stack available node was deleted: %v", err) + } + stored, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.EgressNodeID != 0 || stored.EgressAssignmentMode != "" || stored.EgressAssignedAt != nil { + t.Fatalf("account binding not cleared: %#v", stored) + } +} + +func TestPostgresBillingReservationAndAuditSettlementConcurrency(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx := context.Background() + database, err := OpenPostgres(ctx, dsn, 20, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkey.Key{Name: "postgres-billing", Prefix: "postgres-billing", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + + const workers = 20 + start := make(chan struct{}) + errorsCh := make(chan error, workers) + var successes atomic.Int64 + var successMu sync.Mutex + successfulEventIDs := make([]string, 0, 10) + var wait sync.WaitGroup + wait.Add(workers) + for index := range workers { + go func(index int) { + defer wait.Done() + <-start + eventID := fmt.Sprintf("evt_postgres_reserve_%04d", index) + reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, eventID, 100, time.Now().UTC().Add(time.Hour)) + switch { + case reserveErr == nil && reserved: + successes.Add(1) + successMu.Lock() + successfulEventIDs = append(successfulEventIDs, eventID) + successMu.Unlock() + case errors.Is(reserveErr, repository.ErrLimitExceeded): + default: + errorsCh <- fmt.Errorf("reservation %d: reserved=%v err=%w", index, reserved, reserveErr) + } + }(index) + } + close(start) + wait.Wait() + close(errorsCh) + for reserveErr := range errorsCh { + t.Error(reserveErr) + } + if t.Failed() { + return + } + if successes.Load() != 10 { + t.Fatalf("successful PostgreSQL reservations = %d, want 10", successes.Load()) + } + stored, err := keys.Get(ctx, key.ID) + if err != nil || stored.ReservedUsageUSDTicks != 1_000 { + t.Fatalf("reserved billing state = %#v, err = %v", stored, err) + } + + audits := NewAuditRepository(database) + batchRecords := make([]audit.Record, 0, len(successfulEventIDs)) + for _, eventID := range successfulEventIDs { + batchRecords = append(batchRecords, audit.Record{ + EventID: eventID, RequestID: eventID, ClientKeyID: key.ID, ModelRouteID: 1, + Provider: "grok_build", Operation: audit.OperationResponses, UsageSource: audit.UsageSourceUpstream, + StatusCode: 200, CostInUSDTicks: 100, CreatedAt: time.Now().UTC(), + Attempts: []audit.Attempt{{Number: 1, Source: audit.AttemptSourceCredential, Stage: "credential", StartedAt: time.Now().UTC()}}, + }) + } + if err := audits.CreateBatch(ctx, batchRecords); err != nil { + t.Fatal(err) + } + stored, err = keys.Get(ctx, key.ID) + if err != nil || stored.ReservedUsageUSDTicks != 0 || stored.BilledUsageUSDTicks != 1_000 { + t.Fatalf("settled billing state = %#v, err = %v", stored, err) + } + + settlementKey, err := keys.Create(ctx, clientkey.Key{Name: "postgres-settlement", Prefix: "postgres-settlement", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + for index := range 10 { + now := time.Now().UTC() + eventID := fmt.Sprintf("evt_postgres_cleanup_settle_%04d", index) + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, settlementKey.ID, eventID, 10, now.Add(-time.Minute)); reserveErr != nil || !reserved { + t.Fatalf("settlement reservation %d: reserved=%v err=%v", index, reserved, reserveErr) + } + start := make(chan struct{}) + errorsCh := make(chan error, 2) + go func() { + <-start + errorsCh <- audits.Create(ctx, audit.Record{EventID: eventID, RequestID: eventID, ClientKeyID: settlementKey.ID, ModelRouteID: 1, Provider: "grok_build", Operation: audit.OperationResponses, UsageSource: audit.UsageSourceUpstream, StatusCode: 200, CostInUSDTicks: 10, CreatedAt: now}) + }() + go func() { + <-start + _, cleanupErr := keys.CleanupExpiredBillingReservations(ctx, now, 1) + errorsCh <- cleanupErr + }() + close(start) + for range 2 { + if concurrentErr := <-errorsCh; concurrentErr != nil { + t.Fatalf("cleanup and settlement %d: %v", index, concurrentErr) + } + } + } + stored, err = keys.Get(ctx, settlementKey.ID) + if err != nil || stored.ReservedUsageUSDTicks != 0 || stored.BilledUsageUSDTicks != 100 { + t.Fatalf("cleanup and settlement billing state = %#v, err = %v", stored, err) + } +} + +func TestPostgresAuditWriterRecoversAfterTerminatedTransaction(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkey.Key{Name: "postgres-terminated-writer", Prefix: "postgres-terminated-writer", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + const eventID = "evt_postgres_terminated_writer_0001" + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, eventID, 100, time.Now().UTC().Add(time.Hour)); reserveErr != nil || !reserved { + t.Fatalf("reserve billing usage: reserved=%v err=%v", reserved, reserveErr) + } + + blocker := database.db.WithContext(ctx).Begin() + if blocker.Error != nil { + t.Fatal(blocker.Error) + } + if err := blocker.Exec("SELECT id FROM client_keys WHERE id = ? FOR UPDATE", key.ID).Error; err != nil { + t.Fatal(err) + } + + service := auditapp.NewService(NewAuditRepository(database), slog.New(slog.NewTextHandler(io.Discard, nil)), 32, 16, 100*time.Millisecond) + service.UpdateWriterConfig(16, 100*time.Millisecond, time.Millisecond) + service.Start() + defer func() { + closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer closeCancel() + if err := service.Close(closeCtx); err != nil { + t.Errorf("close audit service: %v", err) + } + }() + defer blocker.Rollback() + record := audit.Record{ + EventID: eventID, RequestID: eventID, ClientKeyID: key.ID, ModelRouteID: 1, + Provider: "grok_build", Operation: audit.OperationResponses, UsageSource: audit.UsageSourceUpstream, + StatusCode: 200, CostInUSDTicks: 100, CreatedAt: time.Now().UTC(), + Attempts: []audit.Attempt{{Number: 1, Source: audit.AttemptSourceCredential, Stage: "credential", StartedAt: time.Now().UTC()}}, + } + result := make(chan error, 1) + go func() { result <- service.Create(ctx, record) }() + + pid := waitForPostgresLockWaiter(t, ctx, database.db) + var terminated bool + if err := database.db.WithContext(ctx).Raw("SELECT pg_terminate_backend(?)", pid).Scan(&terminated).Error; err != nil { + t.Fatal(err) + } + if !terminated { + t.Fatalf("PostgreSQL backend %d was not terminated", pid) + } + if err := blocker.Rollback().Error; err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err != nil { + t.Fatalf("acknowledged audit did not recover after connection termination: %v", err) + } + case <-ctx.Done(): + t.Fatalf("audit writer recovery timed out: %v", ctx.Err()) + } + assertPostgresAuditSettlement(t, ctx, database, keys, key.ID, eventID, 100, 1) + + if err := NewAuditRepository(database).CreateBatch(ctx, []audit.Record{record}); err != nil { + t.Fatalf("idempotent replay: %v", err) + } + assertPostgresAuditSettlement(t, ctx, database, keys, key.ID, eventID, 100, 1) +} + +func TestPostgresAuditBatchRollsBackOnLockTimeoutAndRecovers(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := NewClientKeyRepository(database) + key, err := keys.Create(ctx, clientkey.Key{Name: "postgres-lock-timeout", Prefix: "postgres-lock-timeout", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + const eventID = "evt_postgres_lock_timeout_0001" + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, key.ID, eventID, 100, time.Now().UTC().Add(time.Hour)); reserveErr != nil || !reserved { + t.Fatalf("reserve billing usage: reserved=%v err=%v", reserved, reserveErr) + } + + timeoutDSN, err := postgresDSNWithOption(dsn, "-c lock_timeout=150ms") + if err != nil { + t.Fatal(err) + } + timeoutDatabase, err := OpenPostgres(ctx, timeoutDSN, 1, 1) + if err != nil { + t.Fatal(err) + } + defer timeoutDatabase.Close() + timeoutAudits := NewAuditRepository(timeoutDatabase) + blocker := database.db.WithContext(ctx).Begin() + if blocker.Error != nil { + t.Fatal(blocker.Error) + } + defer blocker.Rollback() + if err := blocker.Exec("SELECT id FROM client_keys WHERE id = ? FOR UPDATE", key.ID).Error; err != nil { + t.Fatal(err) + } + record := audit.Record{ + EventID: eventID, RequestID: eventID, ClientKeyID: key.ID, ModelRouteID: 1, + Provider: "grok_build", Operation: audit.OperationResponses, UsageSource: audit.UsageSourceUpstream, + StatusCode: 200, CostInUSDTicks: 100, CreatedAt: time.Now().UTC(), + Attempts: []audit.Attempt{{Number: 1, Source: audit.AttemptSourceUpstreamHTTP, Stage: "response", StartedAt: time.Now().UTC()}}, + } + if err := timeoutAudits.CreateBatch(ctx, []audit.Record{record}); err == nil { + t.Fatal("audit batch unexpectedly succeeded while the client key lock was held") + } else if !strings.Contains(err.Error(), "SQLSTATE 55P03") { + t.Fatalf("audit batch failed for an unexpected reason: %v", err) + } + assertPostgresAuditSettlement(t, ctx, database, keys, key.ID, eventID, 0, 0) + stored, err := keys.Get(ctx, key.ID) + if err != nil || stored.ReservedUsageUSDTicks != 100 { + t.Fatalf("reservation was not preserved after rollback: key=%#v err=%v", stored, err) + } + if err := blocker.Rollback().Error; err != nil { + t.Fatal(err) + } + if err := timeoutAudits.CreateBatch(ctx, []audit.Record{record}); err != nil { + t.Fatalf("retry after lock release: %v", err) + } + assertPostgresAuditSettlement(t, ctx, database, keys, key.ID, eventID, 100, 1) +} + +func TestPostgresAuditBatchUsesStableClientKeyLockOrder(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + keys := NewClientKeyRepository(database) + firstKey, err := keys.Create(ctx, clientkey.Key{Name: "postgres-lock-order-first", Prefix: "postgres-lock-order-first", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + secondKey, err := keys.Create(ctx, clientkey.Key{Name: "postgres-lock-order-second", Prefix: "postgres-lock-order-second", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, BillingLimitUSDTicks: 1_000}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + batchOne := []audit.Record{ + postgresBillingAudit("evt_postgres_lock_order_a1", firstKey.ID, now), + postgresBillingAudit("evt_postgres_lock_order_b1", secondKey.ID, now), + } + batchTwo := []audit.Record{ + postgresBillingAudit("evt_postgres_lock_order_b2", secondKey.ID, now), + postgresBillingAudit("evt_postgres_lock_order_a2", firstKey.ID, now), + } + for _, record := range append(append([]audit.Record(nil), batchOne...), batchTwo...) { + if reserved, reserveErr := keys.ReserveBillingUsage(ctx, record.ClientKeyID, record.EventID, 10, now.Add(time.Hour)); reserveErr != nil || !reserved { + t.Fatalf("reserve %s: reserved=%v err=%v", record.EventID, reserved, reserveErr) + } + } + blocker := database.db.WithContext(ctx).Begin() + if blocker.Error != nil { + t.Fatal(blocker.Error) + } + defer blocker.Rollback() + if err := blocker.Exec("SELECT id FROM client_keys WHERE id = ? FOR UPDATE", firstKey.ID).Error; err != nil { + t.Fatal(err) + } + audits := NewAuditRepository(database) + start := make(chan struct{}) + results := make(chan error, 2) + go func() { <-start; results <- audits.CreateBatch(ctx, batchOne) }() + go func() { <-start; results <- audits.CreateBatch(ctx, batchTwo) }() + close(start) + waitForPostgresLockWaiters(t, ctx, database.db, 2) + if err := blocker.Rollback().Error; err != nil { + t.Fatal(err) + } + for range 2 { + if err := <-results; err != nil { + t.Fatalf("opposite-order audit batches should not deadlock: %v", err) + } + } + for _, keyID := range []uint64{firstKey.ID, secondKey.ID} { + stored, err := keys.Get(ctx, keyID) + if err != nil || stored.ReservedUsageUSDTicks != 0 || stored.BilledUsageUSDTicks != 20 { + t.Fatalf("stable lock order settlement key=%d value=%#v err=%v", keyID, stored, err) + } + } +} + +func waitForPostgresLockWaiter(t *testing.T, ctx context.Context, database *gorm.DB) int { + t.Helper() + return waitForPostgresLockWaiters(t, ctx, database, 1)[0] +} + +func waitForPostgresLockWaiters(t *testing.T, ctx context.Context, database *gorm.DB, count int) []int { + t.Helper() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var rows []postgresPIDRow + err := database.WithContext(ctx).Raw(` + SELECT pid + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query LIKE '%client_keys%' + ORDER BY query_start + `).Scan(&rows).Error + if err != nil { + t.Fatal(err) + } + if len(rows) >= count { + pids := make([]int, count) + for index := range count { + pids[index] = int(rows[index].PID) + } + return pids + } + select { + case <-ctx.Done(): + t.Fatalf("wait for PostgreSQL lock waiter: %v", ctx.Err()) + case <-ticker.C: + } + } +} + +type postgresPIDRow struct { + PID int64 `gorm:"column:pid"` +} + +func postgresDSNWithOption(dsn, option string) (string, error) { + if _, err := url.ParseRequestURI(dsn); err != nil { + return "", err + } + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + return dsn + separator + "options=" + url.QueryEscape(option), nil +} + +func postgresBillingAudit(eventID string, keyID uint64, createdAt time.Time) audit.Record { + return audit.Record{ + EventID: eventID, RequestID: eventID, ClientKeyID: keyID, ModelRouteID: 1, + Provider: "grok_build", Operation: audit.OperationResponses, UsageSource: audit.UsageSourceUpstream, + StatusCode: 200, CostInUSDTicks: 10, CreatedAt: createdAt, + } +} + +func assertPostgresAuditSettlement(t *testing.T, ctx context.Context, database *Database, keys *ClientKeyRepository, keyID uint64, eventID string, billed int64, attempts int64) { + t.Helper() + var row requestAuditModel + err := database.db.WithContext(ctx).Where("event_id = ?", eventID).Take(&row).Error + if billed == 0 { + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("audit should be absent after rollback: row=%#v err=%v", row, err) + } + } else if err != nil { + t.Fatalf("load settled audit: %v", err) + } + var attemptCount int64 + if row.ID != 0 { + if err := database.db.WithContext(ctx).Model(&requestAuditAttemptModel{}).Where("audit_id = ?", row.ID).Count(&attemptCount).Error; err != nil { + t.Fatal(err) + } + } + if attemptCount != attempts { + t.Fatalf("attempt count = %d, want %d", attemptCount, attempts) + } + stored, err := keys.Get(ctx, keyID) + if err != nil { + t.Fatal(err) + } + if stored.BilledUsageUSDTicks != billed { + t.Fatalf("billed usage = %d, want %d", stored.BilledUsageUSDTicks, billed) + } + if billed > 0 && stored.ReservedUsageUSDTicks != 0 { + t.Fatalf("reserved usage = %d after settlement", stored.ReservedUsageUSDTicks) + } +} + +func TestPostgresRoutingProjectionAndCredentialHydration(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Errorf("close PostgreSQL routing projection database: %v", err) + } + }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + accounts := NewAccountRepository(database) + now := time.Now().UTC().Truncate(time.Second) + unique := strconv.FormatInt(now.UnixNano(), 10) + created, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "postgres routing projection", + SourceKey: "postgres-routing-projection-" + unique, OIDCClientID: "postgres-client", + EncryptedAccessToken: "postgres-access-secret", EncryptedCloudflareCookie: "postgres-cookie-secret", + ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + Priority: 7, MaxConcurrent: 3, MinimumRemaining: 1.5, WebTier: account.WebTierSuper, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + if err := accounts.Delete(cleanupCtx, created.ID); err != nil && !errors.Is(err, repository.ErrNotFound) { + t.Errorf("delete PostgreSQL routing projection account: %v", err) + } + }) + if err := accounts.SaveBilling(ctx, account.Billing{ + AccountID: created.ID, PlanCode: "super", MonthlyLimit: 100, Used: 12, SyncedAt: now, + History: []account.BillingHistoryEntry{{Year: 2026, Month: 7, IncludedUsed: 12}}, + }); err != nil { + t.Fatal(err) + } + resetAt := now.Add(time.Hour) + if err := accounts.SaveQuotaWindows(ctx, created.ID, account.WebTierSuper, now, []account.QuotaWindow{{ + AccountID: created.ID, Mode: "weekly", Remaining: 10, Total: 20, UsagePercent: 50, + Breakdown: []account.QuotaBreakdown{{ProductCode: account.QuotaProductChat, UsagePercent: 50}}, + WindowSeconds: 3600, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }}); err != nil { + t.Fatal(err) + } + + bases, err := accounts.ListRoutingAccountBases(ctx, account.ProviderWeb, "") + if err != nil { + t.Fatal(err) + } + var projected *account.RoutingAccountBase + for index := range bases { + if bases[index].Credential.ID == created.ID { + projected = &bases[index] + break + } + } + if projected == nil { + t.Fatalf("routing projection account %d not found", created.ID) + } + assertRoutingProjection(t, projected.Credential, created) + if projected.Billing == nil || projected.Billing.PlanCode != "super" || len(projected.Billing.History) != 0 { + t.Fatalf("routing billing = %#v, want scalar billing without history", projected.Billing) + } + if projected.QuotaWindow == nil || projected.QuotaWindow.Remaining != 10 || len(projected.QuotaWindow.Breakdown) != 0 { + t.Fatalf("routing quota = %#v, want scalar quota without breakdown", projected.QuotaWindow) + } + + material, err := accounts.GetCredentialMaterial(ctx, created.ID, account.ProviderWeb) + if err != nil { + t.Fatal(err) + } + if material.Provider != account.ProviderWeb || material.EncryptedAccessToken != "postgres-access-secret" || material.EncryptedCloudflareCookie != "postgres-cookie-secret" { + t.Fatalf("credential material = %#v", material) + } + if _, err := accounts.GetCredentialMaterial(ctx, created.ID, account.ProviderBuild); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("cross-provider credential error = %v, want ErrNotFound", err) + } + disabled := false + if _, err := accounts.UpdateMany(ctx, account.ProviderBuild, []uint64{created.ID}, repository.AccountUpdates{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + if _, err := accounts.GetCredentialMaterial(ctx, created.ID, account.ProviderWeb); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("disabled credential error = %v, want ErrNotFound", err) + } +} + +func TestPostgresP1RouteLookupAndBoundedResponseCleanup(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx := context.Background() + database, err := OpenPostgres(ctx, dsn, 10, 2) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + aliasRoute := modelRouteModel{PublicID: "Build/grok-p1-legacy", Provider: string(account.ProviderBuild), UpstreamModel: "grok-p1-legacy", Capability: "responses", Origin: "manual", Enabled: true} + directRoute := modelRouteModel{PublicID: "Web/grok-p1", Provider: string(account.ProviderWeb), UpstreamModel: "grok-p1", Capability: "chat", Origin: "manual", Enabled: true} + if err := database.db.WithContext(ctx).Create(&aliasRoute).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&directRoute).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&modelRouteAliasModel{Alias: "grok-p1", ModelRouteID: aliasRoute.ID, CreatedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + modelRepository := NewModelRepository(database) + route, err := modelRepository.GetByPublicIDIncludingDisabled(ctx, "grok-p1") + if err != nil || route.ID != directRoute.ID { + t.Fatalf("direct route priority = %#v, err = %v", route, err) + } + + accounts := NewAccountRepository(database) + accountValue, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderWeb, Name: "postgres-p1", SourceKey: "postgres-p1", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + key, err := NewClientKeyRepository(database).Create(ctx, clientkey.Key{Name: "postgres-p1", Prefix: "postgres-p1", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true}) + if err != nil { + t.Fatal(err) + } + responses := NewResponseRepository(database) + now := time.Now().UTC() + for index := range 3 { + id := fmt.Sprintf("postgres-p1-%d", index) + if err := responses.Save(ctx, inferencedomain.ResponseOwnership{ResponseID: "ownership-" + id, AccountID: accountValue.ID, ClientKeyID: key.ID, Provider: account.ProviderWeb, ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-time.Hour)}); err != nil { + t.Fatal(err) + } + if err := responses.SaveWebState(ctx, inferencedomain.WebResponseState{ResponseID: "state-" + id, AccountID: accountValue.ID, ConversationID: "conversation", UpstreamParentResponseID: "parent", ResponseJSON: "{}", Status: "completed", ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-time.Hour)}); err != nil { + t.Fatal(err) + } + } + first, err := responses.DeleteExpired(ctx, now, 2, 1) + if err != nil || first.OwnershipDeleted != 2 || first.WebStateDeleted != 1 || !first.HasMore { + t.Fatalf("first bounded cleanup = %#v, err = %v", first, err) + } + second, err := responses.DeleteExpired(ctx, now, 10, 10) + if err != nil || second.OwnershipDeleted != 1 || second.WebStateDeleted != 2 || second.HasMore { + t.Fatalf("second bounded cleanup = %#v, err = %v", second, err) + } +} + +func verifyPostgresMediaJobInputConstraintUpgrade(t *testing.T, ctx context.Context, database *Database) { + t.Helper() + tx := database.db.WithContext(ctx).Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + if err := tx.Exec("ALTER TABLE media_jobs DROP CONSTRAINT IF EXISTS chk_media_jobs_input_json").Error; err != nil { + t.Fatal(err) + } + if err := tx.Exec("ALTER TABLE media_jobs ADD CONSTRAINT chk_media_jobs_input_json CHECK (length(input_json) <= 1048576) NOT VALID").Error; err != nil { + t.Fatal(err) + } + testDatabase := &Database{db: tx, dialect: "postgres"} + if err := testDatabase.ensureMediaJobInputConstraint(ctx); err != nil { + t.Fatal(err) + } + definition, err := testDatabase.constraintDefinition(ctx, consoleConstraint{model: &mediaJobModel{}, table: "media_jobs", name: "chk_media_jobs_input_json"}) + if err != nil || !strings.Contains(definition, strconv.Itoa(media.MaxInputJSONBytes)) || strings.Contains(definition, "1048576") { + t.Fatalf("postgres input constraint = %q, err=%v", definition, err) + } + if err := testDatabase.ensureMediaJobInputConstraint(ctx); err != nil { + t.Fatalf("postgres input constraint migration is not idempotent: %v", err) + } +} diff --git a/backend/internal/infra/persistence/relational/repository_test.go b/backend/internal/infra/persistence/relational/repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6f43e00ff3c9de9440424443bb73b67cbb3246b9 --- /dev/null +++ b/backend/internal/infra/persistence/relational/repository_test.go @@ -0,0 +1,570 @@ +package relational + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/admin" + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + testEncryptedToken = "encrypted-token" + testSecretHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) + +func TestSchemaAndRepositoryConstraints(t *testing.T) { + database := openTestDatabase(t) + adminRepo := NewAdminRepository(database) + if _, err := adminRepo.Create(context.Background(), admin.Admin{Username: "admin", PasswordHash: "hash"}); err != nil { + t.Fatal(err) + } + if _, err := adminRepo.Create(context.Background(), admin.Admin{Username: "admin", PasswordHash: "hash"}); !errors.Is(err, repository.ErrConflict) { + t.Fatalf("重复管理员错误 = %v", err) + } + + accountRepo := NewAccountRepository(database) + value := account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "first", UserID: "user-1", SourceKey: "source", EncryptedAccessToken: "encrypted-a", EncryptedCloudflareCookie: "encrypted-cf", ExpiresAt: time.Now().Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, Priority: 100, MaxConcurrent: 4} + created, wasCreated, err := accountRepo.UpsertByIdentity(context.Background(), value) + if err != nil || !wasCreated { + t.Fatalf("首次 upsert = %#v, %v, %v", created, wasCreated, err) + } + observedAt := time.Now().UTC() + if err := accountRepo.UpdateObservedModel(context.Background(), created.ID, "grok-observed", observedAt); err != nil { + t.Fatal(err) + } + if err := accountRepo.UpdateHealth(context.Background(), created.ID, 0, nil, "", true); err != nil { + t.Fatal(err) + } + value.Name = "updated" + value.EncryptedAccessToken = "encrypted-new" + value.EncryptedCloudflareCookie = "" + updated, wasCreated, err := accountRepo.UpsertByIdentity(context.Background(), value) + if err != nil || wasCreated || updated.ID != created.ID || updated.Name != "updated" || updated.LastUsedAt == nil || updated.ObservedModel != "grok-observed" || updated.ObservedModelAt == nil || updated.EncryptedCloudflareCookie != "encrypted-cf" { + t.Fatalf("幂等 upsert = %#v, %v, %v", updated, wasCreated, err) + } +} + +func TestAccountRepositoryAppliesRoutingDefaults(t *testing.T) { + database := openTestDatabase(t) + value, created, err := NewAccountRepository(database).UpsertByIdentity(context.Background(), account.Credential{ + Provider: account.ProviderBuild, Name: "defaults", SourceKey: "defaults", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil || !created { + t.Fatalf("created = %v, err = %v", created, err) + } + if value.Priority != account.DefaultPriority || value.MaxConcurrent != account.DefaultMaxConcurrent || value.MinimumRemaining != account.DefaultMinimumRemaining { + t.Fatalf("routing defaults = priority %d, concurrency %d, minimum %v", value.Priority, value.MaxConcurrent, value.MinimumRemaining) + } +} + +func TestAccountRepositoryUpsertsImportChunkInOneBatch(t *testing.T) { + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + values := []account.Credential{ + {Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "batch-1", SourceKey: "batch-1", EncryptedAccessToken: testEncryptedToken, EncryptedRefreshToken: "encrypted-refresh", AuthStatus: account.AuthStatusActive}, + {Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "batch-2", SourceKey: "batch-2", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}, + } + created, err := repo.UpsertManyByIdentity(ctx, values) + if err != nil { + t.Fatal(err) + } + if len(created) != 2 || !created[0].Created || !created[1].Created || created[0].ID == 0 || created[1].ID == 0 { + t.Fatalf("created = %#v", created) + } + buildIDs, err := repo.ListEnabledAccountIDs(ctx, account.ProviderBuild, true) + if err != nil { + t.Fatal(err) + } + if len(buildIDs) != 1 || buildIDs[0] != created[0].ID { + t.Fatalf("refreshable build ids = %#v", buildIDs) + } + stored, err := repo.Get(ctx, created[0].ID) + if err != nil { + t.Fatal(err) + } + stored.Priority = 99 + if _, err := repo.Update(ctx, stored); err != nil { + t.Fatal(err) + } + values[0].Name = "batch-1-updated" + updated, err := repo.UpsertManyByIdentity(ctx, values[:1]) + if err != nil { + t.Fatal(err) + } + if len(updated) != 1 || updated[0].Created || updated[0].ID != created[0].ID { + t.Fatalf("updated = %#v", updated) + } + stored, err = repo.Get(ctx, created[0].ID) + if err != nil { + t.Fatal(err) + } + if stored.Name != "batch-1-updated" || stored.Priority != 99 { + t.Fatalf("stored = %#v", stored) + } +} + +func TestAccountRepositoryUpsertsDuplicateIdentityWithinChunk(t *testing.T) { + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + values := []account.Credential{ + {Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "duplicate-first", SourceKey: "duplicate", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}, + {Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "duplicate-final", SourceKey: "duplicate", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}, + } + + results, err := repo.UpsertManyByIdentity(ctx, values) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 || !results[0].Created || results[1].Created || results[0].ID != results[1].ID { + t.Fatalf("results = %#v", results) + } + stored, err := repo.Get(ctx, results[0].ID) + if err != nil { + t.Fatal(err) + } + if stored.Name != "duplicate-final" { + t.Fatalf("stored name = %q", stored.Name) + } +} + +func TestAccountRepositoryLinksWebAndBuildAccountsOnce(t *testing.T) { + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + web, _, err := repo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "web", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + build, _, err := repo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build", SourceKey: "build", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatalf("idempotent link = %v", err) + } + linkedWeb, err := repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + linkedBuild, err := repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + if linkedWeb.LinkedAccountID != build.ID || linkedWeb.LinkedProvider != account.ProviderBuild || linkedWeb.LinkedAccountName != build.Name { + t.Fatalf("web link = %#v", linkedWeb) + } + if linkedBuild.LinkedAccountID != web.ID || linkedBuild.LinkedProvider != account.ProviderWeb || linkedBuild.LinkedAccountName != web.Name { + t.Fatalf("build link = %#v", linkedBuild) + } + unlinkedWeb, _, err := repo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web-2", SourceKey: "web-2", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + unlinkedIDs, total, err := repo.ListUnlinkedWebAccountIDs(ctx, 0, 1) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(unlinkedIDs) != 1 || unlinkedIDs[0] != unlinkedWeb.ID { + t.Fatalf("unlinked web ids = %#v, total = %d", unlinkedIDs, total) + } + nextUnlinkedIDs, nextTotal, err := repo.ListUnlinkedWebAccountIDs(ctx, unlinkedWeb.ID, 1) + if err != nil { + t.Fatal(err) + } + if nextTotal != 0 || len(nextUnlinkedIDs) != 0 { + t.Fatalf("next unlinked web ids = %#v, total = %d", nextUnlinkedIDs, nextTotal) + } + buildCandidates, err := repo.FilterMissingBuildConversionIDs(ctx, []uint64{web.ID, build.ID, unlinkedWeb.ID, 999_999}) + if err != nil { + t.Fatal(err) + } + if len(buildCandidates) != 3 || buildCandidates[0] != build.ID || buildCandidates[1] != unlinkedWeb.ID || buildCandidates[2] != 999_999 { + t.Fatalf("build conversion candidates = %#v", buildCandidates) + } + if _, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", SourceKey: "console-" + web.SourceKey, + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }); err != nil { + t.Fatal(err) + } + consoleCandidates, err := repo.ListMissingConsoleSyncAccounts(ctx, []uint64{web.ID, unlinkedWeb.ID}) + if err != nil { + t.Fatal(err) + } + if len(consoleCandidates) != 1 || consoleCandidates[0].ID != unlinkedWeb.ID { + t.Fatalf("console sync candidates = %#v", consoleCandidates) + } + if _, err := repo.ListMissingConsoleSyncAccounts(ctx, []uint64{build.ID}); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("non-web console candidate error = %v", err) + } + if _, err := repo.ListMissingConsoleSyncAccounts(ctx, []uint64{web.ID, 999_999}); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("missing console candidate error = %v", err) + } + consoleBatch, consoleTotal, consoleSkipped, err := repo.ListMissingConsoleSyncBatch(ctx, 0, 10) + if err != nil { + t.Fatal(err) + } + if consoleTotal != 1 || consoleSkipped != 1 || len(consoleBatch) != 1 || consoleBatch[0].ID != unlinkedWeb.ID { + t.Fatalf("console sync batch = %#v, total = %d, skipped = %d", consoleBatch, consoleTotal, consoleSkipped) + } + otherBuild, _, err := repo.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "build-2", SourceKey: "build-2", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + if err := repo.LinkWebToBuild(ctx, web.ID, otherBuild.ID); !errors.Is(err, repository.ErrConflict) { + t.Fatalf("duplicate web link = %v", err) + } +} + +func TestAccountRepositoryDecrementsQuotaByAmountAtomically(t *testing.T) { + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + value, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "quota", SourceKey: "quota-batch", + EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := repo.SaveQuotaWindows(ctx, value.ID, account.WebTierSuper, now, []account.QuotaWindow{{AccountID: value.ID, Mode: "fast", Remaining: 10, Total: 10, UpdatedAt: now}}); err != nil { + t.Fatal(err) + } + if updated, err := repo.DecrementQuotaWindowBy(ctx, value.ID, "fast", 4, now); err != nil || !updated { + t.Fatalf("first decrement updated=%v err=%v", updated, err) + } + if updated, err := repo.DecrementQuotaWindowBy(ctx, value.ID, "fast", 9, now); err != nil || !updated { + t.Fatalf("second decrement updated=%v err=%v", updated, err) + } + windows, err := repo.GetQuotaWindows(ctx, []uint64{value.ID}) + if err != nil { + t.Fatal(err) + } + if len(windows[value.ID]) != 1 || windows[value.ID][0].Remaining != 0 { + t.Fatalf("quota windows = %#v", windows[value.ID]) + } +} + +func TestAccountRepositorySummarizesOperationalStates(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + repo := NewAccountRepository(openTestDatabase(t)) + create := func(provider account.Provider, name string) account.Credential { + value, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: provider, Name: name, SourceKey: name, EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + return value + } + + create(account.ProviderBuild, "build-active") + cooldown := create(account.ProviderBuild, "build-cooldown") + cooldownUntil := now.Add(time.Hour) + if err := repo.UpdateHealth(ctx, cooldown.ID, 1, &cooldownUntil, "cooldown", false); err != nil { + t.Fatal(err) + } + disabled := create(account.ProviderBuild, "build-disabled") + disabled.Enabled = false + if _, err := repo.Update(ctx, disabled); err != nil { + t.Fatal(err) + } + + exhausted := create(account.ProviderWeb, "web-exhausted") + if err := repo.SaveQuotaWindows(ctx, exhausted.ID, account.WebTierSuper, now, []account.QuotaWindow{{AccountID: exhausted.ID, Mode: "fast", Remaining: 0, Total: 30, UpdatedAt: now}}); err != nil { + t.Fatal(err) + } + reauth := create(account.ProviderWeb, "web-reauth") + reauth.AuthStatus = account.AuthStatusReauthRequired + if _, err := repo.Update(ctx, reauth); err != nil { + t.Fatal(err) + } + + rows, err := repo.Summarize(ctx, now) + if err != nil { + t.Fatal(err) + } + byProvider := make(map[string]repository.AccountSummary, len(rows)) + for _, row := range rows { + byProvider[row.Provider] = row + } + build := byProvider[string(account.ProviderBuild)] + if build.Total != 3 || build.Available != 1 || build.Cooldown != 1 || build.Disabled != 1 { + t.Fatalf("build summary = %#v", build) + } + web := byProvider[string(account.ProviderWeb)] + if web.Total != 2 || web.Available != 0 || web.WaitingReset != 1 || web.ReauthRequired != 1 { + t.Fatalf("web summary = %#v", web) + } +} + +func TestAccountIdentityPrefersStableOAuthIdentity(t *testing.T) { + first := accountIdentity(account.Credential{Provider: account.ProviderBuild, UserID: "user-1", TeamID: "team-1", SourceKey: "device:old-token"}) + second := accountIdentity(account.Credential{Provider: account.ProviderBuild, UserID: "user-1", TeamID: "team-1", SourceKey: "device:new-token"}) + if first != second { + t.Fatal("stable OAuth identity changed after token rotation") + } + fallbackFirst := accountIdentity(account.Credential{Provider: account.ProviderBuild, SourceKey: "source-1"}) + fallbackSecond := accountIdentity(account.Credential{Provider: account.ProviderBuild, SourceKey: "source-2"}) + if fallbackFirst == fallbackSecond { + t.Fatal("source fallback did not distinguish accounts without stable claims") + } +} + +func TestAccountRepositoryPersistsObservedBuildBillingFields(t *testing.T) { + database := openTestDatabase(t) + repo := NewAccountRepository(database) + credential, _, err := repo.UpsertByIdentity(context.Background(), account.Credential{Provider: account.ProviderBuild, Name: "billing", SourceKey: "billing", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + onDemandEnabled := false + if err := repo.UpdateObservedModel(context.Background(), credential.ID, "grok-4.5-build-free", now); err != nil { + t.Fatal(err) + } + if err := repo.UpdateObservedModel(context.Background(), credential.ID, "grok-4.5-build-free", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if err := repo.SaveBilling(context.Background(), account.Billing{AccountID: credential.ID, IsUnifiedBillingUser: true, OnDemandEnabled: &onDemandEnabled, TopUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD", UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", UsagePeriodStart: "2026-07-12T00:00:00Z", UsagePeriodEnd: "2026-07-19T00:00:00Z", History: []account.BillingHistoryEntry{{Year: 2026, Month: 6}}, SyncedAt: now}); err != nil { + t.Fatal(err) + } + storedCredential, err := repo.Get(context.Background(), credential.ID) + if err != nil || storedCredential.ObservedModel != "grok-4.5-build-free" || storedCredential.ObservedModelAt == nil { + t.Fatalf("credential = %#v, err = %v", storedCredential, err) + } + if !storedCredential.ObservedModelAt.Equal(now) { + t.Fatalf("unchanged observed model refreshed early at %v, want %v", storedCredential.ObservedModelAt, now) + } + billing, err := repo.GetBilling(context.Background(), credential.ID) + if err != nil || !billing.IsUnifiedBillingUser || billing.OnDemandEnabled == nil || *billing.OnDemandEnabled || billing.TopUpMethod != "TOP_UP_METHOD_SAVED_PAYMENT_METHOD" || billing.UsagePeriodType != "USAGE_PERIOD_TYPE_WEEKLY" || billing.UsagePeriodEnd != "2026-07-19T00:00:00Z" || len(billing.History) != 1 { + t.Fatalf("billing = %#v, err = %v", billing, err) + } +} + +func TestAccountRepositoryRejectsStaleObservedModelWrite(t *testing.T) { + database := openTestDatabase(t) + repo := NewAccountRepository(database) + credential, _, err := repo.UpsertByIdentity(context.Background(), account.Credential{Provider: account.ProviderBuild, Name: "observed-order", SourceKey: "observed-order", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + older := time.Now().UTC() + newer := older.Add(time.Minute) + if updated, err := repo.UpdateObservedModelIfNewer(context.Background(), credential.ID, "grok-newer", newer); err != nil || !updated { + t.Fatalf("newer observed model update = %v, err = %v", updated, err) + } + if updated, err := repo.UpdateObservedModelIfNewer(context.Background(), credential.ID, "grok-older", older); err != nil || updated { + t.Fatalf("stale observed model update = %v, err = %v", updated, err) + } + stored, err := repo.Get(context.Background(), credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.ObservedModel != "grok-newer" || stored.ObservedModelAt == nil || !stored.ObservedModelAt.Equal(newer) { + t.Fatalf("observed model after stale write = %q at %v", stored.ObservedModel, stored.ObservedModelAt) + } +} + +func TestForeignKeysCascadeRuntimeStateButPreserveAuditHistory(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + models := NewModelRepository(database) + keys := NewClientKeyRepository(database) + responses := NewResponseRepository(database) + audits := NewAuditRepository(database) + + accountValue, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "cascade", SourceKey: "cascade", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + if err := models.UpsertDiscovered(ctx, account.ProviderBuild, []string{"grok-cascade"}); err != nil { + t.Fatal(err) + } + if err := models.ReplaceAccountCapabilities(ctx, accountValue.ID, []string{"grok-cascade"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + route, err := models.GetByPublicID(ctx, "grok-cascade") + if err != nil { + t.Fatal(err) + } + key, err := keys.Create(ctx, clientkeydomain.Key{Name: "cascade", Prefix: "cascade-prefix", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, AllowedModels: []uint64{route.ID}}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accounts.SaveBilling(ctx, account.Billing{AccountID: accountValue.ID, SyncedAt: now}); err != nil { + t.Fatal(err) + } + if err := accounts.SaveQuotaRecovery(ctx, account.QuotaRecovery{AccountID: accountValue.ID, Kind: account.QuotaRecoveryKindFree, Status: account.QuotaRecoveryStatusExhausted, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := responses.Save(ctx, inferencedomain.ResponseOwnership{ResponseID: "resp-account", AccountID: accountValue.ID, ClientKeyID: key.ID, Provider: account.ProviderBuild, ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := audits.Create(ctx, auditdomain.Record{RequestID: "audit-history", ClientKeyID: key.ID, ClientKeyName: key.Name, ModelRouteID: route.ID, ModelPublicID: route.PublicID, AccountID: &accountValue.ID, AccountName: accountValue.Name, StatusCode: 200, CreatedAt: now}); err != nil { + t.Fatal(err) + } + + if err := accounts.Delete(ctx, accountValue.ID); err != nil { + t.Fatal(err) + } + for _, table := range []string{"account_billing_snapshots", "account_quota_recovery", "account_model_capabilities", "account_model_sync_states", "response_ownership"} { + if count := tableRowCount(t, database, table); count != 0 { + t.Fatalf("%s rows after account delete = %d", table, count) + } + } + if count := tableRowCount(t, database, "request_audits"); count != 1 { + t.Fatalf("audit rows after account delete = %d", count) + } + + secondAccount, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "second", SourceKey: "second-cascade", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + if err := responses.Save(ctx, inferencedomain.ResponseOwnership{ResponseID: "resp-key", AccountID: secondAccount.ID, ClientKeyID: key.ID, Provider: account.ProviderBuild, ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := keys.Delete(ctx, key.ID); err != nil { + t.Fatal(err) + } + if count := tableRowCount(t, database, "client_key_models"); count != 0 { + t.Fatalf("permission rows after key delete = %d", count) + } + if count := tableRowCount(t, database, "response_ownership"); count != 0 { + t.Fatalf("response ownership rows after key delete = %d", count) + } +} + +func TestFreshSchemaContract(t *testing.T) { + database := openTestDatabase(t) + if err := database.InitializeSchema(context.Background()); err != nil { + t.Fatalf("repeated schema initialization: %v", err) + } + for _, model := range schemaModels { + if !database.db.Migrator().HasTable(model) { + t.Fatalf("missing table for %T", model) + } + } + assertTableColumns(t, database, "provider_accounts", []string{"provider", "source_key", "auth_status", "build_api_fallback", "build_route_mode", "build_super_entitled"}, []string{"oidc_client_id", "expires_at", "encrypted_access_token", "encrypted_refresh_token"}) + assertTableColumns(t, database, "account_credentials", []string{"account_id", "auth_type", "client_id", "encrypted_primary", "encrypted_refresh", "expires_at", "refresh_due_at", "last_refresh_at", "refresh_failures", "last_refresh_error_status", "last_refresh_error", "last_refresh_error_message", "last_refresh_error_response", "refresh_permanent"}, nil) + assertTableColumns(t, database, "web_account_profiles", []string{"account_id", "tier", "synced_at", "nsfw_enabled_at"}, nil) + assertTableColumns(t, database, "admin_sessions", nil, []string{"revoked_at"}) + assertTableColumns(t, database, "account_model_capabilities", []string{"account_id", "upstream_model"}, []string{"provider", "synced_at"}) + assertTableColumns(t, database, "request_audits", []string{"media_input_images", "media_output_images", "media_output_seconds", "first_token_ms"}, nil) + assertTableColumns(t, database, "response_ownership", []string{"response_id", "account_id", "client_key_id", "provider", "prompt_cache_key", "reasoning_replay_key", "expires_at"}, []string{"parent_response_id", "model_route_id"}) + var requestAuditSQL string + if err := database.db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'request_audits'").Scan(&requestAuditSQL).Error; err != nil { + t.Fatal(err) + } + if strings.Contains(requestAuditSQL, "chk_request_audits_first_token_ms") { + t.Fatalf("first-token observability metadata must not require a table-level CHECK: %s", requestAuditSQL) + } + + var expiresNotNull int + if err := database.db.Raw("SELECT `notnull` FROM pragma_table_info('account_credentials') WHERE name = 'expires_at'").Scan(&expiresNotNull).Error; err != nil { + t.Fatal(err) + } + if expiresNotNull != 0 { + t.Fatal("account credential expires_at must be nullable when the upstream expiry is unknown") + } +} + +func TestSchemaRejectsInvalidPersistentValues(t *testing.T) { + database := openTestDatabase(t) + ctx := context.Background() + if err := database.db.WithContext(ctx).Create(&clientKeyModel{Name: "invalid", Prefix: "invalid", SecretHash: "short", EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8}).Error; err == nil { + t.Fatal("invalid client key hash was accepted") + } + if err := database.db.WithContext(ctx).Create(&requestAuditModel{RequestID: "negative", ClientKeyID: 1, ModelRouteID: 1, Provider: "grok_build", Operation: "responses", UsageSource: "upstream", StatusCode: 200, TotalTokens: -1, CreatedAt: time.Now().UTC()}).Error; err == nil { + t.Fatal("negative audit token count was accepted") + } + accountRow := accountModel{IdentityKey: testIdentityKey("no-token"), Provider: "grok_build", Name: "no-token", SourceKey: "no-token", AuthStatus: "active", MaxConcurrent: 8} + if err := database.db.WithContext(ctx).Create(&accountRow).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&accountCredentialModel{AccountID: accountRow.ID, AuthType: "oauth", UpdatedAt: time.Now().UTC()}).Error; err == nil { + t.Fatal("account credential without a primary or refresh secret was accepted") + } +} + +func TestSchemaDoesNotStoreForbiddenBrowserIdentityFields(t *testing.T) { + database := openTestDatabase(t) + var rows []struct { + TableName string + ColumnName string + } + query := `SELECT m.name AS table_name, p.name AS column_name FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table'` + if err := database.db.Raw(query).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + forbidden := []string{"grok_device_id", "x-anonuserid", "x-userid", "x-challenge", "x-signature"} + for _, row := range rows { + for _, value := range forbidden { + if strings.EqualFold(row.ColumnName, value) { + t.Fatalf("forbidden field %s exists in table %s", value, row.TableName) + } + } + } +} + +func assertTableColumns(t *testing.T, database *Database, table string, required, forbidden []string) { + t.Helper() + var rows []struct{ Name string } + if err := database.db.Raw("SELECT name FROM pragma_table_info(?)", table).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + columns := make(map[string]bool, len(rows)) + for _, row := range rows { + columns[row.Name] = true + } + for _, name := range required { + if !columns[name] { + t.Fatalf("%s missing column %s", table, name) + } + } + for _, name := range forbidden { + if columns[name] { + t.Fatalf("%s contains redundant column %s", table, name) + } + } +} + +func tableRowCount(t *testing.T, database *Database, table string) int64 { + t.Helper() + var count int64 + if err := database.db.Table(table).Count(&count).Error; err != nil { + t.Fatal(err) + } + return count +} + +func testIdentityKey(source string) string { + return accountIdentity(account.Credential{Provider: account.ProviderBuild, SourceKey: source}) +} + +func openTestDatabase(t *testing.T) *Database { + t.Helper() + database, err := OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} diff --git a/backend/internal/infra/persistence/relational/response_repository.go b/backend/internal/infra/persistence/relational/response_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..5d796c15147721634abe01ac4da84458f27e543f --- /dev/null +++ b/backend/internal/infra/persistence/relational/response_repository.go @@ -0,0 +1,132 @@ +package relational + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +type ResponseRepository struct{ db *Database } + +func NewResponseRepository(db *Database) *ResponseRepository { return &ResponseRepository{db: db} } + +func (r *ResponseRepository) Save(ctx context.Context, value inferencedomain.ResponseOwnership) error { + row := responseOwnershipModel{ + ResponseID: value.ResponseID, AccountID: value.AccountID, + ClientKeyID: value.ClientKeyID, Provider: string(value.Provider), + PromptCacheKey: value.PromptCacheKey, ReasoningReplayKey: value.ReasoningReplayKey, + ExpiresAt: value.ExpiresAt, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } + return r.db.db.WithContext(ctx).Save(&row).Error +} + +func (r *ResponseRepository) Get(ctx context.Context, responseID string, clientKeyID uint64, now time.Time) (inferencedomain.ResponseOwnership, error) { + var row responseOwnershipModel + if err := r.db.db.WithContext(ctx).Where("response_id = ? AND client_key_id = ? AND expires_at > ?", responseID, clientKeyID, now).First(&row).Error; err != nil { + return inferencedomain.ResponseOwnership{}, mapError(err) + } + return inferencedomain.ResponseOwnership{ + ResponseID: row.ResponseID, AccountID: row.AccountID, + ClientKeyID: row.ClientKeyID, Provider: account.Provider(row.Provider), + PromptCacheKey: row.PromptCacheKey, ReasoningReplayKey: row.ReasoningReplayKey, + ExpiresAt: row.ExpiresAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + }, nil +} + +func (r *ResponseRepository) Delete(ctx context.Context, responseID string, clientKeyID uint64) error { + result := r.db.db.WithContext(ctx).Where("response_id = ? AND client_key_id = ?", responseID, clientKeyID).Delete(&responseOwnershipModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func (r *ResponseRepository) DeleteExpired(ctx context.Context, now time.Time, ownershipLimit, webStateLimit int) (repository.ResponseCleanupResult, error) { + result := repository.ResponseCleanupResult{} + if ownershipLimit > 0 { + deleted, err := r.deleteExpiredOwnership(ctx, now, ownershipLimit) + if err != nil { + return result, err + } + result.OwnershipDeleted = deleted + result.HasMore = result.HasMore || deleted >= int64(ownershipLimit) + } + if webStateLimit > 0 { + deleted, err := r.deleteExpiredWebState(ctx, now, webStateLimit) + if err != nil { + return result, err + } + result.WebStateDeleted = deleted + result.HasMore = result.HasMore || deleted >= int64(webStateLimit) + } + return result, nil +} + +func (r *ResponseRepository) deleteExpiredOwnership(ctx context.Context, now time.Time, limit int) (int64, error) { + var deleted int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ids := tx.Model(&responseOwnershipModel{}). + Select("response_id"). + Where("expires_at <= ?", now). + Order("expires_at ASC, response_id ASC"). + Limit(limit) + result := tx.Where("response_id IN (?)", ids).Delete(&responseOwnershipModel{}) + deleted = result.RowsAffected + return result.Error + }) + return deleted, err +} + +func (r *ResponseRepository) deleteExpiredWebState(ctx context.Context, now time.Time, limit int) (int64, error) { + var deleted int64 + err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ids := tx.Model(&webResponseStateModel{}). + Select("response_id"). + Where("expires_at <= ?", now). + Order("expires_at ASC, response_id ASC"). + Limit(limit) + result := tx.Where("response_id IN (?)", ids).Delete(&webResponseStateModel{}) + deleted = result.RowsAffected + return result.Error + }) + return deleted, err +} + +func (r *ResponseRepository) SaveWebState(ctx context.Context, value inferencedomain.WebResponseState) error { + row := webResponseStateModel{ + ResponseID: value.ResponseID, AccountID: value.AccountID, ConversationID: value.ConversationID, + UpstreamParentResponseID: value.UpstreamParentResponseID, ResponseJSON: value.ResponseJSON, + Status: value.Status, ExpiresAt: value.ExpiresAt, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } + return r.db.db.WithContext(ctx).Save(&row).Error +} + +func (r *ResponseRepository) GetWebState(ctx context.Context, responseID string, now time.Time) (inferencedomain.WebResponseState, error) { + var row webResponseStateModel + if err := r.db.db.WithContext(ctx).Where("response_id = ? AND expires_at > ?", responseID, now).First(&row).Error; err != nil { + return inferencedomain.WebResponseState{}, mapError(err) + } + return inferencedomain.WebResponseState{ + ResponseID: row.ResponseID, AccountID: row.AccountID, ConversationID: row.ConversationID, + UpstreamParentResponseID: row.UpstreamParentResponseID, ResponseJSON: row.ResponseJSON, + Status: row.Status, ExpiresAt: row.ExpiresAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + }, nil +} + +func (r *ResponseRepository) DeleteWebState(ctx context.Context, responseID string) error { + result := r.db.db.WithContext(ctx).Where("response_id = ?", responseID).Delete(&webResponseStateModel{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} diff --git a/backend/internal/infra/persistence/relational/response_repository_test.go b/backend/internal/infra/persistence/relational/response_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ca00ca8f8372deed00f790cf74dcef22b501e55f --- /dev/null +++ b/backend/internal/infra/persistence/relational/response_repository_test.go @@ -0,0 +1,171 @@ +package relational + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestResponseRepositoryScopesOwnershipByClientAndExpiry(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "responses.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "owner", SourceKey: "owner", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + keyValue, err := NewClientKeyRepository(database).Create(ctx, clientkeydomain.Key{Name: "owner", Prefix: "owner-prefix", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8}) + if err != nil { + t.Fatal(err) + } + repo := NewResponseRepository(database) + value := inferencedomain.ResponseOwnership{ResponseID: "resp_1", AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderBuild, PromptCacheKey: "cache-key", ReasoningReplayKey: "replay-key", ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now} + if err := repo.Save(ctx, value); err != nil { + t.Fatal(err) + } + got, err := repo.Get(ctx, value.ResponseID, value.ClientKeyID, now) + if err != nil || got.AccountID != value.AccountID || got.Provider != account.ProviderBuild || got.PromptCacheKey != value.PromptCacheKey || got.ReasoningReplayKey != value.ReasoningReplayKey { + t.Fatalf("ownership = %#v, err = %v", got, err) + } + if _, err := repo.Get(ctx, value.ResponseID, 99, now); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("cross-client lookup err = %v", err) + } + if _, err := repo.Get(ctx, value.ResponseID, value.ClientKeyID, now.Add(2*time.Hour)); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("expired lookup err = %v", err) + } + deleted, err := repo.DeleteExpired(ctx, now.Add(2*time.Hour), 10, 10) + if err != nil || deleted.OwnershipDeleted != 1 || deleted.WebStateDeleted != 0 { + t.Fatalf("deleted = %#v, err = %v", deleted, err) + } +} + +func TestResponseRepositoryDeletesExpiredRowsInBoundedIndependentBatches(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "response-cleanup.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + accounts := NewAccountRepository(database) + accountValue, _, err := accounts.UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderWeb, Name: "cleanup-owner", SourceKey: "cleanup-owner", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + keyValue, err := NewClientKeyRepository(database).Create(ctx, clientkeydomain.Key{Name: "cleanup-key", Prefix: "cleanup-prefix", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true}) + if err != nil { + t.Fatal(err) + } + repo := NewResponseRepository(database) + for index := range 3 { + responseID := "expired-ownership-" + string(rune('a'+index)) + if err := repo.Save(ctx, inferencedomain.ResponseOwnership{ResponseID: responseID, AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderWeb, ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-time.Hour)}); err != nil { + t.Fatal(err) + } + if err := repo.SaveWebState(ctx, inferencedomain.WebResponseState{ResponseID: "expired-state-" + string(rune('a'+index)), AccountID: accountValue.ID, ConversationID: "conversation", UpstreamParentResponseID: "parent", ResponseJSON: "{}", Status: "completed", ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-time.Hour)}); err != nil { + t.Fatal(err) + } + } + if err := repo.Save(ctx, inferencedomain.ResponseOwnership{ResponseID: "active-ownership", AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderWeb, ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := repo.SaveWebState(ctx, inferencedomain.WebResponseState{ResponseID: "active-state", AccountID: accountValue.ID, ConversationID: "conversation", UpstreamParentResponseID: "parent", ResponseJSON: "{}", Status: "completed", ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + + first, err := repo.DeleteExpired(ctx, now, 2, 1) + if err != nil || first.OwnershipDeleted != 2 || first.WebStateDeleted != 1 || !first.HasMore { + t.Fatalf("first cleanup = %#v, err = %v", first, err) + } + second, err := repo.DeleteExpired(ctx, now, 10, 10) + if err != nil || second.OwnershipDeleted != 1 || second.WebStateDeleted != 2 || second.HasMore { + t.Fatalf("second cleanup = %#v, err = %v", second, err) + } + if _, err := repo.Get(ctx, "active-ownership", keyValue.ID, now); err != nil { + t.Fatalf("active ownership was removed: %v", err) + } + if _, err := repo.GetWebState(ctx, "active-state", now); err != nil { + t.Fatalf("active web state was removed: %v", err) + } +} + +func TestResponseOwnershipIdentityMigrationPreservesExistingRows(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "responses-upgrade.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "legacy-owner", SourceKey: "legacy-owner", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + keyValue, err := NewClientKeyRepository(database).Create(ctx, clientkeydomain.Key{Name: "legacy-owner", Prefix: "legacy-prefix", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8}) + if err != nil { + t.Fatal(err) + } + repo := NewResponseRepository(database) + value := inferencedomain.ResponseOwnership{ResponseID: "resp_legacy", AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderBuild, PromptCacheKey: "old-cache", ReasoningReplayKey: "old-replay", ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now} + if err := repo.Save(ctx, value); err != nil { + t.Fatal(err) + } + if err := database.withSQLiteForeignKeysDisabled(ctx, func() error { + statements := []string{ + `CREATE TABLE response_ownership_legacy ( + response_id text PRIMARY KEY, + account_id integer NOT NULL, + client_key_id integer NOT NULL, + provider text NOT NULL, + expires_at datetime NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + CONSTRAINT fk_response_ownership_account FOREIGN KEY (account_id) REFERENCES provider_accounts(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_response_ownership_client_key FOREIGN KEY (client_key_id) REFERENCES client_keys(id) ON UPDATE CASCADE ON DELETE CASCADE + )`, + `INSERT INTO response_ownership_legacy (response_id, account_id, client_key_id, provider, expires_at, created_at, updated_at) + SELECT response_id, account_id, client_key_id, provider, expires_at, created_at, updated_at FROM response_ownership`, + `DROP TABLE response_ownership`, + `ALTER TABLE response_ownership_legacy RENAME TO response_ownership`, + } + for _, statement := range statements { + if err := database.db.WithContext(ctx).Exec(statement).Error; err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatal(err) + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("upgrade response ownership identity columns: %v", err) + } + got, err := repo.Get(ctx, value.ResponseID, value.ClientKeyID, now) + if err != nil || got.AccountID != value.AccountID || got.Provider != value.Provider || got.PromptCacheKey != "" || got.ReasoningReplayKey != "" { + t.Fatalf("migrated ownership = %#v, err = %v", got, err) + } +} diff --git a/backend/internal/infra/persistence/relational/routing_projection_benchmark_test.go b/backend/internal/infra/persistence/relational/routing_projection_benchmark_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dd570dbd253f50f787bf80ae6a79d295b5f1645d --- /dev/null +++ b/backend/internal/infra/persistence/relational/routing_projection_benchmark_test.go @@ -0,0 +1,111 @@ +package relational + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func BenchmarkRoutingAccountBaseProjectionWithLargePayloads(b *testing.B) { + const accountCount = 300 + b.StopTimer() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(b.TempDir(), "routing-payload-benchmark.db")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + b.Fatal(err) + } + repository := NewAccountRepository(database) + secret := strings.Repeat("s", 32<<10) + credentials := make([]account.Credential, accountCount) + for index := range credentials { + credentials[index] = account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + Name: fmt.Sprintf("payload-%04d", index), SourceKey: fmt.Sprintf("payload-source-%04d", index), + EncryptedAccessToken: secret, EncryptedCloudflareCookie: secret, + Enabled: true, AuthStatus: account.AuthStatusActive, + } + } + created, err := repository.UpsertManyByIdentity(ctx, credentials) + if err != nil { + b.Fatal(err) + } + now := time.Now().UTC() + history := make([]account.BillingHistoryEntry, 256) + period := strings.Repeat("p", 64) + for index := range history { + history[index] = account.BillingHistoryEntry{Year: 2026, Month: index%12 + 1, PeriodType: period, PeriodStart: period, PeriodEnd: period} + } + breakdown := make([]account.QuotaBreakdown, 128) + for index := range breakdown { + breakdown[index] = account.QuotaBreakdown{ProductCode: index % 7, UsagePercent: 50} + } + resetAt := now.Add(time.Hour) + for _, credential := range created { + if err := repository.SaveBilling(ctx, account.Billing{AccountID: credential.ID, PlanName: "SuperGrok", MonthlyLimit: 100, History: history, SyncedAt: now}); err != nil { + b.Fatal(err) + } + if err := repository.SaveQuotaWindows(ctx, credential.ID, account.WebTierSuper, now, []account.QuotaWindow{{ + AccountID: credential.ID, Mode: "weekly", Remaining: 10, Total: 20, + Breakdown: breakdown, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }}); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(accountCount, "accounts") + b.ReportAllocs() + b.ResetTimer() + b.StartTimer() + for b.Loop() { + values, loadErr := repository.ListRoutingAccountBases(ctx, account.ProviderWeb, "") + if loadErr != nil { + b.Fatal(loadErr) + } + if len(values) != accountCount { + b.Fatalf("routing bases = %d, want %d", len(values), accountCount) + } + } +} + +func BenchmarkSelectedCredentialHydration(b *testing.B) { + b.StopTimer() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(b.TempDir(), "credential-hydration-benchmark.db")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + b.Fatal(err) + } + repository := NewAccountRepository(database) + created, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, + Name: "selected", SourceKey: "selected", OIDCClientID: "client-id", + EncryptedAccessToken: strings.Repeat("a", 32<<10), EncryptedRefreshToken: strings.Repeat("r", 32<<10), + Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + b.StartTimer() + for b.Loop() { + material, loadErr := repository.GetCredentialMaterial(ctx, created.ID, account.ProviderBuild) + if loadErr != nil { + b.Fatal(loadErr) + } + if len(material.EncryptedAccessToken) != 32<<10 { + b.Fatalf("access token bytes = %d", len(material.EncryptedAccessToken)) + } + } +} diff --git a/backend/internal/infra/persistence/relational/routing_projection_test.go b/backend/internal/infra/persistence/relational/routing_projection_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3d90cdd764c78f47dd8eaf986866401f9d7ba5fc --- /dev/null +++ b/backend/internal/infra/persistence/relational/routing_projection_test.go @@ -0,0 +1,167 @@ +package relational + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestRoutingProjectionLeavesSecretsAndLargeJSONOutOfCandidateLoad(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "routing-projection.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := NewAccountRepository(database) + now := time.Now().UTC().Truncate(time.Second) + created, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "routing projection", Email: "route@example.test", + SourceKey: "routing:projection", OIDCClientID: "client-id", EncryptedAccessToken: "access-secret", + EncryptedCloudflareCookie: "cookie-secret", ExpiresAt: now.Add(time.Hour), Enabled: true, + AuthStatus: account.AuthStatusActive, Priority: 9, MaxConcurrent: 3, MinimumRemaining: 1.5, + WebTier: account.WebTierSuper, + }) + if err != nil { + t.Fatal(err) + } + if err := accounts.SaveBilling(ctx, account.Billing{ + AccountID: created.ID, PlanCode: "super", MonthlyLimit: 100, Used: 12, SyncedAt: now, + History: []account.BillingHistoryEntry{{Year: 2026, Month: 7, IncludedUsed: 12}}, + }); err != nil { + t.Fatal(err) + } + resetAt := now.Add(time.Hour) + if err := accounts.SaveQuotaWindows(ctx, created.ID, account.WebTierSuper, now, []account.QuotaWindow{{ + AccountID: created.ID, Mode: "weekly", Remaining: 10, Total: 20, UsagePercent: 50, + Breakdown: []account.QuotaBreakdown{{ProductCode: account.QuotaProductChat, UsagePercent: 50}}, + WindowSeconds: 3600, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }}); err != nil { + t.Fatal(err) + } + + bases, err := accounts.ListRoutingAccountBases(ctx, account.ProviderWeb, "") + if err != nil { + t.Fatal(err) + } + if len(bases) != 1 { + t.Fatalf("routing bases = %d, want 1", len(bases)) + } + assertRoutingProjection(t, bases[0].Credential, created) + if bases[0].Billing == nil || bases[0].Billing.PlanCode != "super" || len(bases[0].Billing.History) != 0 { + t.Fatalf("routing billing = %#v, want scalar billing without history", bases[0].Billing) + } + if bases[0].QuotaWindow == nil || bases[0].QuotaWindow.Remaining != 10 || len(bases[0].QuotaWindow.Breakdown) != 0 { + t.Fatalf("routing quota = %#v, want scalar quota without breakdown", bases[0].QuotaWindow) + } + + candidates, err := accounts.ListRoutingCandidates(ctx, account.ProviderWeb, 0, "grok-test", "") + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 { + t.Fatalf("routing candidates = %d, want 1", len(candidates)) + } + assertRoutingProjection(t, candidates[0].Credential, created) + if candidates[0].Billing == nil || len(candidates[0].Billing.History) != 0 || candidates[0].QuotaWindow == nil || len(candidates[0].QuotaWindow.Breakdown) != 0 { + t.Fatalf("routing candidate loaded large payloads: %#v", candidates[0]) + } + + // Management reads retain their complete representations. + managed, err := accounts.ListEnabled(ctx, account.ProviderWeb) + if err != nil { + t.Fatal(err) + } + if len(managed) != 1 || managed[0].EncryptedAccessToken != "access-secret" || managed[0].EncryptedCloudflareCookie != "cookie-secret" { + t.Fatalf("management credentials = %#v", managed) + } + billing, err := accounts.GetBilling(ctx, created.ID) + if err != nil { + t.Fatal(err) + } + if len(billing.History) != 1 { + t.Fatalf("management billing history = %#v, want one entry", billing.History) + } + windows, err := accounts.GetQuotaWindows(ctx, []uint64{created.ID}) + if err != nil { + t.Fatal(err) + } + if len(windows[created.ID]) != 1 || len(windows[created.ID][0].Breakdown) != 1 { + t.Fatalf("management quota windows = %#v, want breakdown", windows) + } +} + +func TestGetCredentialMaterialHydratesOneAccountAndMapsNotFound(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "credential-material.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := NewAccountRepository(database) + now := time.Now().UTC().Truncate(time.Second) + created, _, err := accounts.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "hydrate", SourceKey: "credential:material", + OIDCClientID: "client-id", EncryptedAccessToken: "access-secret", EncryptedRefreshToken: "refresh-secret", + EncryptedCloudflareCookie: "cookie-secret", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + + material, err := accounts.GetCredentialMaterial(ctx, created.ID, account.ProviderBuild) + if err != nil { + t.Fatal(err) + } + if material.AccountID != created.ID || material.Provider != account.ProviderBuild || material.AuthType != account.AuthTypeOAuth || material.OIDCClientID != "client-id" || + material.EncryptedAccessToken != "access-secret" || material.EncryptedRefreshToken != "refresh-secret" || material.EncryptedCloudflareCookie != "cookie-secret" || + !material.ExpiresAt.Equal(now.Add(time.Hour)) { + t.Fatalf("credential material = %#v", material) + } + hydrated, ok := material.ApplyTo(account.Credential{ID: created.ID, Provider: account.ProviderBuild}) + if !ok || hydrated.EncryptedAccessToken != "access-secret" || hydrated.EncryptedRefreshToken != "refresh-secret" { + t.Fatalf("hydrated credential = %#v, ok=%v", hydrated, ok) + } + if _, ok := material.ApplyTo(account.Credential{ID: created.ID + 1}); ok { + t.Fatal("credential material applied to a different account") + } + if _, ok := material.ApplyTo(account.Credential{ID: created.ID, Provider: account.ProviderWeb}); ok { + t.Fatal("credential material applied to a different provider") + } + if _, err := accounts.GetCredentialMaterial(ctx, created.ID+1, account.ProviderBuild); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("missing credential material error = %v, want ErrNotFound", err) + } + if _, err := accounts.GetCredentialMaterial(ctx, created.ID, account.ProviderWeb); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("cross-provider credential material error = %v, want ErrNotFound", err) + } + disabled := false + if _, err := accounts.UpdateMany(ctx, account.ProviderBuild, []uint64{created.ID}, repository.AccountUpdates{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + if _, err := accounts.GetCredentialMaterial(ctx, created.ID, account.ProviderBuild); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("disabled credential material error = %v, want ErrNotFound", err) + } +} + +func assertRoutingProjection(t *testing.T, value, created account.Credential) { + t.Helper() + if value.ID != created.ID || value.Provider != created.Provider || value.Name != created.Name || value.SourceKey != created.SourceKey || + value.Priority != created.Priority || value.MaxConcurrent != created.MaxConcurrent || value.MinimumRemaining != created.MinimumRemaining || value.WebTier != account.WebTierSuper || + value.AuthType != created.AuthType || value.OIDCClientID != created.OIDCClientID || !value.ExpiresAt.Equal(created.ExpiresAt) { + t.Fatalf("routing metadata = %#v", value) + } + if value.EncryptedAccessToken != "" || value.EncryptedRefreshToken != "" || value.EncryptedCloudflareCookie != "" { + t.Fatalf("routing projection includes credential material: %#v", value) + } +} diff --git a/backend/internal/infra/persistence/relational/schema.go b/backend/internal/infra/persistence/relational/schema.go new file mode 100644 index 0000000000000000000000000000000000000000..38b07903686955367afda553f1cd05c18a1bc6ac --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema.go @@ -0,0 +1,605 @@ +package relational + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/domain/media" + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "gorm.io/gorm" +) + +const mediaJobInputMetadataPendingIndex = "CREATE INDEX IF NOT EXISTS idx_media_jobs_input_metadata_pending ON media_jobs(id) WHERE input_image_count IS NULL" + +const postgresSchemaMigrationLockID int64 = 0x47524f4b32415049 + +var schemaModels = []any{ + &adminModel{}, + &adminSessionModel{}, + &egressSubscriptionSourceModel{}, + &egressNodeModel{}, + &egressOperationsConfigModel{}, + &accountModel{}, + &accountCredentialModel{}, + &accountProviderLinkModel{}, + &webConsoleAccountLinkModel{}, + &webAccountProfileModel{}, + "aWindowModel{}, + &billingModel{}, + "aRecoveryModel{}, + &modelRouteModel{}, + &modelRouteAliasModel{}, + &modelRouteAccountModel{}, + &accountModelCapabilityModel{}, + &accountModelSyncStateModel{}, + &accountModelQuotaBlockModel{}, + &clientKeyModel{}, + &clientKeyModelPermission{}, + &billingReservationModel{}, + &requestAuditModel{}, + &requestAuditAttemptModel{}, + &responseOwnershipModel{}, + &webResponseStateModel{}, + &mediaJobModel{}, + &mediaAssetModel{}, + &mediaUploadTicketModel{}, + &runtimeSettingsModel{}, +} + +var schemaIndexes = []string{ + "CREATE INDEX IF NOT EXISTS idx_admin_sessions_admin_created ON admin_sessions(admin_id, created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at)", + // SQLite 通过重建表修改 CHECK 约束,重建会删除独立存储的 GORM 唯一索引; + // 在统一索引阶段显式恢复这些数据完整性约束。 + "CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_accounts_identity_key ON provider_accounts(identity_key)", + "CREATE INDEX IF NOT EXISTS idx_accounts_routing ON provider_accounts(provider, enabled, auth_status, priority DESC, id ASC)", + "CREATE INDEX IF NOT EXISTS idx_accounts_created_id ON provider_accounts(created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_accounts_auto_clean_reauth ON provider_accounts(auth_status, reauth_marked_at, id)", + "CREATE INDEX IF NOT EXISTS idx_accounts_auto_clean_reauth_cursor ON provider_accounts(auth_status, enabled, id, reauth_marked_at)", + "CREATE INDEX IF NOT EXISTS idx_account_credentials_refresh_due ON account_credentials(refresh_due_at, account_id)", + "CREATE INDEX IF NOT EXISTS idx_quota_windows_due ON account_quota_windows(remaining, reset_at, account_id)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_model_routes_public_id ON model_routes(public_id)", + "CREATE INDEX IF NOT EXISTS idx_model_routes_provider_upstream ON model_routes(provider, upstream_model)", + "CREATE INDEX IF NOT EXISTS idx_model_routes_created_id ON model_routes(created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_model_routes_enabled ON model_routes(enabled, public_id, id)", + "CREATE INDEX IF NOT EXISTS idx_model_route_aliases_route ON model_route_aliases(model_route_id, alias)", + "CREATE INDEX IF NOT EXISTS idx_model_route_accounts_account_route ON model_route_accounts(account_id, model_route_id)", + "CREATE INDEX IF NOT EXISTS idx_account_model_quota_blocks_due ON account_model_quota_blocks(cooldown_until, account_id)", + "CREATE INDEX IF NOT EXISTS idx_client_keys_created_id ON client_keys(created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_client_keys_status ON client_keys(enabled, expires_at, created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_client_key_models_route_key ON client_key_models(model_route_id, client_key_id)", + "CREATE INDEX IF NOT EXISTS idx_billing_reservations_expiry ON billing_reservations(expires_at, client_key_id)", + "CREATE INDEX IF NOT EXISTS idx_egress_nodes_scope_health ON egress_nodes(scope, enabled, health DESC, id ASC)", + "CREATE INDEX IF NOT EXISTS idx_egress_nodes_probe_due ON egress_nodes(enabled, last_probed_at, id)", + "CREATE INDEX IF NOT EXISTS idx_audits_created_id ON request_audits(created_at DESC, id DESC)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_audits_event_id ON request_audits(event_id) WHERE event_id <> ''", + "CREATE INDEX IF NOT EXISTS idx_audits_account_created_id ON request_audits(account_id, created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_audits_status_created_id ON request_audits(status_code, created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_audits_streaming_created_id ON request_audits(streaming, created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_audit_attempts_audit_number ON request_audit_attempts(audit_id, number)", + "CREATE INDEX IF NOT EXISTS idx_response_ownership_expires_id ON response_ownership(expires_at, response_id)", + "CREATE INDEX IF NOT EXISTS idx_response_ownership_account ON response_ownership(account_id)", + "CREATE INDEX IF NOT EXISTS idx_response_ownership_client_key ON response_ownership(client_key_id)", + "CREATE INDEX IF NOT EXISTS idx_web_response_states_expires_id ON web_response_states(expires_at, response_id)", + "CREATE INDEX IF NOT EXISTS idx_web_response_states_account ON web_response_states(account_id, created_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_media_jobs_client_created ON media_jobs(client_key_id, created_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_media_jobs_account_status ON media_jobs(account_id, status)", + "CREATE INDEX IF NOT EXISTS idx_media_jobs_recovery ON media_jobs(status, lease_until, created_at, id)", + "CREATE INDEX IF NOT EXISTS idx_media_jobs_usage_recovery ON media_jobs(status, usage_recorded_at, completed_at, id)", + "CREATE INDEX IF NOT EXISTS idx_media_assets_created ON media_assets(created_at DESC, id)", + "CREATE INDEX IF NOT EXISTS idx_media_assets_kind_created ON media_assets(kind, created_at DESC, id)", + "CREATE INDEX IF NOT EXISTS idx_media_upload_tickets_expires ON media_upload_tickets(expires_at, consumed_at)", + "CREATE INDEX IF NOT EXISTS idx_media_jobs_result_asset ON media_jobs(result_asset_id) WHERE result_asset_id <> ''", + // Pending input metadata rows only; keeps startup backfill scans off the full table after migration completes. + mediaJobInputMetadataPendingIndex, +} + +// InitializeSchema 以当前持久化模型作为首版数据库结构基线。 +func (d *Database) InitializeSchema(ctx context.Context) error { + if d.dialect == "postgres" { + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", postgresSchemaMigrationLockID).Error; err != nil { + return fmt.Errorf("acquire PostgreSQL migration lock: %w", err) + } + locked := &Database{db: tx, dialect: d.dialect} + return locked.initializeSchema(ctx) + }) + } + return d.initializeSchema(ctx) +} + +func (d *Database) initializeSchema(ctx context.Context) error { + db := d.db.WithContext(ctx) + hadClientKeys := db.Migrator().HasTable(&clientKeyModel{}) + hadProviderScope := hadClientKeys && db.Migrator().HasColumn(&clientKeyModel{}, "ProviderScopeMask") + hadTierScope := hadClientKeys && db.Migrator().HasColumn(&clientKeyModel{}, "TierScopeMask") + hadLegacyAccountPool := hadClientKeys && db.Migrator().HasColumn("client_keys", "account_pool") + // all 作用域会让 Build 与 Web 共用 UA、健康度和冷却状态,升级时直接移除旧节点。 + if db.Migrator().HasTable(&egressNodeModel{}) { + if err := db.Where("scope = ?", "all").Delete(&egressNodeModel{}).Error; err != nil { + return fmt.Errorf("清理旧版所有域出口节点: %w", err) + } + } + autoMigrate := func() error { + return d.db.WithContext(ctx).AutoMigrate(schemaModels...) + } + var migrateErr error + if d.dialect == "sqlite" { + // SQLite 修改 CHECK 等表级约束时会重建表。provider_accounts 等父表已被多个 + // 子表引用,必须在固定连接上暂停外键,否则 DROP 旧父表会直接失败。 + migrateErr = d.withSQLiteForeignKeysDisabled(ctx, autoMigrate) + } else { + migrateErr = autoMigrate() + } + if migrateErr != nil { + return fmt.Errorf("初始化数据库表: %w", migrateErr) + } + if err := d.migrateClientKeyAccountScopes(ctx, hadLegacyAccountPool, !hadProviderScope, !hadTierScope); err != nil { + return fmt.Errorf("迁移客户端 Key 调用范围: %w", err) + } + if err := d.migrateBuildResponseHeaderTimeout(ctx); err != nil { + return fmt.Errorf("迁移 Grok Build 响应头超时: %w", err) + } + if err := d.ensureConsoleConstraints(ctx); err != nil { + return fmt.Errorf("迁移 Console 数据库约束: %w", err) + } + if err := d.ensureAuditOperationConstraints(ctx); err != nil { + return fmt.Errorf("迁移请求审计操作约束: %w", err) + } + if err := d.ensureMediaJobConstraints(ctx); err != nil { + return fmt.Errorf("迁移 media job 数据库约束: %w", err) + } + if err := d.ensureMediaJobInputConstraint(ctx); err != nil { + return fmt.Errorf("迁移 media job 输入长度约束: %w", err) + } + // Create the pending-metadata partial index before backfill so both first + // upgrade and subsequent empty scans avoid walking the full media_jobs table. + if err := d.ensureMediaJobInputMetadataPendingIndex(ctx); err != nil { + return fmt.Errorf("初始化 media job 输入元数据索引: %w", err) + } + if err := d.migrateMediaJobInputMetadata(ctx); err != nil { + return fmt.Errorf("迁移 media job 输入元数据: %w", err) + } + if err := d.ensureMediaJobAccountForeignKey(ctx); err != nil { + return fmt.Errorf("迁移 media job 账号外键: %w", err) + } + if err := d.ensureMediaAssetConstraints(ctx); err != nil { + return fmt.Errorf("迁移 media asset 数据库约束: %w", err) + } + if err := d.ensureClientKeyLimitConstraints(ctx); err != nil { + return fmt.Errorf("迁移客户端 Key 限额约束: %w", err) + } + if err := d.backfillWebEgressIdentities(ctx); err != nil { + return fmt.Errorf("迁移 Web 出口身份: %w", err) + } + if err := d.backfillReauthMarkedAt(ctx); err != nil { + return fmt.Errorf("迁移 reauth_marked_at: %w", err) + } + if err := d.dropRedundantResponseExpiryIndexes(ctx); err != nil { + return fmt.Errorf("迁移响应过期索引: %w", err) + } + if err := d.dropProviderUpstreamUniqueIndex(ctx); err != nil { + return fmt.Errorf("迁移模型路由上游唯一约束: %w", err) + } + for _, statement := range schemaIndexes { + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("初始化数据库索引: %w", err) + } + } + if err := d.ensureCanonicalModelPublicIDs(ctx); err != nil { + return fmt.Errorf("迁移模型 Provider 命名空间: %w", err) + } + return nil +} + +// migrateClientKeyAccountScopes translates the short-lived account_pool +// representation only when the corresponding scope columns are first added. +func (d *Database) migrateClientKeyAccountScopes(ctx context.Context, hadLegacyAccountPool, providerScopeAdded, tierScopeAdded bool) error { + if !hadLegacyAccountPool || (!providerScopeAdded && !tierScopeAdded) { + return nil + } + updates := make(map[string]any, 2) + if providerScopeAdded { + updates["provider_scope_mask"] = uint8(clientkeydomain.ProviderScopeAll) + } + if tierScopeAdded { + updates["tier_scope_mask"] = gorm.Expr("CASE account_pool WHEN 'free' THEN 1 WHEN 'super' THEN 2 ELSE 7 END") + } + return d.db.WithContext(ctx).Table("client_keys").Where("1 = 1").Updates(updates).Error +} + +// migrateBuildResponseHeaderTimeout persists the runtime default for settings +// rows created before the Build response-header timeout became configurable. +func (d *Database) migrateBuildResponseHeaderTimeout(ctx context.Context) error { + db := d.db.WithContext(ctx) + for range 4 { + var row runtimeSettingsModel + if err := db.Where("key = ?", runtimeSettingsKey).First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + var payload runtimeSettingsPayload + if err := json.Unmarshal([]byte(row.ValueJSON), &payload); err != nil { + return fmt.Errorf("decode runtime settings: %w", err) + } + if payload.Config.ProviderBuild.ResponseHeaderTimeout > 0 { + return nil + } + payload.Config.ProviderBuild.ResponseHeaderTimeout = settingsdomain.DefaultBuildResponseHeaderTimeout + encoded, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode runtime settings: %w", err) + } + result := db.Model(&runtimeSettingsModel{}). + Where("key = ? AND revision = ?", row.Key, row.Revision). + UpdateColumn("value_json", string(encoded)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 1 { + return nil + } + } + return errors.New("runtime settings changed repeatedly during migration") +} + +func (d *Database) dropRedundantResponseExpiryIndexes(ctx context.Context) error { + for _, name := range []string{"idx_response_ownership_expires", "idx_web_response_states_expires"} { + if err := d.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS " + name).Error; err != nil { + return fmt.Errorf("drop index %s: %w", name, err) + } + } + return nil +} + +// dropProviderUpstreamUniqueIndex 允许同一 Provider 下多条对外名映射到同一上游模型。 +func (d *Database) dropProviderUpstreamUniqueIndex(ctx context.Context) error { + if err := d.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS uidx_provider_upstream").Error; err != nil { + return fmt.Errorf("drop index uidx_provider_upstream: %w", err) + } + return nil +} + +// backfillReauthMarkedAt 为历史 reauthRequired 账号补齐清理锚点;优先使用 updated_at。 +func (d *Database) backfillReauthMarkedAt(ctx context.Context) error { + return d.db.WithContext(ctx).Exec(` +UPDATE provider_accounts +SET reauth_marked_at = updated_at +WHERE auth_status = ? AND reauth_marked_at IS NULL +`, "reauthRequired").Error +} + +type consoleConstraint struct { + model any + table string + name string +} + +func (d *Database) ensureConsoleConstraints(ctx context.Context) error { + return d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &accountModel{}, table: "provider_accounts", name: "chk_accounts_provider"}, + {model: &modelRouteModel{}, table: "model_routes", name: "chk_model_routes_provider"}, + {model: &requestAuditModel{}, table: "request_audits", name: "chk_request_audits_provider"}, + {model: &responseOwnershipModel{}, table: "response_ownership", name: "chk_response_ownership_provider"}, + {model: &egressNodeModel{}, table: "egress_nodes", name: "chk_egress_nodes_specific_scope"}, + }, "grok_console") +} + +// ensureAuditOperationConstraints upgrades existing databases so Codex remote +// compaction can be recorded separately from ordinary Responses requests. +func (d *Database) ensureAuditOperationConstraints(ctx context.Context) error { + return d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &requestAuditModel{}, table: "request_audits", name: "chk_request_audits_operation"}, + }, "compaction") +} + +// ensureMediaJobConstraints 将历史仅允许 grok_web 的 media job CHECK 升级到支持 Build 视频。 +// AutoMigrate 不会可靠替换已有 PostgreSQL CHECK,因此启动时幂等检测并重建。 +func (d *Database) ensureMediaJobConstraints(ctx context.Context) error { + return d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &mediaJobModel{}, table: "media_jobs", name: "chk_media_jobs_provider"}, + {model: &mediaJobModel{}, table: "media_jobs", name: "chk_media_jobs_egress_scope"}, + }, "grok_build") +} + +// ensureMediaJobInputConstraint 允许异步视频任务持久化 Base64 首图。 +// SQLite 和 PostgreSQL 都不会由 AutoMigrate 可靠替换已有的同名 CHECK。 +func (d *Database) ensureMediaJobInputConstraint(ctx context.Context) error { + if err := d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &mediaJobModel{}, table: "media_jobs", name: "chk_media_jobs_input_json"}, + }, strconv.Itoa(media.MaxInputJSONBytes)); err != nil { + return err + } + return d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &mediaJobModel{}, table: "media_jobs", name: "chk_media_jobs_input_image_count"}, + }, strconv.Itoa(media.MaxInputImages)) +} + +// ensureMediaJobInputMetadataPendingIndex accelerates the one-shot input_image_count +// backfill and keeps later startup probes from full-scanning media_jobs after completion. +func (d *Database) ensureMediaJobInputMetadataPendingIndex(ctx context.Context) error { + if err := d.db.WithContext(ctx).Exec(mediaJobInputMetadataPendingIndex).Error; err != nil { + return err + } + return nil +} + +// migrateMediaJobInputMetadata backfills the compact image count introduced +// after InputJSON began accepting large Base64 references. Already-audited +// terminal jobs can discard the raw payload immediately; active and unaudited +// jobs retain it for worker recovery. +func (d *Database) migrateMediaJobInputMetadata(ctx context.Context) error { + db := d.db.WithContext(ctx) + terminal := []string{"completed", "failed"} + if err := db.Model(&mediaJobModel{}). + Where("status IN ? AND usage_recorded_at IS NOT NULL AND input_json <> ?", terminal, "{}"). + UpdateColumn("input_json", "{}").Error; err != nil { + return err + } + type inputRow struct { + ID string + InputJSON string + } + const batchSize = 8 + cursor := "" + for { + var rows []inputRow + query := db.Model(&mediaJobModel{}). + Select("id", "input_json"). + Where("id > ? AND input_image_count IS NULL", cursor). + Order("id ASC").Limit(batchSize) + if err := query.Find(&rows).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + for _, row := range rows { + var input struct { + ImageURLs []string `json:"image_urls"` + } + parseErr := json.Unmarshal([]byte(row.InputJSON), &input) + count := min(len(input.ImageURLs), media.MaxInputImages) + updates := map[string]any{"input_image_count": count} + // Historical code treated malformed or empty input as no references. + // Normalize it once so future startups do not rescan the same row. + if parseErr != nil || count == 0 { + updates["input_json"] = "{}" + } + if err := db.Model(&mediaJobModel{}).Where("id = ?", row.ID).UpdateColumns(updates).Error; err != nil { + return err + } + } + cursor = rows[len(rows)-1].ID + } +} + +// ensureMediaJobAccountForeignKey 让终态视频任务在账号删除后保留快照, +// 同时由应用层阻止删除仍有关联 queued/in_progress 任务的账号。 +func (d *Database) ensureMediaJobAccountForeignKey(ctx context.Context) error { + constraint := consoleConstraint{model: &mediaJobModel{}, table: "media_jobs", name: "fk_media_jobs_account"} + definition, err := d.constraintDefinition(ctx, constraint) + if err != nil { + return err + } + if d.dialect == "postgres" { + db := d.db.WithContext(ctx) + if err := db.Exec("ALTER TABLE media_jobs ALTER COLUMN account_id DROP NOT NULL").Error; err != nil { + return err + } + if strings.Contains(strings.ToUpper(definition), "ON DELETE SET NULL") { + return nil + } + if err := db.Exec("ALTER TABLE media_jobs DROP CONSTRAINT IF EXISTS fk_media_jobs_account").Error; err != nil { + return err + } + return db.Exec("ALTER TABLE media_jobs ADD CONSTRAINT fk_media_jobs_account FOREIGN KEY (account_id) REFERENCES provider_accounts(id) ON UPDATE CASCADE ON DELETE SET NULL").Error + } + if strings.Contains(strings.ToUpper(definition), "ON DELETE SET NULL") { + return nil + } + return d.withSQLiteForeignKeysDisabled(ctx, func() error { + migrator := d.db.WithContext(ctx).Migrator() + if err := migrator.AlterColumn(&mediaJobModel{}, "AccountID"); err != nil { + return err + } + if definition != "" { + if err := migrator.DropConstraint(&mediaJobModel{}, "Account"); err != nil { + return err + } + } + return migrator.CreateConstraint(&mediaJobModel{}, "Account") + }) +} + +// ensureMediaAssetConstraints 升级历史仅允许 image 的媒体资产 CHECK,以支持 video 与更大体积。 +func (d *Database) ensureMediaAssetConstraints(ctx context.Context) error { + if err := d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &mediaAssetModel{}, table: "media_assets", name: "chk_media_assets_kind"}, + }, "video"); err != nil { + return err + } + if err := d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &mediaAssetModel{}, table: "media_assets", name: "chk_media_assets_mime"}, + }, "video/mp4"); err != nil { + return err + } + return d.ensureNamedConstraints(ctx, []consoleConstraint{ + {model: &mediaAssetModel{}, table: "media_assets", name: "chk_media_assets_size"}, + }, "268435456") +} + +// ensureClientKeyLimitConstraints 将历史正数限制升级为允许 0 表示无限制。 +// PostgreSQL 不会由 AutoMigrate 可靠替换同名 CHECK,因此需显式检测并重建。 +func (d *Database) ensureClientKeyLimitConstraints(ctx context.Context) error { + constraints := []consoleConstraint{ + {model: &clientKeyModel{}, table: "client_keys", name: "chk_client_keys_rpm"}, + {model: &clientKeyModel{}, table: "client_keys", name: "chk_client_keys_max_concurrent"}, + } + migrate := func() error { + db := d.db.WithContext(ctx) + for _, value := range constraints { + definition, err := d.constraintDefinition(ctx, value) + if err != nil { + return err + } + if clientKeyLimitConstraintAllowsZero(definition) { + continue + } + if definition != "" { + if err := db.Migrator().DropConstraint(value.model, value.name); err != nil { + return fmt.Errorf("删除旧约束 %s: %w", value.name, err) + } + } + if err := db.Migrator().CreateConstraint(value.model, value.name); err != nil { + return fmt.Errorf("创建约束 %s: %w", value.name, err) + } + } + return nil + } + if d.dialect == "sqlite" { + return d.withSQLiteForeignKeysDisabled(ctx, migrate) + } + return migrate() +} + +func clientKeyLimitConstraintAllowsZero(definition string) bool { + normalized := strings.NewReplacer(" ", "", "\n", "", "\t", "", "\"", "", "`", "", "(", "", ")", "").Replace(strings.ToLower(definition)) + return strings.Contains(normalized, "between0and") || strings.Contains(normalized, ">=0") +} + +// ensureNamedConstraints 在约束定义尚未包含 marker 时 drop/recreate;已升级则跳过。 +func (d *Database) ensureNamedConstraints(ctx context.Context, constraints []consoleConstraint, marker string) error { + migrate := func() error { + db := d.db.WithContext(ctx) + for _, value := range constraints { + definition, err := d.constraintDefinition(ctx, value) + if err != nil { + return err + } + if strings.Contains(definition, marker) { + continue + } + if definition != "" { + if err := db.Migrator().DropConstraint(value.model, value.name); err != nil { + return fmt.Errorf("删除旧约束 %s: %w", value.name, err) + } + } + if err := db.Migrator().CreateConstraint(value.model, value.name); err != nil { + return fmt.Errorf("创建约束 %s: %w", value.name, err) + } + } + return nil + } + if d.dialect == "sqlite" { + return d.withSQLiteForeignKeysDisabled(ctx, migrate) + } + return migrate() +} + +// withSQLiteForeignKeysDisabled 将会重建父表的约束迁移固定到唯一连接。 +// SQLite 的 DROP TABLE 即使只用于改 CHECK,也会执行 ON DELETE CASCADE;因此必须 +// 在同一物理连接上临时关闭外键,迁移后再完整校验并恢复。 +func (d *Database) withSQLiteForeignKeysDisabled(ctx context.Context, migrate func() error) error { + sqlDB, err := d.db.DB() + if err != nil { + return err + } + // OpenSQLite 的正常池大小为 16。收敛为一个连接,确保 PRAGMA 与 GORM 的表重建 + // 使用同一 SQLite 会话;初始化阶段尚未启动业务协程,不会影响请求处理。 + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + defer func() { + sqlDB.SetMaxOpenConns(16) + sqlDB.SetMaxIdleConns(16) + }() + db := d.db.WithContext(ctx) + if err := db.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { + return fmt.Errorf("暂停 SQLite 外键约束: %w", err) + } + foreignKeysDisabled := true + defer func() { + if foreignKeysDisabled { + _ = db.Exec("PRAGMA foreign_keys = ON").Error + } + }() + var foreignKeys int + if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil { + return fmt.Errorf("确认 SQLite 外键状态: %w", err) + } + if foreignKeys != 0 { + return fmt.Errorf("暂停 SQLite 外键约束失败") + } + migrationErr := migrate() + if migrationErr == nil { + var violations []struct { + Table string + RowID *int64 + Parent string + FKID int + } + if err := db.Raw("PRAGMA foreign_key_check").Scan(&violations).Error; err != nil { + migrationErr = fmt.Errorf("校验 SQLite 外键: %w", err) + } else if len(violations) > 0 { + migrationErr = fmt.Errorf("SQLite 约束迁移产生 %d 条外键违规", len(violations)) + } + } + enableErr := db.Exec("PRAGMA foreign_keys = ON").Error + if enableErr == nil { + foreignKeysDisabled = false + } + if migrationErr != nil { + if enableErr != nil { + return fmt.Errorf("%w;恢复 SQLite 外键失败: %v", migrationErr, enableErr) + } + return migrationErr + } + if enableErr != nil { + return fmt.Errorf("恢复 SQLite 外键约束: %w", enableErr) + } + return nil +} + +func (d *Database) constraintDefinition(ctx context.Context, value consoleConstraint) (string, error) { + var definition string + switch d.dialect { + case "sqlite": + if err := d.db.WithContext(ctx).Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", value.table).Scan(&definition).Error; err != nil { + return "", err + } + definition = sqliteConstraintDefinition(definition, value.name) + case "postgres": + if err := d.db.WithContext(ctx).Raw(` + SELECT pg_get_constraintdef(constraint_row.oid) + FROM pg_constraint constraint_row + JOIN pg_class table_row ON table_row.oid = constraint_row.conrelid + WHERE table_row.relname = ? AND constraint_row.conname = ? + `, value.table, value.name).Scan(&definition).Error; err != nil { + return "", err + } + default: + return "", fmt.Errorf("不支持的数据库驱动: %s", d.dialect) + } + return definition, nil +} + +func sqliteConstraintDefinition(tableSQL, name string) string { + lower := strings.ToLower(tableSQL) + start := strings.Index(lower, strings.ToLower(name)) + if start < 0 { + return "" + } + definition := tableSQL[start:] + rest := strings.ToLower(definition[len(name):]) + if next := strings.Index(rest, "constraint "); next >= 0 { + definition = definition[:len(name)+next] + } + return definition +} diff --git a/backend/internal/infra/persistence/relational/schema_account_identity.go b/backend/internal/infra/persistence/relational/schema_account_identity.go new file mode 100644 index 0000000000000000000000000000000000000000..fa7d9006b51e49358b566f92b30228c87a34b7dd --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_account_identity.go @@ -0,0 +1,53 @@ +package relational + +import ( + "context" + "encoding/hex" + "strings" +) + +const ( + webSSOSourcePrefix = "sso:" + stableSSODigestLength = 64 + egressDigestLength = 32 +) + +// backfillWebEgressIdentities 只使用已持久化的 SSO 摘要恢复现有 Resin 身份, +// 不解密凭据、不访问上游,也不会建立新的跨 Provider 关系。 +func (d *Database) backfillWebEgressIdentities(ctx context.Context) error { + var rows []struct { + AccountID uint64 + SourceKey string + } + db := d.db.WithContext(ctx) + if err := db.Table("provider_accounts AS account"). + Select("account.id AS account_id, account.source_key"). + Joins("JOIN web_account_profiles AS profile ON profile.account_id = account.id"). + Where("account.provider = ? AND profile.egress_identity = ''", "grok_web"). + Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + identity, ok := egressIdentityFromWebSourceKey(row.SourceKey) + if !ok { + continue + } + if err := db.Model(&webAccountProfileModel{}). + Where("account_id = ? AND egress_identity = ''", row.AccountID). + Update("egress_identity", identity).Error; err != nil { + return err + } + } + return nil +} + +func egressIdentityFromWebSourceKey(sourceKey string) (string, bool) { + digest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sourceKey), webSSOSourcePrefix)) + if !strings.HasPrefix(strings.TrimSpace(sourceKey), webSSOSourcePrefix) || len(digest) != stableSSODigestLength { + return "", false + } + if _, err := hex.DecodeString(digest); err != nil { + return "", false + } + return "sso_" + strings.ToLower(digest[:egressDigestLength]), true +} diff --git a/backend/internal/infra/persistence/relational/schema_build_route_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_build_route_upgrade_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d3b769fbbcd9c0774aa9d8f5f5cf4062f1c5ae31 --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_build_route_upgrade_test.go @@ -0,0 +1,64 @@ +package relational + +import ( + "context" + "path/filepath" + "strings" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestInitializeSchemaUpgradesBuildRouteConstraintWithReferencedAccounts(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-build-route.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + accounts := NewAccountRepository(database) + created, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: "legacy-build", SourceKey: "legacy-build-route", EncryptedAccessToken: testEncryptedToken, + AuthStatus: accountdomain.AuthStatusActive, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + if err := database.withSQLiteForeignKeysDisabled(ctx, func() error { + return database.db.WithContext(ctx).Migrator().DropConstraint(&accountModel{}, "chk_accounts_build_route_mode") + }); err != nil { + t.Fatal(err) + } + definition, err := database.constraintDefinition(ctx, consoleConstraint{ + model: &accountModel{}, table: "provider_accounts", name: "chk_accounts_build_route_mode", + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(definition, "build_route_mode") { + t.Fatalf("legacy constraint was not removed: %s", definition) + } + + // account_credentials 已引用 provider_accounts;升级仍应安全重建父表并保留账号。 + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + definition, err = database.constraintDefinition(ctx, consoleConstraint{ + model: &accountModel{}, table: "provider_accounts", name: "chk_accounts_build_route_mode", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(definition, "auto") || !strings.Contains(definition, "build") || !strings.Contains(definition, "xai") { + t.Fatalf("upgraded constraint = %s", definition) + } + stored, err := accounts.Get(ctx, created.ID) + if err != nil || stored.ID != created.ID { + t.Fatalf("stored account = %#v, err = %v", stored, err) + } +} diff --git a/backend/internal/infra/persistence/relational/schema_client_key_account_pool_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_client_key_account_pool_upgrade_test.go new file mode 100644 index 0000000000000000000000000000000000000000..60039cbf64b90657baf86c959ba9e189fb77868c --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_client_key_account_pool_upgrade_test.go @@ -0,0 +1,91 @@ +package relational + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" +) + +func TestInitializeSchemaMigratesLegacyClientKeyAccountPoolToScopes(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-client-key-account-pool.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := NewClientKeyRepository(database) + create := func(prefix string) clientkeydomain.Key { + value, createErr := repository.Create(ctx, clientkeydomain.Key{Name: prefix, Prefix: prefix, SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true}) + if createErr != nil { + t.Fatal(createErr) + } + return value + } + freeKey := create("legacy-free") + superKey := create("legacy-super") + allKey := create("legacy-all") + if err := recreateClientKeysWithLegacyAccountPool(ctx, database); err != nil { + t.Fatal(err) + } + if err := database.withSQLiteForeignKeysDisabled(ctx, func() error { + db := database.db.WithContext(ctx) + if err := db.Table("client_keys").Where("id = ?", freeKey.ID).Update("account_pool", "free").Error; err != nil { + return err + } + return db.Table("client_keys").Where("id = ?", superKey.ID).Update("account_pool", "super").Error + }); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertScope := func(id uint64, wantTier clientkeydomain.TierScope) { + stored, getErr := repository.Get(ctx, id) + if getErr != nil { + t.Fatal(getErr) + } + if stored.ProviderScope != clientkeydomain.ProviderScopeAll || stored.TierScope != wantTier { + t.Fatalf("migrated scope for %d = %+v", id, stored.AccountScope()) + } + } + assertScope(freeKey.ID, clientkeydomain.TierScopeFree) + assertScope(superKey.ID, clientkeydomain.TierScopeSuper) + assertScope(allKey.ID, clientkeydomain.TierScopeAll) + if err := database.db.WithContext(ctx).Model(&clientKeyModel{}).Where("id = ?", allKey.ID).Update("tier_scope_mask", 4).Error; err == nil { + t.Fatal("tier scope constraint accepted an unsupported unknown-only value") + } +} + +func recreateClientKeysWithLegacyAccountPool(ctx context.Context, database *Database) error { + return database.withSQLiteForeignKeysDisabled(ctx, func() error { + db := database.db.WithContext(ctx) + var tableSQL string + if err := db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'client_keys'").Scan(&tableSQL).Error; err != nil { + return err + } + legacySQL := strings.Replace(tableSQL, ",`provider_scope_mask` integer NOT NULL DEFAULT 7", "", 1) + legacySQL = strings.Replace(legacySQL, ",`tier_scope_mask` integer NOT NULL DEFAULT 7", "", 1) + legacySQL = strings.Replace(legacySQL, ",CONSTRAINT `chk_client_keys_provider_scope` CHECK (provider_scope_mask BETWEEN 1 AND 7)", "", 1) + legacySQL = strings.Replace(legacySQL, ",CONSTRAINT `chk_client_keys_tier_scope` CHECK (tier_scope_mask IN (1,2,3,7))", "", 1) + legacySQL = strings.Replace(legacySQL, ",CONSTRAINT", ",`account_pool` text NOT NULL DEFAULT 'all' CHECK (account_pool IN ('all','free','super')),CONSTRAINT", 1) + legacySQL = strings.Replace(legacySQL, "client_keys", "client_keys_legacy_scope", 1) + if err := db.Exec(legacySQL).Error; err != nil { + return fmt.Errorf("create legacy table with %s: %w", legacySQL, err) + } + const columns = "id,name,prefix,secret_hash,encrypted_secret,enabled,expires_at,rpm_limit,max_concurrent,billing_limit_usd_ticks,billed_usage_usd_ticks,reserved_usage_usd_ticks,allow_model_aliases,last_used_at,created_at,updated_at" + if err := db.Exec("INSERT INTO client_keys_legacy_scope (" + columns + ") SELECT " + columns + " FROM client_keys").Error; err != nil { + return err + } + if err := db.Exec("DROP TABLE client_keys").Error; err != nil { + return err + } + return db.Exec("ALTER TABLE client_keys_legacy_scope RENAME TO client_keys").Error + }) +} diff --git a/backend/internal/infra/persistence/relational/schema_client_key_limits_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_client_key_limits_upgrade_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c81e119b1d1175c3222880d72b5fb3a40b289631 --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_client_key_limits_upgrade_test.go @@ -0,0 +1,108 @@ +package relational + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInitializeSchemaUpgradesClientKeyLimitsToAllowZero(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-client-key-limits.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + db := database.db.WithContext(ctx) + now := time.Now().UTC() + legacy := clientKeyModel{ + Name: "legacy", Prefix: "legacy-prefix", SecretHash: testSecretHash, + EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + if err := recreatePositiveOnlyClientKeyLimits(ctx, database); err != nil { + t.Fatal(err) + } + if err := db.Exec("UPDATE client_keys SET rpm_limit = 0 WHERE prefix = ?", "legacy-prefix").Error; err == nil { + t.Fatal("legacy RPM constraint unexpectedly accepted zero") + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := db.Exec("UPDATE client_keys SET rpm_limit = 0, max_concurrent = 0 WHERE prefix = ?", "legacy-prefix").Error; err != nil { + t.Fatalf("upgraded constraints rejected zero: %v", err) + } + var stored clientKeyModel + if err := db.Where("prefix = ?", "legacy-prefix").First(&stored).Error; err != nil { + t.Fatal(err) + } + if stored.Name != "legacy" || stored.RPMLimit != 0 || stored.MaxConcurrent != 0 { + t.Fatalf("stored key after upgrade = %#v", stored) + } + if err := db.Exec("UPDATE client_keys SET rpm_limit = -1 WHERE prefix = ?", "legacy-prefix").Error; err == nil { + t.Fatal("upgraded RPM constraint accepted a negative value") + } + if err := db.Exec("UPDATE client_keys SET max_concurrent = 1025 WHERE prefix = ?", "legacy-prefix").Error; err == nil { + t.Fatal("upgraded concurrency constraint accepted an oversized value") + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("repeated schema initialization: %v", err) + } + if err := db.Where("prefix = ?", "legacy-prefix").First(&stored).Error; err != nil { + t.Fatal(err) + } + if stored.RPMLimit != 0 || stored.MaxConcurrent != 0 { + t.Fatalf("limits after repeated initialization = rpm %d, concurrency %d", stored.RPMLimit, stored.MaxConcurrent) + } +} + +func recreatePositiveOnlyClientKeyLimits(ctx context.Context, database *Database) error { + return database.withSQLiteForeignKeysDisabled(ctx, func() error { + db := database.db.WithContext(ctx) + var tableSQL string + if err := db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'client_keys'").Scan(&tableSQL).Error; err != nil { + return err + } + legacySQL := strings.Replace(tableSQL, "rpm_limit BETWEEN 0 AND 100000", "rpm_limit BETWEEN 1 AND 100000", 1) + legacySQL = strings.Replace(legacySQL, "max_concurrent BETWEEN 0 AND 1024", "max_concurrent BETWEEN 1 AND 1024", 1) + if legacySQL == tableSQL { + return nil + } + legacySQL = strings.Replace(legacySQL, "client_keys", "client_keys_legacy", 1) + if err := db.Exec(legacySQL).Error; err != nil { + return err + } + if err := db.Exec("INSERT INTO client_keys_legacy SELECT * FROM client_keys").Error; err != nil { + return err + } + if err := db.Exec("DROP TABLE client_keys").Error; err != nil { + return err + } + return db.Exec("ALTER TABLE client_keys_legacy RENAME TO client_keys").Error + }) +} + +func TestClientKeyLimitConstraintAllowsZero(t *testing.T) { + for _, definition := range []string{ + "CHECK (rpm_limit BETWEEN 0 AND 100000)", + `CHECK ((("max_concurrent" >= 0) AND ("max_concurrent" <= 1024)))`, + } { + if !clientKeyLimitConstraintAllowsZero(definition) { + t.Fatalf("constraint should allow zero: %s", definition) + } + } + if clientKeyLimitConstraintAllowsZero("CHECK (rpm_limit BETWEEN 1 AND 100000)") { + t.Fatal("legacy positive-only constraint was treated as upgraded") + } +} diff --git a/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go new file mode 100644 index 0000000000000000000000000000000000000000..64cec2484339501d22d56a3b5e79c2553f3a0872 --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go @@ -0,0 +1,205 @@ +package relational + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestInitializeSchemaUpgradesProviderChecksForConsole(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + legacy := []any{ + &legacyProviderAccountModel{}, &legacyModelRouteModel{}, &legacyRequestAuditModel{}, + &legacyResponseOwnershipModel{}, &legacyEgressNodeModel{}, + } + if err := database.db.WithContext(ctx).AutoMigrate(legacy...); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).AutoMigrate(schemaModels...); err != nil { + t.Fatal(err) + } + accountRepository := NewAccountRepository(database) + created, _, err := accountRepository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "existing-build", SourceKey: "existing-build", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := accountRepository.SaveQuotaWindows(ctx, created.ID, account.WebTierAuto, now, []account.QuotaWindow{{ + AccountID: created.ID, Mode: "test", Remaining: 7, Total: 20, WindowSeconds: 3600, + Source: account.QuotaSourceUpstream, SyncedAt: &now, + }}); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if preserved, err := accountRepository.Get(ctx, created.ID); err != nil || preserved.Name != "existing-build" || preserved.EncryptedAccessToken != "encrypted" || preserved.AuthType != account.AuthTypeOAuth { + t.Fatalf("existing account was not preserved: %#v, err=%v", preserved, err) + } + windows, err := accountRepository.GetQuotaWindows(ctx, []uint64{created.ID}) + if err != nil || len(windows[created.ID]) != 1 || windows[created.ID][0].Remaining != 7 { + t.Fatalf("existing quota windows were not preserved: %#v, err=%v", windows, err) + } + for _, table := range []string{"provider_accounts", "model_routes", "request_audits", "response_ownership", "egress_nodes"} { + var sql string + if err := database.db.WithContext(ctx).Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&sql).Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(sql, "grok_console") { + t.Fatalf("table %s was not upgraded: %s", table, sql) + } + if table == "request_audits" && !strings.Contains(sql, "compaction") { + t.Fatalf("table %s operation constraint was not upgraded: %s", table, sql) + } + } + assertSQLiteUniqueIndexes(t, database, "provider_accounts", "idx_provider_accounts_identity_key") + assertSQLiteUniqueIndexes(t, database, "model_routes", "idx_model_routes_public_id") + assertSQLiteIndexes(t, database, "model_routes", "idx_model_routes_provider_upstream") + assertSQLiteMissingIndexes(t, database, "model_routes", "uidx_provider_upstream") + assertTableColumns(t, database, "request_audits", []string{"first_token_ms"}, nil) + assertTableColumns(t, database, "response_ownership", []string{"prompt_cache_key", "reasoning_replay_key"}, nil) +} + +func TestInitializeSchemaDropsRedundantResponseExpiryIndexes(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "response-indexes.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + "CREATE INDEX IF NOT EXISTS idx_response_ownership_expires ON response_ownership(expires_at)", + "CREATE INDEX IF NOT EXISTS idx_web_response_states_expires ON web_response_states(expires_at)", + } { + if err := database.db.WithContext(ctx).Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + for _, table := range []string{"response_ownership", "web_response_states"} { + var indexes []struct{ Name string } + if err := database.db.Raw("PRAGMA index_list('" + table + "')").Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + for _, index := range indexes { + if index.Name == "idx_response_ownership_expires" || index.Name == "idx_web_response_states_expires" { + t.Fatalf("redundant expiry index %s remains on %s", index.Name, table) + } + } + } +} + +func assertSQLiteUniqueIndexes(t *testing.T, database *Database, table string, expected ...string) { + t.Helper() + var indexes []struct { + Name string + Unique int + } + if err := database.db.Raw("PRAGMA index_list('" + table + "')").Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + found := make(map[string]bool, len(indexes)) + for _, index := range indexes { + if index.Unique == 1 { + found[index.Name] = true + } + } + for _, name := range expected { + if !found[name] { + t.Fatalf("table %s missing unique index %s: %#v", table, name, indexes) + } + } +} + +func assertSQLiteIndexes(t *testing.T, database *Database, table string, expected ...string) { + t.Helper() + var indexes []struct { + Name string + Unique int + } + if err := database.db.Raw("PRAGMA index_list('" + table + "')").Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + found := make(map[string]bool, len(indexes)) + for _, index := range indexes { + found[index.Name] = true + } + for _, name := range expected { + if !found[name] { + t.Fatalf("table %s missing index %s: %#v", table, name, indexes) + } + } +} + +func assertSQLiteMissingIndexes(t *testing.T, database *Database, table string, unexpected ...string) { + t.Helper() + var indexes []struct { + Name string + Unique int + } + if err := database.db.Raw("PRAGMA index_list('" + table + "')").Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + found := make(map[string]bool, len(indexes)) + for _, index := range indexes { + found[index.Name] = true + } + for _, name := range unexpected { + if found[name] { + t.Fatalf("table %s still has index %s: %#v", table, name, indexes) + } + } +} + +type legacyProviderAccountModel struct { + ID uint64 `gorm:"primaryKey"` + Provider string `gorm:"size:32;not null;check:chk_accounts_provider,provider IN ('grok_build','grok_web')"` +} + +func (legacyProviderAccountModel) TableName() string { return "provider_accounts" } + +type legacyModelRouteModel struct { + ID uint64 `gorm:"primaryKey"` + Provider string `gorm:"size:32;not null;check:chk_model_routes_provider,provider IN ('grok_build','grok_web')"` +} + +func (legacyModelRouteModel) TableName() string { return "model_routes" } + +type legacyRequestAuditModel struct { + ID uint64 `gorm:"primaryKey"` + Provider string `gorm:"size:32;not null;check:chk_request_audits_provider,provider IN ('grok_build','grok_web')"` + Operation string `gorm:"size:32;not null;default:'responses';check:chk_request_audits_operation,operation IN ('responses','chat','messages','image','image_edit','video')"` +} + +func (legacyRequestAuditModel) TableName() string { return "request_audits" } + +type legacyResponseOwnershipModel struct { + ID uint64 `gorm:"primaryKey"` + Provider string `gorm:"size:32;not null;check:chk_response_ownership_provider,provider IN ('grok_build','grok_web')"` +} + +func (legacyResponseOwnershipModel) TableName() string { return "response_ownership" } + +type legacyEgressNodeModel struct { + ID uint64 `gorm:"primaryKey"` + Scope string `gorm:"size:32;not null;check:chk_egress_nodes_specific_scope,scope IN ('all','grok_build','grok_web','grok_web_asset')"` +} + +func (legacyEgressNodeModel) TableName() string { return "egress_nodes" } diff --git a/backend/internal/infra/persistence/relational/schema_media_job_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_media_job_upgrade_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dd281e7ec452135f5af65cc8ff40719daaa417d6 --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_media_job_upgrade_test.go @@ -0,0 +1,386 @@ +package relational + +import ( + "context" + "fmt" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" +) + +func TestMediaJobModelTagsAllowBuildVideoProviderAndScope(t *testing.T) { + modelType := reflect.TypeOf(mediaJobModel{}) + providerField, ok := modelType.FieldByName("Provider") + if !ok { + t.Fatal("Provider field missing") + } + providerTag := providerField.Tag.Get("gorm") + if !strings.Contains(providerTag, "chk_media_jobs_provider") || + !strings.Contains(providerTag, "grok_web") || + !strings.Contains(providerTag, "grok_build") || + strings.Contains(providerTag, "grok_console") { + t.Fatalf("provider tag = %q", providerTag) + } + scopeField, ok := modelType.FieldByName("EgressScope") + if !ok { + t.Fatal("EgressScope field missing") + } + scopeTag := scopeField.Tag.Get("gorm") + if !strings.Contains(scopeTag, "chk_media_jobs_egress_scope") || + !strings.Contains(scopeTag, "''") || + !strings.Contains(scopeTag, "grok_web") || + !strings.Contains(scopeTag, "grok_build") || + strings.Contains(scopeTag, "grok_console") { + t.Fatalf("egress_scope tag = %q", scopeTag) + } + inputField, ok := modelType.FieldByName("InputJSON") + if !ok { + t.Fatal("InputJSON field missing") + } + inputTag := inputField.Tag.Get("gorm") + limitLiteral := strconv.Itoa(mediadomain.MaxInputJSONBytes) + if !strings.Contains(inputTag, "chk_media_jobs_input_json") || + !strings.Contains(inputTag, limitLiteral) || strings.Contains(inputTag, "1048576") { + t.Fatalf("input_json tag = %q, want limit %s", inputTag, limitLiteral) + } + countField, ok := modelType.FieldByName("InputImageCount") + if !ok { + t.Fatal("InputImageCount field missing") + } + countTag := countField.Tag.Get("gorm") + maxImages := strconv.Itoa(mediadomain.MaxInputImages) + if !strings.Contains(countTag, "chk_media_jobs_input_image_count") || + !strings.Contains(countTag, "IS NULL OR input_image_count BETWEEN 0 AND "+maxImages) { + t.Fatalf("input_image_count tag = %q, want max %s", countTag, maxImages) + } +} + +func TestMediaJobInputMetadataPendingIndexExists(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-input-pending-index.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + var count int + if err := database.db.WithContext(ctx).Raw( + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, + "idx_media_jobs_input_metadata_pending", + ).Scan(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("pending metadata index count = %d", count) + } + var sql string + if err := database.db.WithContext(ctx).Raw( + `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`, + "idx_media_jobs_input_metadata_pending", + ).Scan(&sql).Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.ToLower(sql), "input_image_count is null") { + t.Fatalf("pending index sql = %q", sql) + } +} + +func TestInitializeSchemaUpgradesOnlyPreviousMediaJobInputConstraint(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "previous-media-input.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "previous-media-account", SourceKey: "previous-media-account", + EncryptedAccessToken: testEncryptedToken, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "previous-media-key", Prefix: "previous-media-key", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + existing := testMediaJob("video_previous_input", accountValue.ID, key.ID, mediadomain.StatusQueued, now) + existing.InputJSON = `{"image_urls":["data:image/png;base64,AAAA"]}` + if err := NewMediaJobRepository(database).CreateMediaJob(ctx, existing); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Model(&mediaJobModel{}).Where("id = ?", existing.ID).UpdateColumn("input_image_count", nil).Error; err != nil { + t.Fatal(err) + } + if err := downgradeOnlyMediaJobInputConstraint(ctx, database); err != nil { + t.Fatal(err) + } + assertMediaJobSQLLacksNewInputLimit(t, database) + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertMediaJobSQLContainsBuild(t, database) + var migrated mediaJobModel + if err := database.db.WithContext(ctx).Where("id = ?", existing.ID).First(&migrated).Error; err != nil { + t.Fatal(err) + } + if migrated.InputJSON != existing.InputJSON || migrated.InputImageCount == nil || *migrated.InputImageCount != 1 { + t.Fatalf("migrated input json len=%d count=%v", len(migrated.InputJSON), migrated.InputImageCount) + } + large := testMediaJob("video_previous_large", accountValue.ID, key.ID, mediadomain.StatusQueued, now) + large.InputJSON = `{"image_urls":["` + strings.Repeat("A", (1<<20)+1) + `"]}` + large.InputImageCount = 1 + if err := NewMediaJobRepository(database).CreateMediaJob(ctx, large); err != nil { + t.Fatalf("upgraded input constraint rejected large payload: %v", err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Where("id = ?", existing.ID).First(&migrated).Error; err != nil || migrated.InputImageCount == nil || *migrated.InputImageCount != 1 { + t.Fatalf("idempotent migration lost existing job: %#v, err=%v", migrated, err) + } +} + +func TestInitializeSchemaUpgradesMediaJobChecksForBuild(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-media-jobs.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + // 先建立当前完整 schema,再将 media_jobs 回退到仅允许 Web 的旧 CHECK。 + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := recreateLegacyMediaJobsTable(ctx, database); err != nil { + t.Fatal(err) + } + assertMediaJobSQLLacksBuild(t, database) + + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, + AuthType: accountdomain.AuthTypeOAuth, + Name: "build-media-account", + SourceKey: "build-media-account", + EncryptedAccessToken: testEncryptedToken, + AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{ + Name: "build-media-key", Prefix: "build-media-key", SecretHash: testSecretHash, + EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + } + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + + // 旧约束下 Build provider 必须失败。 + now := time.Now().UTC() + legacyJob := mediadomain.Job{ + ID: "video_build_legacy_blocked", RequestID: "request-build-legacy", + ClientKeyID: key.ID, ClientKeyName: key.Name, + AccountID: accountValue.ID, AccountName: accountValue.Name, + Provider: "grok_build", Model: "grok-imagine-video-1.5", ModelRouteID: 1, + UpstreamModel: "grok-imagine-video-1.5", Prompt: "test", Seconds: 6, + Size: "16:9", Quality: "720p", Status: mediadomain.StatusQueued, + InputJSON: `{"image_urls":["` + strings.Repeat("A", (1<<20)+1) + `"]}`, CreatedAt: now, UpdatedAt: now, + } + jobs := NewMediaJobRepository(database) + if err := jobs.CreateMediaJob(ctx, legacyJob); err == nil { + t.Fatal("legacy schema unexpectedly accepted grok_build media job") + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertMediaJobSQLContainsBuild(t, database) + + // 升级后 Build job 与 grok_build egress scope 均可写入。 + legacyJob.EgressScope = "grok_build" + if err := jobs.CreateMediaJob(ctx, legacyJob); err != nil { + t.Fatalf("upgraded schema rejected build media job: %v", err) + } + webJob := mediadomain.Job{ + ID: "video_web_still_ok", RequestID: "request-web-ok", + ClientKeyID: key.ID, ClientKeyName: key.Name, + AccountID: accountValue.ID, AccountName: accountValue.Name, + Provider: "grok_web", Model: "grok-imagine-video", ModelRouteID: 2, + UpstreamModel: "grok-imagine-video", Prompt: "web", Seconds: 6, + Size: "16:9", Quality: "720p", Status: mediadomain.StatusQueued, + EgressScope: "grok_web", InputJSON: `{}`, CreatedAt: now, UpdatedAt: now, + } + if err := jobs.CreateMediaJob(ctx, webJob); err != nil { + t.Fatalf("web media job regression: %v", err) + } + + // 非法 provider / scope 仍应被拒绝。 + invalidProvider := legacyJob + invalidProvider.ID = "video_console_blocked" + invalidProvider.RequestID = "request-console-blocked" + invalidProvider.Provider = "grok_console" + if err := jobs.CreateMediaJob(ctx, invalidProvider); err == nil { + t.Fatal("console provider was accepted for media jobs") + } + invalidScope := legacyJob + invalidScope.ID = "video_scope_blocked" + invalidScope.RequestID = "request-scope-blocked" + invalidScope.EgressScope = "grok_console" + if err := jobs.CreateMediaJob(ctx, invalidScope); err == nil { + t.Fatal("console egress scope was accepted for media jobs") + } + + // 重复迁移幂等,且已有 Build 任务仍在。 + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertMediaJobSQLContainsBuild(t, database) + stored, err := jobs.GetMediaJob(ctx, legacyJob.ID, key.ID) + if err != nil || stored.Provider != "grok_build" || stored.EgressScope != "grok_build" { + t.Fatalf("stored build job = %#v, err = %v", stored, err) + } +} + +func recreateLegacyMediaJobsTable(ctx context.Context, database *Database) error { + return database.withSQLiteForeignKeysDisabled(ctx, func() error { + db := database.db.WithContext(ctx) + if err := db.Exec("DROP TABLE IF EXISTS media_jobs").Error; err != nil { + return err + } + // 仅保留升级测试所需字段,CHECK 复刻生产旧定义。 + return db.Exec(` + CREATE TABLE media_jobs ( + id text PRIMARY KEY, + request_id text NOT NULL, + client_key_id integer NOT NULL, + client_key_name text NOT NULL DEFAULT '', + account_id integer NOT NULL, + account_name text NOT NULL DEFAULT '', + egress_node_id integer, + egress_node_name text NOT NULL DEFAULT '', + egress_scope text NOT NULL DEFAULT '', + egress_mode text NOT NULL DEFAULT '', + provider text NOT NULL, + model text NOT NULL, + model_route_id integer NOT NULL, + upstream_model text NOT NULL, + prompt text NOT NULL, + seconds integer NOT NULL, + size text NOT NULL, + quality text NOT NULL, + status text NOT NULL, + progress integer NOT NULL DEFAULT 0, + input_json text NOT NULL DEFAULT '{}', + upstream_url text NOT NULL DEFAULT '', + content_type text NOT NULL DEFAULT '', + error_code text NOT NULL DEFAULT '', + error_message text NOT NULL DEFAULT '', + lease_until datetime, + claim_token text NOT NULL DEFAULT '', + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + completed_at datetime, + usage_recorded_at datetime, + CONSTRAINT chk_media_jobs_provider CHECK (provider IN ('grok_web')), + CONSTRAINT chk_media_jobs_egress_scope CHECK (egress_scope IN ('','grok_web')), + CONSTRAINT chk_media_jobs_input_json CHECK (length(input_json) <= 1048576), + CONSTRAINT fk_media_jobs_account FOREIGN KEY (account_id) REFERENCES provider_accounts(id) ON UPDATE CASCADE ON DELETE RESTRICT, + CONSTRAINT fk_media_jobs_client_key FOREIGN KEY (client_key_id) REFERENCES client_keys(id) ON UPDATE CASCADE ON DELETE RESTRICT + ) + `).Error + }) +} + +func assertMediaJobSQLLacksBuild(t *testing.T, database *Database) { + t.Helper() + sql := mediaJobsTableSQL(t, database) + if strings.Contains(sql, "grok_build") { + t.Fatalf("legacy media_jobs unexpectedly contains grok_build: %s", sql) + } + if !strings.Contains(sql, "grok_web") { + t.Fatalf("legacy media_jobs missing grok_web: %s", sql) + } + if !strings.Contains(sql, "1048576") { + t.Fatalf("legacy media_jobs missing old input_json limit: %s", sql) + } +} + +func assertMediaJobSQLLacksNewInputLimit(t *testing.T, database *Database) { + t.Helper() + sql := mediaJobsTableSQL(t, database) + if !strings.Contains(sql, "1048576") || strings.Contains(sql, strconv.Itoa(mediadomain.MaxInputJSONBytes)) { + t.Fatalf("previous media_jobs input constraint was not installed: %s", sql) + } + if !strings.Contains(sql, "grok_build") || !strings.Contains(strings.ToUpper(sql), "ON DELETE SET NULL") { + t.Fatalf("fixture changed unrelated v3.0.6 constraints: %s", sql) + } +} + +func assertMediaJobSQLContainsBuild(t *testing.T, database *Database) { + t.Helper() + sql := mediaJobsTableSQL(t, database) + if !strings.Contains(sql, "grok_build") { + t.Fatalf("media_jobs was not upgraded with grok_build: %s", sql) + } + if strings.Contains(sql, "grok_console") { + t.Fatalf("media_jobs unexpectedly allows console: %s", sql) + } + if !strings.Contains(strings.ToUpper(sql), "ON DELETE SET NULL") { + t.Fatalf("media_jobs account history is not detached on account delete: %s", sql) + } + limitLiteral := strconv.Itoa(mediadomain.MaxInputJSONBytes) + if !strings.Contains(sql, limitLiteral) || strings.Contains(sql, "1048576") { + t.Fatalf("media_jobs input_json limit was not upgraded to %s: %s", limitLiteral, sql) + } +} + +func mediaJobsTableSQL(t *testing.T, database *Database) string { + t.Helper() + var sql string + if err := database.db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", "media_jobs").Scan(&sql).Error; err != nil { + t.Fatal(err) + } + return sql +} + +func downgradeOnlyMediaJobInputConstraint(ctx context.Context, database *Database) error { + return database.withSQLiteForeignKeysDisabled(ctx, func() error { + db := database.db.WithContext(ctx) + var currentSQL string + if err := db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", "media_jobs").Scan(¤tSQL).Error; err != nil { + return err + } + createPattern := regexp.MustCompile("(?i)^CREATE TABLE\\s+[`\"]?media_jobs[`\"]?") + previousSQL := createPattern.ReplaceAllString(currentSQL, "CREATE TABLE media_jobs_previous") + previousSQL = strings.Replace(previousSQL, strconv.Itoa(mediadomain.MaxInputJSONBytes), "1048576", 1) + if previousSQL == currentSQL || !strings.Contains(previousSQL, "1048576") { + return fmt.Errorf("无法构造上一版本 media_jobs schema") + } + if err := db.Exec(previousSQL).Error; err != nil { + return err + } + if err := db.Exec("INSERT INTO media_jobs_previous SELECT * FROM media_jobs").Error; err != nil { + return err + } + if err := db.Exec("DROP TABLE media_jobs").Error; err != nil { + return err + } + return db.Exec("ALTER TABLE media_jobs_previous RENAME TO media_jobs").Error + }) +} diff --git a/backend/internal/infra/persistence/relational/schema_runtime_settings_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_runtime_settings_upgrade_test.go new file mode 100644 index 0000000000000000000000000000000000000000..61fa1abde4d2960f11593269a45d6876bcb85bc2 --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_runtime_settings_upgrade_test.go @@ -0,0 +1,88 @@ +package relational + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" +) + +func TestSchemaBackfillsBuildResponseHeaderTimeout(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "runtime-settings.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + assertSchemaBackfillsBuildResponseHeaderTimeout(t, ctx, database) +} + +func TestPostgresSchemaBackfillsBuildResponseHeaderTimeout(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + ctx := context.Background() + database, err := OpenPostgres(ctx, dsn, 2, 1) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Where("key = ?", runtimeSettingsKey).Delete(&runtimeSettingsModel{}).Error; err != nil { + t.Fatal(err) + } + defer database.db.WithContext(ctx).Where("key = ?", runtimeSettingsKey).Delete(&runtimeSettingsModel{}) + assertSchemaBackfillsBuildResponseHeaderTimeout(t, ctx, database) +} + +func assertSchemaBackfillsBuildResponseHeaderTimeout(t *testing.T, ctx context.Context, database *Database) { + t.Helper() + legacy, err := json.Marshal(runtimeSettingsPayload{Config: settingsdomain.Config{ + ProviderBuild: settingsdomain.ProviderBuildConfig{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, + }}) + if err != nil { + t.Fatal(err) + } + updatedAt := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + row := runtimeSettingsModel{Key: runtimeSettingsKey, ValueJSON: string(legacy), Revision: 7, UpdatedAt: updatedAt} + if err := database.db.WithContext(ctx).Create(&row).Error; err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + var migrated runtimeSettingsModel + if err := database.db.WithContext(ctx).Where("key = ?", runtimeSettingsKey).First(&migrated).Error; err != nil { + t.Fatal(err) + } + var payload runtimeSettingsPayload + if err := json.Unmarshal([]byte(migrated.ValueJSON), &payload); err != nil { + t.Fatal(err) + } + if payload.Config.ProviderBuild.ResponseHeaderTimeout != settingsdomain.DefaultBuildResponseHeaderTimeout { + t.Fatalf("response header timeout = %s", payload.Config.ProviderBuild.ResponseHeaderTimeout) + } + if migrated.Revision != 7 || !migrated.UpdatedAt.Equal(updatedAt) { + t.Fatalf("migration changed revision metadata: revision=%d updatedAt=%s", migrated.Revision, migrated.UpdatedAt) + } + firstValue := migrated.ValueJSON + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Where("key = ?", runtimeSettingsKey).First(&migrated).Error; err != nil { + t.Fatal(err) + } + if migrated.ValueJSON != firstValue { + t.Fatal("repeated schema initialization rewrote the migrated settings") + } +} diff --git a/backend/internal/infra/persistence/relational/selector_segmented_integration_test.go b/backend/internal/infra/persistence/relational/selector_segmented_integration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..26ced901c4590c6dcc6772ba83fcedefdc868662 --- /dev/null +++ b/backend/internal/infra/persistence/relational/selector_segmented_integration_test.go @@ -0,0 +1,180 @@ +package relational_test + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/application/gateway" + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + redisruntime "github.com/chenyme/grok2api/backend/internal/infra/runtime/redis" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestPostgresRedisSegmentedSelectorIntegration(t *testing.T) { + postgresDSN := os.Getenv("TEST_POSTGRES_DSN") + redisAddress := os.Getenv("TEST_REDIS_ADDRESS") + if postgresDSN == "" || redisAddress == "" { + t.Skip("TEST_POSTGRES_DSN and TEST_REDIS_ADDRESS are required") + } + redisDatabase := 0 + if raw := os.Getenv("TEST_REDIS_DATABASE"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value < 0 { + t.Fatalf("TEST_REDIS_DATABASE = %q", raw) + } + redisDatabase = value + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + database, err := relational.OpenPostgres(ctx, postgresDSN, 30, 10) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Errorf("close PostgreSQL integration database: %v", err) + } + }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + values := make([]account.Credential, 3000) + identityPrefix := fmt.Sprintf("p4-%d", time.Now().UTC().UnixNano()) + for index := range values { + values[index] = account.Credential{ + Provider: account.ProviderBuild, Name: fmt.Sprintf("p4-%04d", index+1), + SourceKey: identityPrefix + "-" + strconv.Itoa(index+1), EncryptedAccessToken: "encrypted", + AuthStatus: account.AuthStatusActive, Priority: 1_000_000, MaxConcurrent: 1, + } + } + created, err := accounts.UpsertManyByIdentity(ctx, values) + if err != nil { + t.Fatal(err) + } + createdIDs := make([]uint64, len(created)) + for index, value := range created { + createdIDs[index] = value.ID + } + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + if _, err := accounts.DeleteMany(cleanupCtx, createdIDs); err != nil { + t.Errorf("delete P4 integration accounts: %v", err) + } + }) + loadedBases, err := accounts.ListRoutingAccountBases(ctx, account.ProviderBuild, "") + if err != nil { + t.Fatal(err) + } + bases := make([]account.RoutingAccountBase, 0, len(values)) + for _, value := range loadedBases { + if strings.HasPrefix(value.Credential.SourceKey, identityPrefix+"-") { + bases = append(bases, value) + } + } + if len(bases) != len(values) { + t.Fatalf("routing account count = %d, want %d", len(bases), len(values)) + } + + store, err := redisruntime.Open(ctx, redisruntime.Config{ + Address: redisAddress, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), Database: redisDatabase, + KeyPrefix: identityPrefix + ":", ConcurrencyLease: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + defer store.Close() + limiter := redisruntime.NewConcurrencyLimiter(store) + + newSelector := func() *gateway.Selector { + selector := gateway.NewSelector(accounts, limiter, store, nil, time.Hour, time.Second, time.Minute, 100*time.Millisecond) + selector.UpdateSegmentedSelector(true, 3000, 64) + return selector + } + saturate := func(t *testing.T, count int) { + t.Helper() + for index := 0; index < count; index++ { + release, acquired, err := limiter.Acquire(ctx, repository.AccountConcurrencyKey(bases[index].Credential.ID), 1) + if err != nil || !acquired { + t.Fatalf("saturate account %d: acquired=%v err=%v", bases[index].Credential.ID, acquired, err) + } + t.Cleanup(release) + } + } + + t.Run("continues to the next window", func(t *testing.T) { + saturate(t, 64) + lease, err := newSelector().Acquire(ctx, account.ProviderBuild, 0, "p4-model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != bases[64].Credential.ID { + t.Fatalf("selected account = %d, want %d", lease.Credential.ID, bases[64].Credential.ID) + } + }) + + t.Run("falls back after four saturated windows", func(t *testing.T) { + saturate(t, 256) + lease, err := newSelector().Acquire(ctx, account.ProviderBuild, 0, "p4-model", "", "", nil, false) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if lease.Credential.ID != bases[256].Credential.ID { + t.Fatalf("selected account = %d, want %d", lease.Credential.ID, bases[256].Credential.ID) + } + }) + + t.Run("shares atomic capacity across instances", func(t *testing.T) { + selectors := []*gateway.Selector{newSelector(), newSelector()} + const workers = 32 + type result struct { + accountID uint64 + release func() + err error + } + start := make(chan struct{}) + results := make(chan result, workers) + var wait sync.WaitGroup + for index := range workers { + wait.Add(1) + go func(selector *gateway.Selector) { + defer wait.Done() + <-start + lease, err := selector.Acquire(ctx, account.ProviderBuild, 0, "p4-model", "", "", nil, false) + if err != nil { + results <- result{err: err} + return + } + results <- result{accountID: lease.Credential.ID, release: lease.Release} + }(selectors[index%len(selectors)]) + } + close(start) + wait.Wait() + close(results) + selected := make(map[uint64]struct{}, workers) + for value := range results { + if value.err != nil { + t.Fatal(value.err) + } + t.Cleanup(value.release) + if _, exists := selected[value.accountID]; exists { + t.Fatalf("account %d exceeded shared concurrency capacity", value.accountID) + } + selected[value.accountID] = struct{}{} + } + if len(selected) != workers { + t.Fatalf("selected accounts = %d, want %d", len(selected), workers) + } + }) +} diff --git a/backend/internal/infra/persistence/relational/settings_repository.go b/backend/internal/infra/persistence/relational/settings_repository.go new file mode 100644 index 0000000000000000000000000000000000000000..bd553f764a034c54035abb9bcf2d2bfd18854567 --- /dev/null +++ b/backend/internal/infra/persistence/relational/settings_repository.go @@ -0,0 +1,85 @@ +package relational + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +const runtimeSettingsKey = "gateway" + +type runtimeSettingsPayload struct { + Config settingsdomain.Config `json:"config"` + EncryptedStatsigManualValue string `json:"encryptedStatsigManualValue,omitempty"` +} + +type RuntimeSettingsRepository struct { + database *Database + cipher *security.Cipher +} + +func NewRuntimeSettingsRepository(database *Database, cipher *security.Cipher) *RuntimeSettingsRepository { + return &RuntimeSettingsRepository{database: database, cipher: cipher} +} + +func (r *RuntimeSettingsRepository) Get(ctx context.Context) (settingsdomain.Config, time.Time, uint64, bool, error) { + var row runtimeSettingsModel + err := r.database.db.WithContext(ctx).Where("key = ?", runtimeSettingsKey).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return settingsdomain.Config{}, time.Time{}, 0, false, nil + } + if err != nil { + return settingsdomain.Config{}, time.Time{}, 0, false, err + } + var payload runtimeSettingsPayload + if err := json.Unmarshal([]byte(row.ValueJSON), &payload); err != nil { + return settingsdomain.Config{}, time.Time{}, 0, false, fmt.Errorf("解析运行设置: %w", err) + } + manualValue, err := r.cipher.Decrypt(payload.EncryptedStatsigManualValue) + if err != nil { + return settingsdomain.Config{}, time.Time{}, 0, false, fmt.Errorf("解密 Statsig 手动值: %w", err) + } + payload.Config.ProviderWeb.StatsigManualValue = manualValue + return payload.Config, row.UpdatedAt, row.Revision, true, nil +} + +func (r *RuntimeSettingsRepository) Save(ctx context.Context, value settingsdomain.Config, expectedRevision uint64) (time.Time, uint64, error) { + manualValue, err := r.cipher.Encrypt(value.ProviderWeb.StatsigManualValue) + if err != nil { + return time.Time{}, 0, fmt.Errorf("加密 Statsig 手动值: %w", err) + } + value.ProviderWeb.StatsigManualValue = "" + payload, err := json.Marshal(runtimeSettingsPayload{Config: value, EncryptedStatsigManualValue: manualValue}) + if err != nil { + return time.Time{}, 0, fmt.Errorf("编码运行设置: %w", err) + } + now := time.Now().UTC() + nextRevision := expectedRevision + 1 + if expectedRevision == 0 { + row := runtimeSettingsModel{Key: runtimeSettingsKey, ValueJSON: string(payload), Revision: nextRevision, UpdatedAt: now} + if err := r.database.db.WithContext(ctx).Create(&row).Error; err != nil { + if errors.Is(mapError(err), repository.ErrConflict) { + return time.Time{}, 0, repository.ErrConflict + } + return time.Time{}, 0, err + } + return now, nextRevision, nil + } + result := r.database.db.WithContext(ctx).Model(&runtimeSettingsModel{}). + Where("key = ? AND revision = ?", runtimeSettingsKey, expectedRevision). + Updates(map[string]any{"value_json": string(payload), "revision": nextRevision, "updated_at": now}) + if result.Error != nil { + return time.Time{}, 0, result.Error + } + if result.RowsAffected != 1 { + return time.Time{}, 0, repository.ErrConflict + } + return now, nextRevision, nil +} diff --git a/backend/internal/infra/persistence/relational/settings_repository_test.go b/backend/internal/infra/persistence/relational/settings_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5211a7dfbfed3a510bcadca55202fde756fb929d --- /dev/null +++ b/backend/internal/infra/persistence/relational/settings_repository_test.go @@ -0,0 +1,70 @@ +package relational + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + "github.com/chenyme/grok2api/backend/internal/infra/security" + repositorypkg "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestRuntimeSettingsRepositoryRoundTrip(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + repository := NewRuntimeSettingsRepository(database, cipher) + if _, _, _, found, err := repository.Get(ctx); err != nil || found { + t.Fatalf("initial get found = %v, err = %v", found, err) + } + settings := settingsdomain.Config{ + Server: settingsdomain.ServerConfig{MaxConcurrentRequests: 2048}, + ProviderWeb: settingsdomain.ProviderWebConfig{StatsigManualValue: "sensitive-statsig-value"}, + Media: settingsdomain.MediaConfig{ + MaxImageBytes: 16 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, + CleanupInterval: 10 * time.Minute, + }, + Routing: settingsdomain.RoutingConfig{StickyTTL: time.Hour, MaxAttempts: 3, PreferFreeBuild: true}, + } + updatedAt, revision, err := repository.Save(ctx, settings, 0) + if err != nil { + t.Fatal(err) + } + value, storedUpdatedAt, storedRevision, found, err := repository.Get(ctx) + if err != nil || !found { + t.Fatalf("saved get found = %v, err = %v", found, err) + } + if value.Server != settings.Server || value.Routing != settings.Routing || value.Media != settings.Media || !storedUpdatedAt.Equal(updatedAt) || revision != 1 || storedRevision != revision { + t.Fatalf("saved value = %#v", value) + } + if value.ProviderWeb.StatsigManualValue != settings.ProviderWeb.StatsigManualValue { + t.Fatalf("Statsig manual value = %q", value.ProviderWeb.StatsigManualValue) + } + var row runtimeSettingsModel + if err := database.db.WithContext(ctx).Where("key = ?", runtimeSettingsKey).First(&row).Error; err != nil { + t.Fatal(err) + } + if strings.Contains(row.ValueJSON, settings.ProviderWeb.StatsigManualValue) { + t.Fatalf("Statsig manual value was stored in plaintext: %s", row.ValueJSON) + } + if strings.Contains(row.ValueJSON, `"version"`) { + t.Fatalf("runtime settings contain version metadata: %s", row.ValueJSON) + } + if _, _, err := repository.Save(ctx, settings, 0); !errors.Is(err, repositorypkg.ErrConflict) { + t.Fatalf("stale repository revision error = %v", err) + } +} diff --git a/backend/internal/infra/persistence/relational/sort.go b/backend/internal/infra/persistence/relational/sort.go new file mode 100644 index 0000000000000000000000000000000000000000..4cd4b9552bfea34793e667965da1163a9a29b9b2 --- /dev/null +++ b/backend/internal/infra/persistence/relational/sort.go @@ -0,0 +1,49 @@ +package relational + +import ( + "strings" + + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +type sortSpec struct { + expression string + nullsLast bool + defaultDirection repository.SortDirection +} + +func applyStableSort(query *gorm.DB, sort repository.SortQuery, fields map[string]sortSpec, fallback sortSpec, idColumn string) *gorm.DB { + spec, ok := fields[sort.Field] + if !ok || strings.TrimSpace(spec.expression) == "" { + spec = fallback + } + direction := "ASC" + resolvedDirection := sort.Direction + if resolvedDirection != repository.SortAscending && resolvedDirection != repository.SortDescending { + resolvedDirection = spec.defaultDirection + } + if resolvedDirection == repository.SortDescending { + direction = "DESC" + } + if spec.nullsLast { + query = query.Order("CASE WHEN " + spec.expression + " IS NULL THEN 1 ELSE 0 END ASC") + } + return query.Order(spec.expression + " " + direction).Order(idColumn + " " + direction) +} + +func stableSortSpec(sort repository.SortQuery, fields map[string]sortSpec, fallback sortSpec) (sortSpec, string) { + spec, ok := fields[sort.Field] + if !ok || strings.TrimSpace(spec.expression) == "" { + spec = fallback + } + direction := "ASC" + resolvedDirection := sort.Direction + if resolvedDirection != repository.SortAscending && resolvedDirection != repository.SortDescending { + resolvedDirection = spec.defaultDirection + } + if resolvedDirection == repository.SortDescending { + direction = "DESC" + } + return spec, direction +} diff --git a/backend/internal/infra/persistence/relational/web_nsfw_test.go b/backend/internal/infra/persistence/relational/web_nsfw_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c7a3f9702aff3090a2e2f368fd0ff071307b0ebe --- /dev/null +++ b/backend/internal/infra/persistence/relational/web_nsfw_test.go @@ -0,0 +1,190 @@ +package relational + +import ( + "context" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestWebNSFWMarkerPersistsAcrossAccountUpserts(t *testing.T) { + t.Parallel() + ctx := context.Background() + database := openTestDatabase(t) + repo := NewAccountRepository(database) + credential, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + Name: "web", SourceKey: "web-nsfw", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if credential.WebNSFWEnabledAt != nil { + t.Fatalf("new account marker = %s", credential.WebNSFWEnabledAt) + } + + first := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + if err := repo.MarkWebNSFWEnabled(ctx, credential.ID, first); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, account.CurrentWebTermsVersion, first); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebBirthDateSet(ctx, credential.ID, first); err != nil { + t.Fatal(err) + } + marked, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if marked.WebNSFWEnabledAt == nil || !marked.WebNSFWEnabledAt.Equal(first) || marked.WebTermsAcceptedAt == nil || !marked.WebTermsAcceptedAt.Equal(first) || marked.WebTermsAcceptedVersion != account.CurrentWebTermsVersion || marked.WebBirthDateSetAt == nil || !marked.WebBirthDateSetAt.Equal(first) { + t.Fatalf("markers nsfw=%v terms=%v version=%d birth=%v, want %s", marked.WebNSFWEnabledAt, marked.WebTermsAcceptedAt, marked.WebTermsAcceptedVersion, marked.WebBirthDateSetAt, first) + } + + if err := repo.MarkWebNSFWEnabled(ctx, credential.ID, first.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, account.CurrentWebTermsVersion, first.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebBirthDateSet(ctx, credential.ID, first.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := repo.UpsertManyByIdentity(ctx, []account.Credential{{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + Name: "web renamed", SourceKey: "web-nsfw", EncryptedAccessToken: "encrypted-new", Enabled: true, AuthStatus: account.AuthStatusActive, + }}); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + refreshed, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if refreshed.WebNSFWEnabledAt == nil || !refreshed.WebNSFWEnabledAt.Equal(first) || refreshed.WebTermsAcceptedAt == nil || !refreshed.WebTermsAcceptedAt.Equal(first) || refreshed.WebTermsAcceptedVersion != account.CurrentWebTermsVersion || refreshed.WebBirthDateSetAt == nil || !refreshed.WebBirthDateSetAt.Equal(first) { + t.Fatalf("markers after upsert nsfw=%v terms=%v version=%d birth=%v, want first timestamp %s", refreshed.WebNSFWEnabledAt, refreshed.WebTermsAcceptedAt, refreshed.WebTermsAcceptedVersion, refreshed.WebBirthDateSetAt, first) + } +} + +func TestWebNSFWMarkerRejectsNonWebAccounts(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo := NewAccountRepository(openTestDatabase(t)) + credential, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, + Name: "build", SourceKey: "build-nsfw", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := repo.MarkWebNSFWEnabled(ctx, credential.ID, time.Now()); err == nil { + t.Fatal("expected non-Web marker rejection") + } + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, account.CurrentWebTermsVersion, time.Now()); err == nil { + t.Fatal("expected non-Web terms marker rejection") + } + if err := repo.MarkWebBirthDateSet(ctx, credential.ID, time.Now()); err == nil { + t.Fatal("expected non-Web birth marker rejection") + } +} + +func TestCurrentWebTermsVersionUpgradesLegacyMarker(t *testing.T) { + t.Parallel() + ctx := context.Background() + database := openTestDatabase(t) + repo := NewAccountRepository(database) + credential, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + Name: "legacy terms", SourceKey: "legacy-web-terms", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + legacyAt := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + if err := database.db.Model(&webAccountProfileModel{}).Where("account_id = ?", credential.ID). + Update("terms_accepted_at", legacyAt).Error; err != nil { + t.Fatal(err) + } + legacy, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if legacy.WebTermsAcceptedAt != nil || legacy.WebTermsAcceptedVersion != 0 { + t.Fatalf("legacy terms state = at:%v version:%d", legacy.WebTermsAcceptedAt, legacy.WebTermsAcceptedVersion) + } + currentAt := legacyAt.Add(time.Hour) + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, account.CurrentWebTermsVersion, currentAt); err != nil { + t.Fatal(err) + } + current, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if current.WebTermsAcceptedAt == nil || !current.WebTermsAcceptedAt.Equal(currentAt) || current.WebTermsAcceptedVersion != account.CurrentWebTermsVersion { + t.Fatalf("current terms state = at:%v version:%d", current.WebTermsAcceptedAt, current.WebTermsAcceptedVersion) + } +} + +func TestInitializeSchemaAddsWebNSFWMarkerColumn(t *testing.T) { + t.Parallel() + ctx := context.Background() + database := openTestDatabase(t) + repo := NewAccountRepository(database) + credential, _, err := repo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + Name: "legacy", SourceKey: "legacy-web-nsfw", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "NSFWEnabledAt"); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "TermsAcceptedAt"); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "TermsAcceptedVersion"); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "BirthDateSetAt"); err != nil { + t.Fatal(err) + } + if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "NSFWEnabledAt") { + t.Fatal("legacy schema still contains NSFW marker column") + } + if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "TermsAcceptedAt") { + t.Fatal("legacy schema still contains terms marker column") + } + if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "TermsAcceptedVersion") { + t.Fatal("legacy schema still contains terms version column") + } + if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "BirthDateSetAt") { + t.Fatal("legacy schema still contains birth marker column") + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "NSFWEnabledAt") { + t.Fatal("schema migration did not add NSFW marker column") + } + if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "TermsAcceptedAt") { + t.Fatal("schema migration did not add terms marker column") + } + if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "TermsAcceptedVersion") { + t.Fatal("schema migration did not add terms version column") + } + if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "BirthDateSetAt") { + t.Fatal("schema migration did not add birth marker column") + } + refreshed, err := repo.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if refreshed.ID != credential.ID || refreshed.WebNSFWEnabledAt != nil || refreshed.WebTermsAcceptedAt != nil || refreshed.WebBirthDateSetAt != nil { + t.Fatalf("migrated account = %#v", refreshed) + } +} diff --git a/backend/internal/infra/persistence/relational/web_state_repository_test.go b/backend/internal/infra/persistence/relational/web_state_repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f0a1fc7e69bc53a0ecd01c98b098ad0daee0d0ee --- /dev/null +++ b/backend/internal/infra/persistence/relational/web_state_repository_test.go @@ -0,0 +1,96 @@ +package relational + +import ( + "context" + "errors" + "testing" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestWebResponseStateAndMediaJobRoundTrip(t *testing.T) { + ctx := context.Background() + database := openTestDatabase(t) + accounts := NewAccountRepository(database) + accountValue, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, WebTier: accountdomain.WebTierBasic, + Name: "web", SourceKey: "web", EncryptedAccessToken: testEncryptedToken, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + key := clientKeyModel{Name: "key", Prefix: "prefix", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 60, MaxConcurrent: 4} + if err := database.db.WithContext(ctx).Create(&key).Error; err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + responses := NewResponseRepository(database) + state := inferencedomain.WebResponseState{ResponseID: "resp_web", AccountID: accountValue.ID, ConversationID: "conv", UpstreamParentResponseID: "parent", ResponseJSON: `{}`, Status: "completed", ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now} + if err := responses.SaveWebState(ctx, state); err != nil { + t.Fatal(err) + } + stored, err := responses.GetWebState(ctx, state.ResponseID, now) + if err != nil || stored.ConversationID != "conv" || stored.UpstreamParentResponseID != "parent" { + t.Fatalf("state = %#v, err = %v", stored, err) + } + + jobs := NewMediaJobRepository(database) + job := mediadomain.Job{ID: "video_test", RequestID: "request-video-test", ClientKeyID: key.ID, ClientKeyName: key.Name, AccountID: accountValue.ID, AccountName: accountValue.Name, Provider: "grok_web", Model: "grok-imagine-video", ModelRouteID: 1, UpstreamModel: "video", Prompt: "test", Seconds: 8, Size: "16:9", Quality: "720p", Status: mediadomain.StatusQueued, InputJSON: `{}`, CreatedAt: now, UpdatedAt: now} + if err := jobs.CreateMediaJob(ctx, job); err != nil { + t.Fatal(err) + } + recoverable, err := jobs.ListRecoverableMediaJobs(ctx, 10) + if err != nil || len(recoverable) != 1 || recoverable[0].ID != job.ID { + t.Fatalf("jobs = %#v, err = %v", recoverable, err) + } + claimed, ok, err := jobs.TryClaimMediaJob(ctx, job.ID, now, now.Add(time.Hour), "claim_token_0000000000000001") + if err != nil || !ok || claimed.Status != mediadomain.StatusInProgress || claimed.LeaseUntil == nil { + t.Fatalf("claimed job = %#v, ok = %v, err = %v", claimed, ok, err) + } + firstClaim := claimed + if _, ok, err := jobs.TryClaimMediaJob(ctx, job.ID, now.Add(time.Minute), now.Add(2*time.Hour), "claim_token_0000000000000002"); err != nil || ok { + t.Fatalf("active lease was claimed again: ok = %v, err = %v", ok, err) + } + claimed, ok, err = jobs.TryClaimMediaJob(ctx, job.ID, now.Add(2*time.Hour), now.Add(3*time.Hour), "claim_token_0000000000000003") + if err != nil || !ok || claimed.LeaseUntil == nil || !claimed.LeaseUntil.Equal(now.Add(3*time.Hour)) { + t.Fatalf("expired job claim = %#v, ok = %v, err = %v", claimed, ok, err) + } + firstClaim.Progress = 50 + if err := jobs.UpdateMediaJob(ctx, firstClaim); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stale claim update error = %v", err) + } + completedAt := now.Add(2*time.Hour + time.Minute) + claimed.Status, claimed.Progress, claimed.LeaseUntil, claimed.CompletedAt = mediadomain.StatusCompleted, 100, nil, &completedAt + if err := jobs.UpdateMediaJob(ctx, claimed); err != nil { + t.Fatal(err) + } + unrecorded, err := jobs.ListUnrecordedTerminalMediaJobs(ctx, 10) + if err != nil || len(unrecorded) != 1 || unrecorded[0].RequestID != job.RequestID || unrecorded[0].ModelRouteID != job.ModelRouteID { + t.Fatalf("unrecorded jobs = %#v, err = %v", unrecorded, err) + } + if err := jobs.MarkMediaJobUsageRecorded(ctx, job.ID, completedAt.Add(time.Second)); err != nil { + t.Fatal(err) + } + unrecorded, err = jobs.ListUnrecordedTerminalMediaJobs(ctx, 10) + if err != nil || len(unrecorded) != 0 { + t.Fatalf("recorded jobs = %#v, err = %v", unrecorded, err) + } + failedAt := completedAt.Add(2 * time.Second) + failed := job + failed.ID, failed.RequestID, failed.Status, failed.ErrorCode = "video_failed", "request-video-failed", mediadomain.StatusFailed, "generation_failed" + failed.Progress, failed.ClaimToken, failed.CompletedAt, failed.UpdatedAt = 50, "", &failedAt, failedAt + if err := jobs.CreateMediaJob(ctx, failed); err != nil { + t.Fatal(err) + } + unrecorded, err = jobs.ListUnrecordedTerminalMediaJobs(ctx, 10) + if err != nil || len(unrecorded) != 1 || unrecorded[0].ID != failed.ID { + t.Fatalf("failed unrecorded jobs = %#v, err = %v", unrecorded, err) + } + if err := jobs.MarkMediaJobUsageRecorded(ctx, failed.ID, failedAt.Add(time.Second)); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/infra/provider/account_block.go b/backend/internal/infra/provider/account_block.go new file mode 100644 index 0000000000000000000000000000000000000000..2b22ab90b5d693477dbc2400ebc238fde973c809 --- /dev/null +++ b/backend/internal/infra/provider/account_block.go @@ -0,0 +1,37 @@ +package provider + +import ( + "encoding/json" + "strings" +) + +// IsDefinitiveAccountBlockBody accepts only explicit error code or message signals. +func IsDefinitiveAccountBlockBody(body []byte) bool { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return IsDefinitiveAccountBlockText(string(body)) + } + values := []string{ + jsonStringField(payload, "code"), + jsonStringField(payload, "message"), + jsonStringField(payload, "error"), + } + if nested, ok := payload["error"].(map[string]any); ok { + values = append(values, + jsonStringField(nested, "code"), + jsonStringField(nested, "message"), + jsonStringField(nested, "error"), + ) + } + return IsDefinitiveAccountBlockText(strings.Join(values, " ")) +} + +func IsDefinitiveAccountBlockText(value string) bool { + value = strings.ToLower(value) + return strings.Contains(value, "blocked-user") || strings.Contains(value, "user is blocked") +} + +func jsonStringField(value map[string]any, key string) string { + result, _ := value[key].(string) + return result +} diff --git a/backend/internal/infra/provider/account_block_test.go b/backend/internal/infra/provider/account_block_test.go new file mode 100644 index 0000000000000000000000000000000000000000..978f14fdeedb0c07ab61a8334c373ac6788b6f54 --- /dev/null +++ b/backend/internal/infra/provider/account_block_test.go @@ -0,0 +1,26 @@ +package provider + +import "testing" + +func TestIsDefinitiveAccountBlockBody(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {name: "top-level message", body: `{"code":7,"message":"User is blocked [WKE=unauthorized:blocked-user]"}`, want: true}, + {name: "nested error", body: `{"error":{"code":7,"message":"User is blocked [WKE=unauthorized:blocked-user]"}}`, want: true}, + {name: "console error", body: `{"code":"unauthorized:blocked-user","error":"User is blocked"}`, want: true}, + {name: "plain text", body: `User is blocked`, want: true}, + {name: "generic rejection", body: `{"message":"temporary rejection"}`, want: false}, + {name: "unrelated details", body: `{"message":"temporary rejection","details":["blocked-user is an internal enum"]}`, want: false}, + {name: "numeric code only", body: `{"code":7,"message":"anti-bot rejection"}`, want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsDefinitiveAccountBlockBody([]byte(test.body)); got != test.want { + t.Fatalf("IsDefinitiveAccountBlockBody() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/backend/internal/infra/provider/browserheaders/chromium.go b/backend/internal/infra/provider/browserheaders/chromium.go new file mode 100644 index 0000000000000000000000000000000000000000..cdd8e9ee08209f0d36605e77cdbda0a4418a9451 --- /dev/null +++ b/backend/internal/infra/provider/browserheaders/chromium.go @@ -0,0 +1,66 @@ +package browserheaders + +import ( + "fmt" + "net/http" + "regexp" + "strconv" + "strings" +) + +var ( + chromiumVersionPattern = regexp.MustCompile(`(?i)\b(?:chrome|chromium|crios)/(\d{2,3})(?:\.\d+)*`) + edgeVersionPattern = regexp.MustCompile(`(?i)\b(?:edg|edga|edgios)/(\d{2,3})(?:\.\d+)*`) +) + +// ApplyChromiumClientHints 根据真实 User-Agent 补齐一致的 Chromium Client Hints。 +// 非 Chromium UA 不生成提示头,避免互相矛盾的浏览器指纹。 +func ApplyChromiumClientHints(header http.Header, userAgent string) { + lower := strings.ToLower(userAgent) + brand := "Google Chrome" + match := chromiumVersionPattern.FindStringSubmatch(userAgent) + if edge := edgeVersionPattern.FindStringSubmatch(userAgent); len(edge) == 2 { + brand, match = "Microsoft Edge", edge + } else if strings.Contains(lower, "chromium/") { + brand = "Chromium" + } + if len(match) != 2 { + return + } + version := match[1] + header.Set("Sec-Ch-Ua", fmt.Sprintf(`"%s";v="%s", "Chromium";v="%s", "Not(A:Brand";v="24"`, brand, version, version)) + + platform := "" + switch { + case strings.Contains(lower, "windows"): + platform = "Windows" + case strings.Contains(lower, "mac os x") || strings.Contains(lower, "macintosh"): + platform = "macOS" + case strings.Contains(lower, "android"): + platform = "Android" + case strings.Contains(lower, "iphone") || strings.Contains(lower, "ipad"): + platform = "iOS" + case strings.Contains(lower, "linux"): + platform = "Linux" + } + header.Set("Sec-Ch-Ua-Mobile", "?0") + if strings.Contains(lower, "mobile") || platform == "Android" || platform == "iOS" { + header.Set("Sec-Ch-Ua-Mobile", "?1") + } + header.Set("Sec-Ch-Ua-Model", "") + if platform != "" { + header.Set("Sec-Ch-Ua-Platform", strconv.Quote(platform)) + } + + arch := "" + switch { + case strings.Contains(lower, "aarch64") || strings.Contains(lower, "arm64") || strings.Contains(lower, " arm"): + arch = "arm" + case strings.Contains(lower, "x86_64") || strings.Contains(lower, "x64") || strings.Contains(lower, "win64") || strings.Contains(lower, "intel"): + arch = "x86" + } + if arch != "" { + header.Set("Sec-Ch-Ua-Arch", arch) + header.Set("Sec-Ch-Ua-Bitness", "64") + } +} diff --git a/backend/internal/infra/provider/cli/adapter.go b/backend/internal/infra/provider/cli/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..30988eeb0c9ba67106d7953d58215d180d0490a5 --- /dev/null +++ b/backend/internal/infra/provider/cli/adapter.go @@ -0,0 +1,940 @@ +package cli + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/reasoningreplay" +) + +type Config struct { + BaseURL string + FallbackBaseURL string + ClientVersion string + ClientIdentifier string + TokenAuth string + UserAgent string + ResponseHeaderTimeout time.Duration +} + +const ( + subscriptionTierTimeout = 10 * time.Second + buildControlTimeout = 30 * time.Second +) + +// Adapter implements the Grok Build CLI Responses, model, Billing, and OAuth protocols. +type Adapter struct { + cfgMu sync.RWMutex + cfg Config + http *http.Client + oauth *oauthClient + cipher *security.Cipher + base *buildDirectTransport + agentID string + modelsMu sync.Mutex + modelsETags map[uint64]string + fallbackMarker FallbackMarker + uploadIssuer VideoUploadIssuer + replay *reasoningreplay.ReasoningReplay + compaction *gatewayCompactionCodec + logger *slog.Logger +} + +func NewAdapter(cfg Config, cipher *security.Cipher) *Adapter { + cfg.ResponseHeaderTimeout = normalizeBuildResponseHeaderTimeout(cfg.ResponseHeaderTimeout) + transport := newBuildDirectTransport(cfg.ResponseHeaderTimeout) + httpClient := &http.Client{Transport: transport} + // The official CLI uses a persistent machine identity. The gateway does not collect machine fingerprints; + // instead each backend process generates one random UUID for its lifetime as the Agent identity. + agentID := uuid.NewString() + adapter := &Adapter{ + cfg: cfg, http: httpClient, cipher: cipher, base: transport, + agentID: agentID, modelsETags: make(map[uint64]string), compaction: newGatewayCompactionCodec(cipher), logger: slog.Default(), + } + adapter.oauth = newOAuthClient(httpClient, func() string { return adapter.config().ClientVersion }) + return adapter +} + +func (a *Adapter) SetLogger(logger *slog.Logger) { + if logger != nil { + a.logger = logger + } +} + +func (a *Adapter) SetEgress(manager *infraegress.Manager) { + if manager != nil { + a.http.Transport = &egressTransport{manager: manager, fallback: a.base} + } +} + +// SetReasoningReplay injects the optional server-side reasoning replay cache. +func (a *Adapter) SetReasoningReplay(replay *reasoningreplay.ReasoningReplay) { + a.replay = replay +} + +func (a *Adapter) Provider() account.Provider { return account.ProviderBuild } + +// CredentialMetadata extracts only non-sensitive risk flags from a Build access token. +// bot_flag_source must be JSON number 1; other values, malformed tokens, and decryption failures are not marked. +func (a *Adapter) CredentialMetadata(credential account.Credential) provider.CredentialMetadata { + if credential.Provider != account.ProviderBuild || a.cipher == nil || credential.EncryptedAccessToken == "" { + return provider.CredentialMetadata{} + } + accessToken, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return provider.CredentialMetadata{} + } + value, ok := decodeJWTClaims(accessToken)["bot_flag_source"].(float64) + return provider.CredentialMetadata{BuildBotFlagged: ok && value == 1} +} + +func (a *Adapter) UpdateConfig(cfg Config) { + cfg.ResponseHeaderTimeout = normalizeBuildResponseHeaderTimeout(cfg.ResponseHeaderTimeout) + a.cfgMu.Lock() + previousTimeout := a.cfg.ResponseHeaderTimeout + a.cfg = cfg + a.cfgMu.Unlock() + if previousTimeout != cfg.ResponseHeaderTimeout && a.base != nil { + a.base.UpdateResponseHeaderTimeout(cfg.ResponseHeaderTimeout) + } +} + +type buildDirectTransport struct { + current atomic.Pointer[http.Transport] +} + +func newBuildDirectTransport(responseHeaderTimeout time.Duration) *buildDirectTransport { + value := &buildDirectTransport{} + value.current.Store(newBuildHTTPTransport(responseHeaderTimeout)) + return value +} + +func (t *buildDirectTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return t.current.Load().RoundTrip(request) +} + +func (t *buildDirectTransport) UpdateResponseHeaderTimeout(responseHeaderTimeout time.Duration) { + next := newBuildHTTPTransport(responseHeaderTimeout) + previous := t.current.Swap(next) + if previous != nil { + previous.CloseIdleConnections() + } +} + +func newBuildHTTPTransport(responseHeaderTimeout time.Duration) *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, ForceAttemptHTTP2: true, + MaxIdleConns: 256, MaxIdleConnsPerHost: 128, MaxConnsPerHost: 256, + IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: normalizeBuildResponseHeaderTimeout(responseHeaderTimeout), + ExpectContinueTimeout: time.Second, + } +} + +func normalizeBuildResponseHeaderTimeout(value time.Duration) time.Duration { + if value <= 0 { + return settingsdomain.DefaultBuildResponseHeaderTimeout + } + return value +} + +func (a *Adapter) config() Config { + a.cfgMu.RLock() + defer a.cfgMu.RUnlock() + return a.cfg +} + +func (a *Adapter) ForwardResponse(ctx context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + accessToken, err := a.cipher.Decrypt(request.Credential.EncryptedAccessToken) + if err != nil { + return nil, err + } + body := request.Body + var toolCompatibility *responsesToolCompatibility + var conversationOptions conversation.ResponseOptions + cacheRoute := buildPromptCacheRoute{} + compactionRequested := false + if request.NormalizeBody { + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + body, conversationOptions, err = conversation.ConvertRequestWithOptions(body, request.Model, request.Operation) + } else { + var foreignCompactions int + body, foreignCompactions, err = expandGatewayCompactionHistory(body, a.compaction, request.PromptCacheKey) + if err != nil { + return invalidResponsesResponse(err), nil + } + body, toolCompatibility, err = normalizeResponsesRequest(body, request.Model) + if toolCompatibility != nil { + compactionRequested = toolCompatibility.compactionRequested + if foreignCompactions > 0 { + toolCompatibility.addWarning("foreign_compaction_omitted") + } + } + } + if err != nil { + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + return invalidConversationResponse(request.Operation, err), nil + } + return invalidResponsesResponse(err), nil + } + body, err = normalizeBuildReasoningEffort(body) + if err != nil { + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + return invalidConversationResponse(request.Operation, err), nil + } + return invalidResponsesResponse(err), nil + } + } + if request.Operation == conversation.OperationMessages && conversationOptions.AnthropicWebSearch { + request.ReasoningReplayKey = "" + } + if compactionRequested { + body, err = prepareGatewayCompactionSample(body) + if err != nil { + return invalidResponsesResponse(err), nil + } + } + if len(body) > 0 && request.Method == http.MethodPost { + if !compactionRequested { + allowClientTools := request.AllowClientToolCacheRoute || (account.RoutingCandidate{Credential: request.Credential, Billing: request.Billing}).IsKnownFreeBuild() + body, cacheRoute, err = prepareBuildPromptCacheRoute(body, request.Operation, request.Model, request.PromptCacheKey, allowClientTools) + if err != nil { + err = fmt.Errorf("准备 Build prompt cache 路由: %w", err) + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + return invalidConversationResponse(request.Operation, err), nil + } + return invalidResponsesResponse(err), nil + } + body, err = injectPromptCacheKey(body, request.PromptCacheKey) + if err != nil { + err = fmt.Errorf("写入 prompt_cache_key: %w", err) + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + return invalidConversationResponse(request.Operation, err), nil + } + return invalidResponsesResponse(err), nil + } + } + } + if compactionRequested { + warnings := "" + if toolCompatibility != nil { + warnings = toolCompatibility.warningHeader() + } + return a.forwardGatewayCompaction(ctx, request, accessToken, body, warnings) + } + // Explicit mode wins; in auto mode only confirmed Super accounts with bot_flag_source=1 default to XAI. + primaryBase := a.primaryBaseURL() + base := a.inferenceBaseForOperation(request.Credential, request.Billing, request.Method, request.Path) + // Cache affinity and reasoning replay use separate identities. Replay is also bound to the actual account and upstream plane, + // preventing opaque reasoning issued for one account or Build plane from reaching another scope. + replayBaseBody := body + body, replayKey := a.applyReasoningReplay(ctx, request, replayBaseBody, base) + resp, reqURL, err := a.doResponseRequest(ctx, request, accessToken, body, base) + if err != nil { + return nil, err + } + if err := normalizeGzipResponse(resp); err != nil { + return nil, err + } + resp, reqURL, reasoningRecovery := a.recoverReasoningDecodeFailure(ctx, request, accessToken, body, base, replayKey, resp, reqURL) + var recoveredPrimaryFailure *provider.DiagnosticResponse + // Only eligible operations probe XAI with an equivalent request after the Build primary explicitly returns 403. + if strings.EqualFold(base, primaryBase) && shouldProbeXAIInferenceFallback(request.Credential, request.Billing, request.Method, request.Path, resp.StatusCode) { + // Buffer the primary 403 body and replay it unchanged if fallback fails; never issue a second primary POST. + primaryBody, primaryTruncated, readErr := provider.ReadDiagnosticBody(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, readErr + } + primaryResp := cloneBufferedResponse(resp, primaryBody, primaryTruncated) + if shouldSkipXAIFallback(primaryBody) { + resp = primaryResp + } else { + fallbackBase := a.fallbackBaseURL() + if fallbackBase != "" && !strings.EqualFold(fallbackBase, base) { + fallbackBody, fallbackReplayKey := a.applyReasoningReplay(ctx, request, replayBaseBody, fallbackBase) + fallbackCtx := infraegress.WithPhysicalCallStage(ctx, "plane_fallback") + fallbackResp, fallbackURL, fallbackErr := a.doResponseRequest(fallbackCtx, request, accessToken, fallbackBody, fallbackBase) + if fallbackErr == nil { + fallbackErr = normalizeGzipResponse(fallbackResp) + } + fallbackRecovery := reasoningRecoveryOutcome{} + if fallbackErr == nil { + fallbackResp, fallbackURL, fallbackRecovery = a.recoverReasoningDecodeFailure(ctx, request, accessToken, fallbackBody, fallbackBase, fallbackReplayKey, fallbackResp, fallbackURL) + } + if fallbackErr == nil && isHTTPSuccess(fallbackResp.StatusCode) { + recoveredPrimaryFailure = bufferedFailureDiagnostic(primaryResp, primaryBody, primaryTruncated) + a.activateBuildAPIFallback(ctx, &request.Credential) + resp, reqURL, base, body, replayKey = fallbackResp, fallbackURL, fallbackBase, fallbackBody, fallbackReplayKey + reasoningRecovery = reasoningRecovery.merge(fallbackRecovery) + } else { + if fallbackErr == nil { + _ = fallbackResp.Body.Close() + } + // Preserve the original primary 403 URL and buffered body without requesting the primary again. + resp = primaryResp + } + } else { + resp = primaryResp + } + } + } + var rateLimit *provider.RateLimitMetadata + var rateLimitDiagnostic *provider.DiagnosticResponse + if resp.StatusCode == http.StatusTooManyRequests { + body, truncated, readErr := provider.ReadDiagnosticBody(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, readErr + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + rateLimit = provider.RateLimitFromResponse(resp.StatusCode, resp.Header, body) + if truncated { + rateLimitDiagnostic = &provider.DiagnosticResponse{ + StatusCode: resp.StatusCode, Status: resp.Status, Header: resp.Header.Clone(), + Body: append([]byte(nil), body...), BodyTruncated: true, + } + } + } + modelCatalogChanged := a.modelCatalogChanged(request.Credential.ID, resp.Header.Get("x-models-etag")) + // Capture or clear reasoning replay in the upstream Responses shape before protocol conversion. + if a.shouldCaptureReplay(request, resp, replayKey) { + resp.Body = a.replay.CaptureBody(resp.Body, request.Model, replayKey, request.Streaming, isCompactPath(request.Path)) + } + // Replay must read the raw upstream output. Hide xAI native search subcalls from downstream clients + // only after capture wrapping has completed. + if isHTTPSuccess(resp.StatusCode) { + if err := filterBuildPromptCacheResponse(resp, request.Streaming, cacheRoute); err != nil { + return nil, err + } + } + responsesOperation := request.Operation == "" || request.Operation == conversation.OperationResponses || request.Operation == conversation.OperationCompaction + if responsesOperation && toolCompatibility != nil { + if warnings := toolCompatibility.warningHeader(); warnings != "" { + resp.Header.Set("X-Grok2API-Compatibility-Warnings", warnings) + } + } + reasoningRecovery.appendWarnings(resp.Header) + if responsesOperation && toolCompatibility != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { + if request.Streaming { + resp.Body = toolCompatibility.normalizeResponseStream(resp.Body) + resp.Header.Del("Content-Length") + resp.Header.Set("Content-Type", "text/event-stream") + } else { + data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxCompatibleResponseBytes+1)) + _ = resp.Body.Close() + if readErr != nil { + return nil, readErr + } + if len(data) > maxCompatibleResponseBytes { + return nil, fmt.Errorf("上游兼容 Responses 响应超过 128 MiB") + } + converted, convertErr := toolCompatibility.normalizeResponseJSON(data) + if convertErr != nil { + return nil, convertErr + } + resp.Body = io.NopCloser(bytes.NewReader(converted)) + resp.Header.Set("Content-Length", strconv.Itoa(len(converted))) + resp.Header.Set("Content-Type", "application/json") + } + } + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + if request.Streaming && resp.StatusCode >= 200 && resp.StatusCode < 300 { + resp.Body = conversation.ConvertResponseStreamWithOptions(resp.Body, request.Operation, conversationOptions) + resp.Header.Del("Content-Length") + resp.Header.Set("Content-Type", "text/event-stream") + } else { + var data []byte + var readErr error + var diagnosticTruncated bool + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + data, readErr = io.ReadAll(io.LimitReader(resp.Body, (64<<20)+1)) + } else { + data, diagnosticTruncated, readErr = provider.ReadDiagnosticBody(resp.Body) + } + _ = resp.Body.Close() + if readErr != nil { + return nil, readErr + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(data) > 64<<20 { + return nil, fmt.Errorf("上游对话响应超过 64 MiB") + } + var diagnostic *provider.DiagnosticResponse + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + diagnostic = &provider.DiagnosticResponse{StatusCode: resp.StatusCode, Status: resp.Status, Header: resp.Header.Clone(), Body: data, BodyTruncated: diagnosticTruncated || rateLimitDiagnostic != nil} + } + converted, convertErr := conversation.ConvertResponseJSONWithOptions(data, request.Operation, conversationOptions) + if convertErr != nil { + if diagnostic == nil { + return nil, convertErr + } + return &provider.Response{StatusCode: resp.StatusCode, Status: resp.Status, Header: diagnostic.Header.Clone(), Body: io.NopCloser(bytes.NewReader(data)), UpstreamURL: reqURL, Diagnostic: diagnostic, RecoveredPrimaryFailure: recoveredPrimaryFailure, RateLimit: rateLimit, ModelCatalogChanged: modelCatalogChanged}, nil + } + resp.Body = io.NopCloser(bytes.NewReader(converted)) + resp.Header.Set("Content-Length", strconv.Itoa(len(converted))) + resp.Header.Set("Content-Type", "application/json") + return &provider.Response{StatusCode: resp.StatusCode, Status: resp.Status, Header: resp.Header.Clone(), Body: resp.Body, UpstreamURL: reqURL, Diagnostic: diagnostic, RecoveredPrimaryFailure: recoveredPrimaryFailure, RateLimit: rateLimit, ModelCatalogChanged: modelCatalogChanged}, nil + } + } + return &provider.Response{StatusCode: resp.StatusCode, Status: resp.Status, Header: resp.Header.Clone(), Body: resp.Body, UpstreamURL: reqURL, Diagnostic: rateLimitDiagnostic, RecoveredPrimaryFailure: recoveredPrimaryFailure, RateLimit: rateLimit, ModelCatalogChanged: modelCatalogChanged}, nil +} + +func (a *Adapter) shouldCaptureReplay(request provider.ResponseResourceRequest, resp *http.Response, replayKey string) bool { + if a.replay == nil || !a.replay.Enabled() || resp == nil { + return false + } + if request.Method != http.MethodPost || strings.TrimSpace(replayKey) == "" { + return false + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false + } + return true +} + +func (a *Adapter) applyReasoningReplay(ctx context.Context, request provider.ResponseResourceRequest, body []byte, base string) ([]byte, string) { + if a.replay == nil || !a.replay.Enabled() || request.Method != http.MethodPost { + return body, "" + } + key := a.scopedReasoningReplayKey(request, base) + if key == "" { + return body, "" + } + if isCompactPath(request.Path) { + // compact does not inject history, but a successful request still clears old replay in the same scope. + return body, key + } + return a.replay.Apply(ctx, request.Model, key, body), key +} + +func (a *Adapter) scopedReasoningReplayKey(request provider.ResponseResourceRequest, base string) string { + seed := strings.TrimSpace(request.ReasoningReplayKey) + if seed == "" || request.Credential.ID == 0 { + return "" + } + plane := "build" + if fallback := a.fallbackBaseURL(); fallback != "" && strings.EqualFold(strings.TrimRight(base, "/"), fallback) { + plane = "xai" + } + digest := sha256.Sum256([]byte(fmt.Sprintf("grok2api:reasoning-replay:v2:%s:%d:%s", seed, request.Credential.ID, plane))) + return hex.EncodeToString(digest[:]) +} + +func isCompactPath(path string) bool { + return strings.Contains(strings.ToLower(path), "compact") +} + +func (a *Adapter) doResponseRequest(ctx context.Context, request provider.ResponseResourceRequest, accessToken string, body []byte, base string) (*http.Response, string, error) { + var bodyReader io.Reader + if len(body) > 0 { + bodyReader = bytes.NewReader(body) + } + requestCtx := infraegress.WithCredential(ctx, request.Credential) + plane := "build" + if fallback := a.fallbackBaseURL(); fallback != "" && strings.EqualFold(strings.TrimRight(base, "/"), fallback) { + plane = "xai" + } + requestCtx = infraegress.WithPhysicalCallPlane(requestCtx, plane) + req, err := http.NewRequestWithContext(requestCtx, request.Method, a.urlWithBase(base, request.Path), bodyReader) + if err != nil { + return nil, "", err + } + if err := a.applyHeaders(req, request.Credential, accessToken, request.Model, request.PromptCacheKey, true); err != nil { + return nil, "", err + } + applyGrokTurnIndexHeader(req, request.GrokTurnIndex) + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + if request.Streaming { + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Accept-Encoding", "identity") + } else { + req.Header.Set("Accept", "application/json") + } + if request.IdempotencyID != "" { + req.Header.Set("Idempotency-Key", request.IdempotencyID) + } + resp, err := a.http.Do(req) + if err != nil { + return nil, "", err + } + return resp, req.URL.String(), nil +} + +// applyGrokTurnIndexHeader forwards a real client turn only when the request has a stable Grok session. +func applyGrokTurnIndexHeader(request *http.Request, value string) { + if request.Header.Get("x-grok-session-id") == "" { + return + } + if turnIndex := normalizeGrokTurnIndex(value); turnIndex != "" { + request.Header.Set("x-grok-turn-idx", turnIndex) + } +} + +// normalizeGrokTurnIndex accepts only non-negative decimal u64 values generated by an official client. +// Empty or invalid values are omitted; the gateway never fabricates turns from history, tool loops, or compaction. +func normalizeGrokTurnIndex(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > 20 { + return "" + } + for _, char := range value { + if char < '0' || char > '9' { + return "" + } + } + if _, err := strconv.ParseUint(value, 10, 64); err != nil { + return "" + } + return value +} + +// invalidResponsesResponse converts local protocol validation errors to a standard OpenAI error response, +// avoiding an upstream account retry. +func invalidResponsesResponse(err error) *provider.Response { + code := "invalid_request" + param := "" + message := err.Error() + var requestErr *responsesRequestError + if errors.As(err, &requestErr) { + code = requestErr.Code + param = requestErr.Param + message = requestErr.Message + } + errorBody := map[string]any{"type": "invalid_request_error", "message": message, "code": code} + if param != "" { + errorBody["param"] = param + } + data, _ := json.Marshal(map[string]any{"error": errorBody}) + return &provider.Response{ + StatusCode: http.StatusBadRequest, Status: "400 Bad Request", + Header: http.Header{"Content-Type": []string{"application/json"}, "Content-Length": []string{strconv.Itoa(len(data))}}, + Body: io.NopCloser(bytes.NewReader(data)), + } +} + +func invalidConversationResponse(operation string, err error) *provider.Response { + var payload any = map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": err.Error()}} + if operation == conversation.OperationMessages { + payload = map[string]any{"type": "error", "error": map[string]any{"type": "invalid_request_error", "message": err.Error()}} + } + data, _ := json.Marshal(payload) + return &provider.Response{ + StatusCode: http.StatusBadRequest, Status: "400 Bad Request", + Header: http.Header{"Content-Type": []string{"application/json"}, "Content-Length": []string{strconv.Itoa(len(data))}}, + Body: io.NopCloser(bytes.NewReader(data)), + } +} + +func (a *Adapter) ListModels(ctx context.Context, credential account.Credential) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, buildControlTimeout) + defer cancel() + accessToken, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return nil, err + } + // Always request the model catalog from the Build primary; do not preemptively switch to XAI because 1.5 or Super entitlement is absent. + // NormalizeAccountModelCapabilities fills 1.5 locally from Billing paid or BuildSuperEntitled. + models, status, err := a.listModelsAt(ctx, credential, accessToken, a.primaryBaseURL()) + if err != nil { + return nil, err + } + if models != nil { + return models, nil + } + return nil, fmt.Errorf("上游模型接口返回 %d", status) +} + +// NormalizeAccountModelCapabilities normalizes 1.5 video entitlement from Super (Billing paid or BuildSuperEntitled). +// Super always includes grok-imagine-video-1.5; Free and Unknown remove it exactly. BuildAPIFallback is ignored. +func (a *Adapter) NormalizeAccountModelCapabilities(models []string, billing *account.Billing, credential account.Credential) []string { + super := account.IsBuildSuper(credential, billing) + result := make([]string, 0, len(models)+1) + seen := make(map[string]struct{}, len(models)+1) + hasVideo15 := false + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" { + continue + } + if _, exists := seen[model]; exists { + continue + } + if model == buildVideoModel { + if !super { + continue + } + hasVideo15 = true + } + seen[model] = struct{}{} + result = append(result, model) + } + if super && !hasVideo15 { + result = append(result, buildVideoModel) + } + return result +} + +func (a *Adapter) listModelsAt(ctx context.Context, credential account.Credential, accessToken, base string) ([]string, int, error) { + requestCtx := infraegress.WithCredential(ctx, credential) + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, a.urlWithBase(base, "/models"), nil) + if err != nil { + return nil, 0, err + } + if err := a.applyHeaders(req, credential, accessToken, "", "", false); err != nil { + return nil, 0, err + } + resp, err := a.http.Do(req) + if err != nil { + return nil, 0, err + } + if err := normalizeGzipResponse(resp); err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, 0, err + } + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, nil + } + var payload struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, resp.StatusCode, err + } + models := make([]string, 0, len(payload.Data)) + for _, item := range payload.Data { + if item.ID != "" { + models = append(models, item.ID) + } + } + a.recordModelsETag(credential.ID, resp.Header.Get("ETag")) + return models, resp.StatusCode, nil +} + +func (a *Adapter) recordModelsETag(accountID uint64, etag string) { + etag = strings.TrimSpace(etag) + if etag == "" { + return + } + a.modelsMu.Lock() + if a.modelsETags == nil { + a.modelsETags = make(map[uint64]string) + } + a.modelsETags[accountID] = etag + a.modelsMu.Unlock() +} + +func (a *Adapter) modelCatalogChanged(accountID uint64, etag string) bool { + etag = strings.TrimSpace(etag) + if etag == "" { + return false + } + a.modelsMu.Lock() + defer a.modelsMu.Unlock() + if a.modelsETags == nil { + a.modelsETags = make(map[uint64]string) + } + current := a.modelsETags[accountID] + if current == "" { + // After a process restart there is no in-memory catalog baseline. Let the Gateway perform one account-level + // /models sync; recordModelsETag establishes the baseline after success. + return true + } + return current != etag +} + +func (a *Adapter) GetBilling(ctx context.Context, credential account.Credential) (account.Billing, error) { + ctx, cancel := context.WithTimeout(ctx, buildControlTimeout) + defer cancel() + accessToken, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return account.Billing{}, err + } + billing, err := a.getBilling(ctx, credential, accessToken, "format=credits") + if err != nil { + return account.Billing{}, err + } + // A weekly quota at 0% usage cannot distinguish Free from a newly activated paid plan. + // The official CLI uses /user?include=subscription for the live subscription tier, then falls back to the JWT tier. + if tier, tierErr := a.getSubscriptionTier(ctx, credential, accessToken); tierErr == nil && tier != "" { + billing.PlanName = tier + } else if billing.PlanCode == "" && billing.PlanName == "" { + billing.PlanName = subscriptionTierFromJWT(accessToken) + } + billing.AccountID = credential.ID + billing.SyncedAt = time.Now().UTC() + return billing, nil +} + +func (a *Adapter) RefreshCredential(ctx context.Context, credential account.Credential) (provider.RefreshedCredential, error) { + ctx, cancel := context.WithTimeout(ctx, buildControlTimeout) + defer cancel() + refreshToken, err := a.cipher.Decrypt(credential.EncryptedRefreshToken) + if err != nil { + // Decryption failures are usually temporary or mismatched local encryption keys and are recoverable; + // do not mark them permanent, or manual/batch refresh will never retry after the key is fixed. + // True permanent OAuth failures such as invalid_grant are returned by oauth.refresh with Permanent=true. + return provider.RefreshedCredential{}, &provider.CredentialRefreshError{Code: "credential_decrypt_failed", Message: "Stored refresh credential could not be decrypted", Permanent: false, Cause: err} + } + if strings.TrimSpace(refreshToken) == "" { + return provider.RefreshedCredential{}, &provider.CredentialRefreshError{Code: "missing_refresh_token", Message: "Refresh token is missing", Permanent: true} + } + refreshCtx := infraegress.WithCredential(ctx, credential) + tokens, err := a.oauth.refresh(refreshCtx, refreshToken) + if err != nil { + return provider.RefreshedCredential{}, err + } + accessEncrypted, err := a.cipher.Encrypt(tokens.AccessToken) + if err != nil { + return provider.RefreshedCredential{}, err + } + refreshEncrypted, err := a.cipher.Encrypt(tokens.RefreshToken) + if err != nil { + return provider.RefreshedCredential{}, err + } + return provider.RefreshedCredential{EncryptedAccessToken: accessEncrypted, EncryptedRefreshToken: refreshEncrypted, ExpiresAt: tokens.ExpiresAt}, nil +} + +func (a *Adapter) StartDeviceAuthorization(ctx context.Context) (provider.DeviceAuthorization, error) { + ctx, cancel := context.WithTimeout(ctx, buildControlTimeout) + defer cancel() + return a.oauth.startDevice(ctx) +} + +func (a *Adapter) PollDeviceAuthorization(ctx context.Context, deviceCode string) (provider.CredentialSeed, error) { + ctx, cancel := context.WithTimeout(ctx, buildControlTimeout) + defer cancel() + tokens, err := a.oauth.pollDevice(ctx, deviceCode) + if err != nil { + return provider.CredentialSeed{}, err + } + claims := decodeJWTClaims(firstNonEmpty(tokens.IDToken, tokens.AccessToken)) + userID := stringClaim(claims, "sub") + email := stringClaim(claims, "email") + return provider.CredentialSeed{Name: firstNonEmpty(email, userID, "Grok Build account"), Email: email, UserID: userID, TeamID: stringClaim(claims, "team_id"), OIDCClientID: defaultOAuthClientID, AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, ExpiresAt: tokens.ExpiresAt}, nil +} + +func (a *Adapter) ParseImportedCredentials(data []byte) ([]provider.CredentialSeed, error) { + return parseImportedCredentials(data) +} + +func (a *Adapter) MarshalCredentials(values []provider.CredentialSeed) ([]byte, error) { + return marshalCredentials(values) +} + +func (a *Adapter) applyHeaders(req *http.Request, credential account.Credential, accessToken, model, promptCacheKey string, trace bool) error { + cfg := a.config() + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("X-XAI-Token-Auth", cfg.TokenAuth) + req.Header.Set("x-grok-client-version", cfg.ClientVersion) + req.Header.Set("x-grok-client-identifier", cfg.ClientIdentifier) + req.Header.Set("x-grok-client-mode", "headless") + + if trace { + requestID := uuid.NewString() + // Set x-grok-conv-id and session-id only when a stable session exists. + // Never generate a random UUID per request; it breaks xAI session affinity and keeps cached_tokens at zero. + sessionID, err := grokSessionID(promptCacheKey) + if err != nil { + return err + } + req.Header.Set("x-authenticateresponse", "authenticate-response") + req.Header.Set("x-grok-agent-id", a.agentID) + if sessionID != "" { + req.Header.Set("x-grok-session-id", sessionID) + req.Header.Set("x-grok-conv-id", sessionID) + } + req.Header.Set("x-grok-req-id", requestID) + // The gateway cannot reliably recover the CLI prompt index from a stateless API request. + // The field is optional in the official protocol, so do not fabricate x-grok-turn-idx. + if credential.UserID != "" { + req.Header.Set("x-grok-user-id", credential.UserID) + } + traceID, traceErr := randomHex(16) + if traceErr != nil { + return traceErr + } + spanID, spanErr := randomHex(8) + if spanErr != nil { + return spanErr + } + req.Header.Set("traceparent", "00-"+traceID+"-"+spanID+"-01") + } else { + if credential.UserID != "" { + req.Header.Set("x-userid", credential.UserID) + } + if credential.Email != "" { + req.Header.Set("x-email", credential.Email) + } + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("User-Agent", cfg.UserAgent) + if model != "" { + req.Header.Set("x-grok-model-override", model) + } + return nil +} + +// grokSessionID converts a stable session key to the upstream x-grok-conv-id. +// An empty key returns an empty string; stateless requests never receive a random ID. +func grokSessionID(promptCacheKey string) (string, error) { + key := strings.TrimSpace(promptCacheKey) + if key == "" { + return "", nil + } + if parsed, err := uuid.Parse(key); err == nil { + return parsed.String(), nil + } + return uuid.NewHash(sha256.New(), uuid.NameSpaceURL, []byte("grok2api:session:"+key), 8).String(), nil +} + +func injectPromptCacheKey(body []byte, clientKey string) ([]byte, error) { + key := strings.TrimSpace(clientKey) + if key == "" { + return body, nil + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if payload == nil { + payload = make(map[string]json.RawMessage) + } + payload["prompt_cache_key"] = mustJSON(key) + return json.Marshal(payload) +} + +func randomHex(bytesLength int) (string, error) { + value := make([]byte, bytesLength) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +func normalizeGzipResponse(response *http.Response) error { + if response == nil || response.Body == nil || !strings.EqualFold(strings.TrimSpace(response.Header.Get("Content-Encoding")), "gzip") { + return nil + } + reader, err := gzip.NewReader(response.Body) + if err != nil { + _ = response.Body.Close() + return err + } + response.Body = &gzipResponseBody{Reader: reader, source: response.Body} + response.Header.Del("Content-Encoding") + response.Header.Del("Content-Length") + response.ContentLength = -1 + return nil +} + +type gzipResponseBody struct { + *gzip.Reader + source io.Closer +} + +func (b *gzipResponseBody) Close() error { + readerErr := b.Reader.Close() + sourceErr := b.source.Close() + if readerErr != nil { + return readerErr + } + return sourceErr +} + +func (a *Adapter) url(path string) string { + return strings.TrimRight(a.config().BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (a *Adapter) getBilling(ctx context.Context, credential account.Credential, accessToken, query string) (account.Billing, error) { + endpoint := a.url("/billing") + if query != "" { + endpoint += "?" + query + } + requestCtx := infraegress.WithCredential(ctx, credential) + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint, nil) + if err != nil { + return account.Billing{}, err + } + if err := a.applyHeaders(req, credential, accessToken, "", "", false); err != nil { + return account.Billing{}, err + } + resp, err := a.http.Do(req) + if err != nil { + return account.Billing{}, err + } + if err := normalizeGzipResponse(resp); err != nil { + return account.Billing{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return account.Billing{}, err + } + if resp.StatusCode != http.StatusOK { + return account.Billing{}, fmt.Errorf("上游 Billing 接口返回 %d", resp.StatusCode) + } + return parseBilling(body) +} + +func (a *Adapter) getSubscriptionTier(ctx context.Context, credential account.Credential, accessToken string) (string, error) { + endpoint := a.url("/user") + "?include=subscription" + requestCtx, cancel := context.WithTimeout(infraegress.WithCredential(ctx, credential), subscriptionTierTimeout) + defer cancel() + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + if err := a.applyHeaders(req, credential, accessToken, "", "", false); err != nil { + return "", err + } + resp, err := a.http.Do(req) + if err != nil { + return "", err + } + if err := normalizeGzipResponse(resp); err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("上游订阅接口返回 %d", resp.StatusCode) + } + return parseSubscriptionTier(body) +} diff --git a/backend/internal/infra/provider/cli/adapter_test.go b/backend/internal/infra/provider/cli/adapter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..df0aa3e90f9a2af9cbe68ced815fc78c5ee5c2e0 --- /dev/null +++ b/backend/internal/infra/provider/cli/adapter_test.go @@ -0,0 +1,1003 @@ +package cli + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/reasoningreplay" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestAdapterHotUpdatesDirectResponseHeaderTimeout(t *testing.T) { + adapter := NewAdapter(Config{ResponseHeaderTimeout: 2 * time.Minute}, nil) + before := adapter.base.current.Load() + if before.ResponseHeaderTimeout != 2*time.Minute { + t.Fatalf("initial timeout = %s", before.ResponseHeaderTimeout) + } + adapter.UpdateConfig(Config{ResponseHeaderTimeout: 7 * time.Minute}) + after := adapter.base.current.Load() + if after == before || after.ResponseHeaderTimeout != 7*time.Minute { + t.Fatalf("updated transport=%p timeout=%s", after, after.ResponseHeaderTimeout) + } +} + +func TestCredentialMetadataMarksOnlyNumericBotFlagOne(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{}, cipher) + tests := []struct { + name string + provider account.Provider + claims map[string]any + token string + want bool + }{ + {name: "numeric one", provider: account.ProviderBuild, claims: map[string]any{"bot_flag_source": 1}, want: true}, + {name: "numeric zero", provider: account.ProviderBuild, claims: map[string]any{"bot_flag_source": 0}}, + {name: "numeric two", provider: account.ProviderBuild, claims: map[string]any{"bot_flag_source": 2}}, + {name: "string one", provider: account.ProviderBuild, claims: map[string]any{"bot_flag_source": "1"}}, + {name: "missing claim", provider: account.ProviderBuild, claims: map[string]any{"sub": "user"}}, + {name: "malformed jwt", provider: account.ProviderBuild, token: "not-a-jwt"}, + {name: "non build", provider: account.ProviderWeb, claims: map[string]any{"bot_flag_source": 1}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + token := test.token + if test.claims != nil { + payload, marshalErr := json.Marshal(test.claims) + if marshalErr != nil { + t.Fatal(marshalErr) + } + token = "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + } + encrypted, encryptErr := cipher.Encrypt(token) + if encryptErr != nil { + t.Fatal(encryptErr) + } + metadata := adapter.CredentialMetadata(account.Credential{Provider: test.provider, EncryptedAccessToken: encrypted}) + if metadata.BuildBotFlagged != test.want { + t.Fatalf("flagged = %t, want %t", metadata.BuildBotFlagged, test.want) + } + }) + } + + metadata := adapter.CredentialMetadata(account.Credential{Provider: account.ProviderBuild, EncryptedAccessToken: "invalid-ciphertext"}) + if metadata.BuildBotFlagged { + t.Fatal("decrypt failure must not mark the account") + } +} + +func TestForwardResponseMatchesGrokBuildHeadersAndPreservesReasoning(t *testing.T) { + var captured map[string]any + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/responses" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer access-token" || r.Header.Get("X-XAI-Token-Auth") != "xai-grok-cli" || r.Header.Get("x-authenticateresponse") != "authenticate-response" || r.Header.Get("x-grok-client-version") != "0.2.102" || r.Header.Get("x-grok-client-identifier") != "grok-shell" || r.Header.Get("x-grok-client-mode") != "headless" || r.Header.Get("User-Agent") != "grok-shell/0.2.102 (linux; x86_64)" { + t.Fatalf("headers = %#v", r.Header) + } + requestID := r.Header.Get("x-grok-req-id") + sessionID := r.Header.Get("x-grok-session-id") + expectedSessionID, err := grokSessionID("isolated-key") + if err != nil { + t.Fatal(err) + } + requestUUID, requestErr := uuid.Parse(requestID) + agentUUID, agentErr := uuid.Parse(r.Header.Get("x-grok-agent-id")) + if requestErr != nil || requestUUID.Version() != uuid.Version(4) || agentErr != nil || agentUUID.Version() != uuid.Version(4) || sessionID != expectedSessionID || r.Header.Get("x-grok-conv-id") != sessionID { + t.Fatalf("client identity headers = %#v", r.Header) + } + for _, legacy := range []string{"x-grok-client-surface", "x-grok-client-name", "x-grok-conversation-id", "x-grok-session-id-legacy", "x-grok-request-id"} { + if r.Header.Get(legacy) != "" { + t.Fatalf("legacy header %s = %q", legacy, r.Header.Get(legacy)) + } + } + if r.Header.Get("x-grok-user-id") != "user-123" || r.Header.Get("x-grok-turn-idx") != "7" || r.Header.Get("x-userid") != "" || r.Header.Get("Accept-Encoding") != "gzip" || len(r.Header.Get("traceparent")) != 55 { + t.Fatalf("protocol headers = %#v", r.Header) + } + if _, ok := r.Header["Tracestate"]; ok { + t.Fatalf("tracestate = %#v", r.Header["Tracestate"]) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatal(err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_1","object":"response"}`)), + Request: r, + }, nil + }) + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1", ClientVersion: "0.2.102", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", UserAgent: "grok-shell/0.2.102 (linux; x86_64)"}, cipher) + adapter.http.Transport = transport + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 7, UserID: "user-123", EncryptedAccessToken: encrypted}, Method: http.MethodPost, Path: "/responses", + Model: "grok-4.5", PromptCacheKey: "isolated-key", GrokTurnIndex: "7", NormalizeBody: true, + Body: []byte(`{"model":"public","prompt_cache_key":"client-key","input":[{"type":"reasoning","id":"rs_1","encrypted_content":"cipher"}]}`), + }) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + input := captured["input"].([]any) + if captured["model"] != "grok-4.5" || captured["prompt_cache_key"] != "isolated-key" || len(input) != 1 || input[0].(map[string]any)["type"] != "reasoning" || input[0].(map[string]any)["encrypted_content"] != "cipher" { + t.Fatalf("captured = %#v", captured) + } +} + +func TestNormalizeGrokTurnIndex(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "missing"}, + {name: "zero", value: "0", want: "0"}, + {name: "positive", value: "42", want: "42"}, + {name: "trimmed", value: " 7 ", want: "7"}, + {name: "max uint64", value: "18446744073709551615", want: "18446744073709551615"}, + {name: "negative", value: "-1"}, + {name: "explicit positive", value: "+1"}, + {name: "decimal", value: "1.0"}, + {name: "overflow", value: "18446744073709551616"}, + {name: "too long", value: "000000000000000000001"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := normalizeGrokTurnIndex(test.value); got != test.want { + t.Fatalf("turn index = %q, want %q", got, test.want) + } + }) + } +} + +func TestGrokTurnIndexRequiresStableSession(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + applyGrokTurnIndexHeader(request, "7") + if got := request.Header.Get("x-grok-turn-idx"); got != "" { + t.Fatalf("turn index without session = %q", got) + } + + request.Header.Set("x-grok-session-id", "session-1") + applyGrokTurnIndexHeader(request, "7") + if got := request.Header.Get("x-grok-turn-idx"); got != "7" { + t.Fatalf("turn index with session = %q, want 7", got) + } +} + +func TestForwardResponseReplaysReasoningAcrossMessagesTurns(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + + rawEncrypted := make([]byte, 64) + for index := range rawEncrypted { + rawEncrypted[index] = byte(index) + } + replayEncrypted := base64.RawStdEncoding.EncodeToString(rawEncrypted) + requestCount := 0 + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.SetReasoningReplay(reasoningreplay.New( + memory.NewReasoningReplayStore(16), + reasoningreplay.Config{Enabled: true, TTL: time.Hour}, + nil, + )) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + requestCount++ + var payload struct { + Input []map[string]any `json:"input"` + PromptCacheKey string `json:"prompt_cache_key"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + switch requestCount { + case 2: + expectedSessionID, err := grokSessionID(payload.PromptCacheKey) + if err != nil { + t.Fatal(err) + } + if payload.PromptCacheKey != "messages-cache-key" || request.Header.Get("x-grok-session-id") != expectedSessionID || len(payload.Input) != 1 || payload.Input[0]["role"] != "user" { + t.Fatalf("WebSearch replay isolation = key %q input %#v", payload.PromptCacheKey, payload.Input) + } + case 3: + if len(payload.Input) != 4 || payload.Input[0]["role"] != "user" || payload.Input[1]["type"] != "reasoning" || payload.Input[1]["encrypted_content"] != replayEncrypted || payload.Input[2]["role"] != "assistant" || payload.Input[3]["role"] != "user" { + t.Fatalf("ordinary replay after WebSearch = %#v", payload.Input) + } + } + body := `{"id":"resp_3","model":"grok-4.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"second"}]}]}` + if requestCount == 1 { + body = `{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"reasoning","encrypted_content":"` + replayEncrypted + `"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first"}]}]}` + } else if requestCount == 2 { + body = `{"id":"resp_search","model":"grok-4.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"search"}]}]}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + }) + + credential := account.Credential{ID: 7, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted} + first, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-cache-key", ReasoningReplayKey: "messages-replay-key", + Body: []byte(`{"model":"public","max_tokens":128,"messages":[{"role":"user","content":"first"}]}`), + }) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(first.Body); err != nil { + t.Fatal(err) + } + if err := first.Body.Close(); err != nil { + t.Fatal(err) + } + + webSearch, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-cache-key", ReasoningReplayKey: "messages-replay-key", + Body: []byte(`{ + "model":"public","max_tokens":128, + "messages":[{"role":"user","content":"weather"}], + "tools":[{"type":"web_search_20250305","name":"web_search"}] + }`), + }) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(webSearch.Body); err != nil { + t.Fatal(err) + } + if err := webSearch.Body.Close(); err != nil { + t.Fatal(err) + } + + second, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-cache-key", ReasoningReplayKey: "messages-replay-key", + Body: []byte(`{"model":"public","max_tokens":128,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"first"},{"role":"user","content":"second"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer second.Body.Close() + if _, err := io.ReadAll(second.Body); err != nil { + t.Fatal(err) + } + if requestCount != 3 { + t.Fatalf("request count = %d", requestCount) + } +} + +func TestReasoningReplayScopeSeparatesAccountAndPlane(t *testing.T) { + adapter := NewAdapter(Config{ + BaseURL: "https://build.example/v1", + FallbackBaseURL: "https://xai.example/v1", + }, nil) + request := provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 7}, + ReasoningReplayKey: "explicit-session", + } + buildKey := adapter.scopedReasoningReplayKey(request, "https://build.example/v1") + if buildKey == "" { + t.Fatal("explicit session did not produce replay scope") + } + otherAccount := request + otherAccount.Credential.ID = 8 + if got := adapter.scopedReasoningReplayKey(otherAccount, "https://build.example/v1"); got == buildKey { + t.Fatal("reasoning replay scope was shared across accounts") + } + if got := adapter.scopedReasoningReplayKey(request, "https://xai.example/v1"); got == buildKey { + t.Fatal("reasoning replay scope was shared across Build and XAI") + } + request.ReasoningReplayKey = "" + if got := adapter.scopedReasoningReplayKey(request, "https://build.example/v1"); got != "" { + t.Fatalf("soft/empty session unexpectedly enabled replay: %q", got) + } +} + +func TestListModelsUsesOfficialMetadataHeaders(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1", ClientVersion: "0.2.102", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", UserAgent: "grok-shell/0.2.102 (linux; x86_64)"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/v1/models" || request.Header.Get("Authorization") != "Bearer access-token" || request.Header.Get("X-XAI-Token-Auth") != "xai-grok-cli" || request.Header.Get("x-grok-client-version") != "0.2.102" || request.Header.Get("x-grok-client-identifier") != "grok-shell" || request.Header.Get("x-grok-client-mode") != "headless" || request.Header.Get("User-Agent") != "grok-shell/0.2.102 (linux; x86_64)" { + t.Fatalf("headers = %#v", request.Header) + } + if request.Header.Get("x-userid") != "user-123" || request.Header.Get("x-email") != "user@example.com" || request.Header.Get("x-grok-user-id") != "" || request.Header.Get("x-authenticateresponse") != "" || request.Header.Get("x-grok-session-id") != "" { + t.Fatalf("metadata headers = %#v", request.Header) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"grok-4.5"}]}`)), Request: request}, nil + }) + models, err := adapter.ListModels(context.Background(), account.Credential{UserID: "user-123", Email: "user@example.com", EncryptedAccessToken: encrypted}) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0] != "grok-4.5" { + t.Fatalf("models = %#v", models) + } +} + +func TestModelCatalogETagSignalsMissingOrChangedCatalogBaseline(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + modelETag := `"catalog-v1"` + responseETag := modelETag + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + header := make(http.Header) + body := `{"id":"resp_1","status":"completed","output":[]}` + if request.URL.Path == "/v1/models" { + header.Set("ETag", modelETag) + body = `{"data":[{"id":"grok-4.5"}]}` + } else { + header.Set("x-models-etag", responseETag) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + }) + credential := account.Credential{ID: 42, EncryptedAccessToken: encrypted} + if !adapter.modelCatalogChanged(43, `"catalog-v1"`) { + t.Fatal("缺少进程内目录基线时应补一次账号模型同步") + } + if _, err := adapter.ListModels(context.Background(), credential); err != nil { + t.Fatal(err) + } + forward := func() *provider.Response { + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Body: []byte(`{}`), Operation: conversation.OperationResponses, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response + } + if response := forward(); response.ModelCatalogChanged { + t.Fatal("与最近模型同步相同的 ETag 不应触发刷新") + } + responseETag = `"catalog-v2"` + if response := forward(); !response.ModelCatalogChanged { + t.Fatal("推理响应报告新 ETag 时应触发账号模型刷新") + } + modelETag = responseETag + if _, err := adapter.ListModels(context.Background(), credential); err != nil { + t.Fatal(err) + } + if response := forward(); response.ModelCatalogChanged { + t.Fatal("成功同步新目录后不应继续重复触发刷新") + } +} + +func TestGetBillingUsesCreditsAndLiveSubscriptionTier(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1", ClientVersion: "0.2.102", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", UserAgent: "grok-shell/0.2.102 (linux; x86_64)"}, cipher) + calls := 0 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + var body string + switch request.URL.Path { + case "/v1/billing": + if request.URL.Query().Get("format") != "credits" { + t.Fatalf("billing request = %s", request.URL.String()) + } + body = `{"config":{"creditUsagePercent":0,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-01T00:00:00Z","end":"2026-07-08T00:00:00Z"}}}` + case "/v1/user": + if request.URL.Query().Get("include") != "subscription" { + t.Fatalf("subscription request = %s", request.URL.String()) + } + body = `{"subscriptionTier":"SuperGrokPro"}` + default: + t.Fatalf("unexpected request = %s", request.URL.String()) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + }) + billing, err := adapter.GetBilling(context.Background(), account.Credential{ID: 7, EncryptedAccessToken: encrypted}) + if err != nil { + t.Fatal(err) + } + if calls != 2 || billing.AccountID != 7 || billing.CreditUsagePercent != 0 || billing.UsagePeriodType != "USAGE_PERIOD_TYPE_WEEKLY" || billing.PlanName != "SuperGrokPro" || !billing.IsPaid() || billing.SyncedAt.IsZero() { + t.Fatalf("calls=%d billing=%#v", calls, billing) + } +} + +func TestNormalizeAccountModelCapabilitiesSuperAddsVideo15(t *testing.T) { + adapter := &Adapter{} + build := account.Credential{Provider: account.ProviderBuild} + // Super / paid: add 1.5 even when primary Build returns only grok-4.5. + got := adapter.NormalizeAccountModelCapabilities([]string{"grok-4.5", " ", "grok-4.5"}, &account.Billing{MonthlyLimit: 100}, build) + if len(got) != 2 || got[0] != "grok-4.5" || got[1] != buildVideoModel { + t.Fatalf("super primary catalog = %#v", got) + } + // Super with 1.5 already present: deduplicate idempotently and preserve other models. + got = adapter.NormalizeAccountModelCapabilities( + []string{"grok-4.5", buildVideoModel, "grok-code-fast-1", buildVideoModel}, + &account.Billing{OnDemandCap: 10}, + build, + ) + if len(got) != 3 || got[0] != "grok-4.5" || got[1] != buildVideoModel || got[2] != "grok-code-fast-1" { + t.Fatalf("super catalog = %#v", got) + } + // Free: remove 1.5 even when the catalog exposes it. + got = adapter.NormalizeAccountModelCapabilities([]string{"grok-4.5", buildVideoModel}, &account.Billing{Used: 1, PlanName: "free"}, build) + if len(got) != 1 || got[0] != "grok-4.5" { + t.Fatalf("free catalog = %#v", got) + } + // Unknown (no Billing): treat as Free and remove 1.5. + got = adapter.NormalizeAccountModelCapabilities([]string{buildVideoModel, "grok-4.5"}, nil, build) + if len(got) != 1 || got[0] != "grok-4.5" { + t.Fatalf("unknown catalog = %#v", got) + } + // Do not depend on BuildAPIFallback; an empty catalog with Billing Super adds only 1.5. + got = adapter.NormalizeAccountModelCapabilities(nil, &account.Billing{PlanName: "SuperGrok", CreditUsagePercent: 1}, build) + if len(got) != 1 || got[0] != buildVideoModel { + t.Fatalf("super empty catalog = %#v", got) + } + // Zero-value Billing with BuildSuperEntitled: add 1.5. + entitled := account.Credential{Provider: account.ProviderBuild, BuildSuperEntitled: true} + got = adapter.NormalizeAccountModelCapabilities([]string{"grok-4.5"}, &account.Billing{IsUnifiedBillingUser: true}, entitled) + if len(got) != 2 || got[0] != "grok-4.5" || got[1] != buildVideoModel { + t.Fatalf("entitled catalog = %#v", got) + } +} + +func TestGrokSessionIDFollowsConversationIdentity(t *testing.T) { + explicit := "019f6b02-5bae-7cf3-b26e-73e85c861749" + if value, err := grokSessionID(explicit); err != nil || value != explicit { + t.Fatalf("explicit session = %q, %v", value, err) + } + first, err := grokSessionID("client-conversation") + if err != nil { + t.Fatal(err) + } + second, err := grokSessionID("client-conversation") + if err != nil { + t.Fatal(err) + } + parsed, err := uuid.Parse(first) + if err != nil || parsed.Version() != uuid.Version(8) || first != second { + t.Fatalf("derived sessions = %q %q, %v", first, second, err) + } + // Never fabricate a random conv-id without a session key; it breaks xAI server affinity and keeps cached_tokens at zero. + generated, err := grokSessionID("") + if err != nil { + t.Fatal(err) + } + if generated != "" { + t.Fatalf("empty session key must not invent conv-id, got %q", generated) + } +} + +func TestInferenceIdentityIsConversationScopedNotAccountScoped(t *testing.T) { + adapter := NewAdapter(Config{ClientVersion: "0.2.102", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", UserAgent: "grok-shell/0.2.102 (linux; x86_64)"}, nil) + build := func(accountID uint64, conversation string) http.Header { + request := httptest.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + if err := adapter.applyHeaders(request, account.Credential{ID: accountID}, "token", "grok-4.5", conversation, true); err != nil { + t.Fatal(err) + } + return request.Header + } + first := build(1, "conversation-a") + second := build(2, "conversation-a") + third := build(1, "conversation-b") + if first.Get("x-grok-agent-id") != second.Get("x-grok-agent-id") || first.Get("x-grok-session-id") != second.Get("x-grok-session-id") { + t.Fatalf("same conversation identity changed across accounts: first=%#v second=%#v", first, second) + } + if first.Get("x-grok-req-id") == second.Get("x-grok-req-id") { + t.Fatalf("request ID was reused: %q", first.Get("x-grok-req-id")) + } + if first.Get("x-grok-session-id") == third.Get("x-grok-session-id") { + t.Fatalf("different conversations shared session ID: %q", first.Get("x-grok-session-id")) + } +} + +func TestForwardResponseSupportsResourceMethodsAndQuery(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1", ClientVersion: "0.2.102", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", UserAgent: "grok-shell/0.2.102 (linux; x86_64)"}, cipher) + methods := []string{http.MethodGet, http.MethodDelete} + next := 0 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method != methods[next] || request.URL.Path != "/v1/responses/resp_1" || request.URL.RawQuery != "include=reasoning.encrypted_content" { + t.Fatalf("request = %s %s", request.Method, request.URL.RequestURI()) + } + if request.Header.Get("Accept") != "application/json" || request.Header.Get("Content-Type") != "" { + t.Fatalf("headers = %#v", request.Header) + } + if request.Body != nil { + t.Fatal("resource request unexpectedly gained a body") + } + next++ + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"resp_1"}`)), Request: request}, nil + }) + + for _, method := range methods { + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, EncryptedAccessToken: encrypted}, + Method: method, + Path: "/responses/resp_1?include=reasoning.encrypted_content", + PromptCacheKey: "resource-cache-key", + }) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + } + if next != len(methods) { + t.Fatalf("requests = %d", next) + } +} + +func TestForwardResponseDecodesExplicitGzipResponse(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err := writer.Write([]byte(`{"id":"resp_gzip"}`)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Header.Get("Accept-Encoding") != "gzip" { + t.Fatalf("Accept-Encoding = %q", request.Header.Get("Accept-Encoding")) + } + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", + Header: http.Header{"Content-Encoding": []string{"gzip"}, "Content-Length": []string{"999"}}, + Body: io.NopCloser(bytes.NewReader(compressed.Bytes())), Request: request, + }, nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 8, EncryptedAccessToken: encrypted}, Method: http.MethodPost, Path: "/responses", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != `{"id":"resp_gzip"}` || response.Header.Get("Content-Encoding") != "" || response.Header.Get("Content-Length") != "" { + t.Fatalf("body=%q headers=%#v", body, response.Header) + } +} + +func TestForwardResponseDowngradesServerToolSearchBeforeUpstream(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if _, exists := payload["tools"]; exists { + t.Fatalf("server tool_search 未从上游请求移除: %#v", payload) + } + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_search"}`)), + Request: request, + }, nil + }) + + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + NormalizeBody: true, Operation: conversation.OperationResponses, + Body: []byte(`{"model":"public","input":"hello","tools":[{"type":"tool_search"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + if !strings.Contains(response.Header.Get("X-Grok2API-Compatibility-Warnings"), "server_tool_search_eager_loaded") { + t.Fatalf("compatibility warnings = %q", response.Header.Get("X-Grok2API-Compatibility-Warnings")) + } + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["id"] != "resp_search" { + t.Fatalf("response = %#v", payload) + } +} + +func TestForwardResponseRestoresNamespaceResponse(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + tools := payload["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["name"] != "crm__lookup" { + t.Fatalf("上游 tools = %#v", tools) + } + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_1","object":"response", + "tools":[{"type":"function","name":"crm__lookup"}], + "output":[{"type":"function_call","call_id":"call_1","name":"crm__lookup","arguments":"{}"}] + }`)), + Request: request, + }, nil + }) + + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + NormalizeBody: true, Operation: conversation.OperationResponses, + Body: []byte(`{ + "model":"public","input":"lookup", + "tools":[{"type":"namespace","name":"crm","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}] + }`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.Header.Get("X-Grok2API-Compatibility-Warnings") != "namespace_flattened" { + t.Fatalf("compatibility warnings = %q", response.Header.Get("X-Grok2API-Compatibility-Warnings")) + } + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + call := payload["output"].([]any)[0].(map[string]any) + if call["name"] != "lookup" || call["namespace"] != "crm" { + t.Fatalf("下游 function_call = %#v", call) + } + tools := payload["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["type"] != "namespace" { + t.Fatalf("下游 tools = %#v", tools) + } +} + +func TestForwardResponsePreservesClaudeCodeMessagesOptions(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["instructions"] != "legacy system" || payload["store"] != false || payload["reasoning"].(map[string]any)["effort"] != "high" || payload["prompt_cache_key"] != "messages-cache-key" { + t.Fatalf("upstream payload = %#v", payload) + } + expectedSessionID, err := grokSessionID("messages-cache-key") + if err != nil { + t.Fatal(err) + } + if request.Header.Get("x-grok-conv-id") != expectedSessionID || request.Header.Get("x-grok-session-id") != expectedSessionID { + t.Fatalf("prompt cache headers = %#v", request.Header) + } + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_1","model":"grok-4.5","status":"completed", + "output":[ + {"type":"reasoning","summary":[{"type":"summary_text","text":"thought"}],"encrypted_content":"signature"}, + {"type":"message","content":[{"type":"output_text","text":"ABCSTOPXYZ"}]} + ] + }`)), + Request: request, + }, nil + }) + + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", NormalizeBody: true, PromptCacheKey: "messages-cache-key", + Operation: conversation.OperationMessages, + Body: []byte(`{ + "model":"public","max_tokens":256,"stop_sequences":["STOP"], + "thinking":{"type":"enabled","budget_tokens":20000}, + "output_config":{"effort":"max"}, + "messages":[{"role":"system","content":"legacy system"},{"role":"user","content":"hello"}] + }`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + content := payload["content"].([]any) + if payload["stop_reason"] != "stop_sequence" || payload["stop_sequence"] != "STOP" || content[0].(map[string]any)["type"] != "thinking" || content[1].(map[string]any)["text"] != "ABC" { + t.Fatalf("messages response = %#v", payload) + } +} + +func TestForwardResponseMapsClaudeCodeWebSearchEndToEnd(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + tools, _ := payload["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["type"] != "web_search" || payload["tool_choice"] != "required" { + t.Fatalf("upstream web search payload = %#v", payload) + } + domains := tools[0].(map[string]any)["filters"].(map[string]any)["allowed_domains"].([]any) + if len(domains) != 1 || domains[0] != "doc.rust-lang.org" { + t.Fatalf("upstream web search filters = %#v", tools[0]) + } + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_search","model":"grok-4.5","status":"completed", + "output":[ + {"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust tutorials","sources":[{"url":"https://doc.rust-lang.org"}]}}, + {"type":"message","content":[{"type":"output_text","text":"Here you go.","annotations":[{"type":"url_citation","url":"https://doc.rust-lang.org","title":"The Rust Book"}]}]} + ], + "usage":{"input_tokens":7,"output_tokens":5,"total_tokens":12,"cost_in_usd_ticks":12000,"context_details":{"input_tokens":6,"output_tokens":4}} + }`)), + Request: request, + }, nil + }) + + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", NormalizeBody: true, + Operation: conversation.OperationMessages, + Body: []byte(`{ + "model":"public","max_tokens":256, + "messages":[{"role":"user","content":"Perform a web search for the query: rust tutorials"}], + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":8,"allowed_domains":["doc.rust-lang.org"]}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + content := payload["content"].([]any) + if len(content) != 3 || content[0].(map[string]any)["type"] != "server_tool_use" || content[1].(map[string]any)["type"] != "web_search_tool_result" || content[2].(map[string]any)["text"] != "Here you go." { + t.Fatalf("messages web search response = %#v", payload) + } + use := content[0].(map[string]any) + if use["input"].(map[string]any)["query"] != "rust tutorials" || content[1].(map[string]any)["tool_use_id"] != use["id"] { + t.Fatalf("web search block linkage = %#v", content) + } + hits := content[1].(map[string]any)["content"].([]any) + if len(hits) != 1 || hits[0].(map[string]any)["title"] != "The Rust Book" { + t.Fatalf("web search hits = %#v", hits) + } + serverUsage := payload["usage"].(map[string]any)["server_tool_use"].(map[string]any) + if serverUsage["web_search_requests"] != float64(1) || payload["stop_reason"] != "end_turn" { + t.Fatalf("messages web search usage = %#v", payload) + } + usage := payload["usage"].(map[string]any) + if usage["cost_in_usd_ticks"] != float64(12000) || usage["context_details"].(map[string]any)["input_tokens"] != float64(6) { + t.Fatalf("messages upstream usage = %#v", usage) + } +} + +func TestForwardResponseInjectsPromptCacheKeyAfterChatConversion(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + expectedSessionID, err := grokSessionID("chat-cache-key") + if err != nil { + t.Fatal(err) + } + if payload["prompt_cache_key"] != "chat-cache-key" || request.Header.Get("x-grok-conv-id") != expectedSessionID || request.Header.Get("x-grok-session-id") != expectedSessionID { + t.Fatalf("prompt cache request: payload=%#v headers=%#v", payload, request.Header) + } + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_1","model":"grok-4.5","status":"completed", + "output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}], + "usage":{"input_tokens":11,"output_tokens":2,"cost_in_usd_ticks":7000,"context_details":{"input_tokens":10,"output_tokens":2}} + }`)), + Request: request, + }, nil + }) + + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", NormalizeBody: true, + Operation: conversation.OperationChat, PromptCacheKey: "chat-cache-key", + Body: []byte(`{"model":"public","messages":[{"role":"user","content":"hello"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + usage := payload["usage"].(map[string]any) + if payload["object"] != "chat.completion" || usage["prompt_tokens"] != float64(11) || usage["cost_in_usd_ticks"] != float64(7000) || usage["context_details"].(map[string]any)["input_tokens"] != float64(10) { + t.Fatalf("chat response = %#v", payload) + } +} + +func TestShouldSkipXAIFallbackForSafetyAndBlocked(t *testing.T) { + if !shouldSkipXAIFallback([]byte(`{"code":"permission-denied","error":"Content violates usage guidelines. SAFETY_CHECK_TYPE_VIOLENCE"}`)) { + t.Fatal("safety body must skip XAI fallback") + } + if !shouldSkipXAIFallback([]byte(`{"code":"unauthorized:blocked-user","error":"User is blocked"}`)) { + t.Fatal("blocked body must skip XAI fallback") + } + if shouldSkipXAIFallback([]byte(`{"code":"permission-denied","error":"Access to the chat endpoint is denied"}`)) { + t.Fatal("ordinary permission denial may still probe XAI for Super Auto accounts") + } +} + +func TestParseBuildTeamRPSRateLimitMetadata(t *testing.T) { + body := []byte(`{"code":"resource-exhausted","error":"Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.20. Requests per Second (actual/limit): 2/2."}`) + metadata := provider.ParseRateLimitMetadata(body) + if metadata == nil { + t.Fatal("expected rate limit metadata") + } + if metadata.Scope != provider.RateLimitScopeRPS || metadata.TeamID != "00000000-0000-0000-0000-000000000013" || metadata.Model != "grok-4.20" || metadata.Actual != 2 || metadata.Limit != 2 { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestForwardResponsePreservesTruncatedRateLimitDiagnostic(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + body := `{"code":"resource-exhausted","error":"Too many requests"}` + strings.Repeat("x", provider.MaxDiagnosticBodyBytes) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, Status: "429 Too Many Requests", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), Request: request, + }, nil + }) + + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", NormalizeBody: true, + Body: []byte(`{"model":"grok-4.5","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.Diagnostic == nil || !response.Diagnostic.BodyTruncated || len(response.Diagnostic.Body) != provider.MaxDiagnosticBodyBytes { + t.Fatalf("diagnostic = %#v", response.Diagnostic) + } +} diff --git a/backend/internal/infra/provider/cli/billing.go b/backend/internal/infra/provider/cli/billing.go new file mode 100644 index 0000000000000000000000000000000000000000..e396da1cdb875eaa272742ad6ca0ba1a2092ab34 --- /dev/null +++ b/backend/internal/infra/provider/cli/billing.go @@ -0,0 +1,211 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func parseSubscriptionTier(data []byte) (string, error) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return "", fmt.Errorf("解析订阅等级: %w", err) + } + if tier := stringValue(firstValue(payload, "subscriptionTier", "subscription_tier")); strings.TrimSpace(tier) != "" { + return strings.TrimSpace(tier), nil + } + if user, ok := payload["user"].(map[string]any); ok { + if tier := stringValue(firstValue(user, "subscriptionTier", "subscription_tier")); strings.TrimSpace(tier) != "" { + return strings.TrimSpace(tier), nil + } + } + return "", nil +} + +func subscriptionTierFromJWT(token string) string { + claims := decodeJWTClaims(token) + value, exists := claims["tier"] + if !exists { + return "" + } + if tier, ok := value.(string); ok { + tier = strings.TrimSpace(tier) + if tier == "" { + return "" + } + if numeric, err := strconv.Atoi(tier); err == nil { + return subscriptionTierFromNumber(numeric) + } + return tier + } + number, ok := value.(float64) + if !ok || number < 0 || number != float64(int(number)) { + return "" + } + return subscriptionTierFromNumber(int(number)) +} + +func subscriptionTierFromNumber(tier int) string { + switch tier { + case 0: + return "free" + case 1: + return "supergrok" + case 2: + return "x_basic" + case 3: + return "x_premium" + case 4: + return "x_premium_plus" + case 5: + return "supergrok_heavy" + case 6: + return "supergrok_lite" + default: + return "" + } +} + +func parseBilling(data []byte) (account.Billing, error) { + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return account.Billing{}, fmt.Errorf("解析 Billing: %w", err) + } + original := root + if nested, ok := root["config"].(map[string]any); ok { + root = nested + } + planCode, planName := planValues(root) + if planCode == "" || planName == "" { + outerCode, outerName := planValues(original) + if planCode == "" { + planCode = outerCode + } + if planName == "" { + planName = outerName + } + } + onDemandEnabled := optionalBool(firstValue(root, "onDemandEnabled", "on_demand_enabled")) + if onDemandEnabled == nil { + onDemandEnabled = optionalBool(firstValue(original, "onDemandEnabled", "on_demand_enabled")) + } + result := account.Billing{ + PlanCode: planCode, + PlanName: planName, + MonthlyLimit: numberValue(firstValue(root, "monthlyLimit", "monthly_limit")), + Used: numberValue(firstValue(root, "used", "totalUsed", "includedUsed")), + OnDemandCap: numberValue(firstValue(root, "onDemandCap", "on_demand_cap", "maxAmountPerMonth")), + OnDemandUsed: numberValue(firstValue(root, "onDemandUsed", "on_demand_used")), + PrepaidBalance: numberValue(firstValue(root, "prepaidBalance", "prepaid_balance")), + CreditUsagePercent: numberValue(firstValue(root, "creditUsagePercent", "credit_usage_percent")), + IsUnifiedBillingUser: boolValue(firstValue(root, "isUnifiedBillingUser", "is_unified_billing_user")), + OnDemandEnabled: onDemandEnabled, + TopUpMethod: stringValue(firstValue(root, "topUpMethod", "top_up_method")), + BillingPeriodStart: stringValue(firstValue(root, "billingPeriodStart", "billing_period_start")), + BillingPeriodEnd: stringValue(firstValue(root, "billingPeriodEnd", "billing_period_end")), + } + if currentPeriod, ok := root["currentPeriod"].(map[string]any); ok { + result.UsagePeriodType = stringValue(currentPeriod["type"]) + result.UsagePeriodStart = stringValue(currentPeriod["start"]) + result.UsagePeriodEnd = stringValue(currentPeriod["end"]) + } + if history, ok := root["history"].([]any); ok { + result.History = make([]account.BillingHistoryEntry, 0, len(history)) + for _, raw := range history { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + cycle, _ := entry["billingCycle"].(map[string]any) + period, _ := entry["period"].(map[string]any) + result.History = append(result.History, account.BillingHistoryEntry{ + Year: int(numberValue(cycle["year"])), + Month: int(numberValue(cycle["month"])), + PeriodType: stringValue(period["type"]), + PeriodStart: stringValue(period["start"]), + PeriodEnd: stringValue(period["end"]), + IncludedUsed: numberValue(entry["includedUsed"]), + OnDemandUsed: numberValue(entry["onDemandUsed"]), + TotalUsed: numberValue(entry["totalUsed"]), + }) + } + } + if result.CreditUsagePercent == 0 { + switch { + case result.OnDemandCap > 0: + result.CreditUsagePercent = result.OnDemandUsed / result.OnDemandCap * 100 + case result.MonthlyLimit > 0: + result.CreditUsagePercent = result.Used / result.MonthlyLimit * 100 + } + } + return result, nil +} + +func boolValue(value any) bool { + result, _ := value.(bool) + return result +} + +func optionalBool(value any) *bool { + result, ok := value.(bool) + if !ok { + return nil + } + return &result +} + +func planValues(values map[string]any) (string, string) { + code := stringValue(firstValue(values, "planCode", "plan_code", "tier")) + name := stringValue(firstValue(values, "planName", "plan_name", "subscriptionName", "subscription_name", "subscriptionTier", "subscription_tier")) + for _, key := range []string{"plan", "subscription", "membership"} { + value, ok := values[key] + if !ok { + continue + } + switch typed := value.(type) { + case string: + if name == "" { + name = typed + } + case map[string]any: + if code == "" { + code = stringValue(firstValue(typed, "code", "id", "tier", "slug")) + } + if name == "" { + name = stringValue(firstValue(typed, "name", "displayName", "display_name", "label")) + } + } + } + return code, name +} + +func firstValue(values map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := values[key]; ok { + return value + } + } + return nil +} + +func numberValue(value any) float64 { + switch typed := value.(type) { + case float64: + return typed + case string: + parsed, _ := strconv.ParseFloat(typed, 64) + return parsed + case map[string]any: + return numberValue(typed["val"]) + default: + return 0 + } +} + +func stringValue(value any) string { + result, _ := value.(string) + return result +} diff --git a/backend/internal/infra/provider/cli/billing_test.go b/backend/internal/infra/provider/cli/billing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3c18811633dc8c9ba903198ecb4594a6f6b6f191 --- /dev/null +++ b/backend/internal/infra/provider/cli/billing_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "encoding/base64" + "testing" +) + +func TestParseBillingMonthlyPayload(t *testing.T) { + value, err := parseBilling([]byte(`{"config":{"monthlyLimit":{"val":100},"used":{"val":25},"onDemandCap":{"val":0},"billingPeriodStart":"2026-07-01T00:00:00Z","billingPeriodEnd":"2026-08-01T00:00:00Z"}}`)) + if err != nil { + t.Fatal(err) + } + if value.MonthlyLimit != 100 || value.Used != 25 || value.CreditUsagePercent != 25 { + t.Fatalf("billing = %#v", value) + } +} + +func TestParseBillingCreditsPayload(t *testing.T) { + value, err := parseBilling([]byte(`{"subscription":{"code":"super","name":"Super Plan"},"config":{"currentPeriod":{"start":"2026-07-08T00:00:00Z","end":"2026-07-15T00:00:00Z"},"onDemandCap":{"val":50},"onDemandUsed":{"val":12.5}}}`)) + if err != nil { + t.Fatal(err) + } + if value.OnDemandCap != 50 || value.OnDemandUsed != 12.5 || value.Used != 0 || value.CreditUsagePercent != 25 { + t.Fatalf("billing = %#v", value) + } + if value.UsagePeriodStart != "2026-07-08T00:00:00Z" || value.UsagePeriodEnd != "2026-07-15T00:00:00Z" { + t.Fatalf("usage period = %q - %q", value.UsagePeriodStart, value.UsagePeriodEnd) + } + if value.PlanCode != "super" || value.PlanName != "Super Plan" { + t.Fatalf("plan = %q / %q", value.PlanCode, value.PlanName) + } +} + +func TestParseBillingMatchesObservedBuildPayloads(t *testing.T) { + monthly, err := parseBilling([]byte(`{"config":{"monthlyLimit":{"val":0},"used":{"val":0},"onDemandCap":{"val":0},"billingPeriodStart":"2026-07-01T00:00:00+00:00","billingPeriodEnd":"2026-08-01T00:00:00+00:00","history":[{"billingCycle":{"year":2026,"month":6},"includedUsed":{"val":0},"onDemandUsed":{"val":0},"totalUsed":{"val":0}}]}}`)) + if err != nil { + t.Fatal(err) + } + if len(monthly.History) != 1 || monthly.History[0].Year != 2026 || monthly.History[0].Month != 6 { + t.Fatalf("monthly = %#v", monthly) + } + + credits, err := parseBilling([]byte(`{"onDemandEnabled":false,"subscriptionTier":"SuperGrok Heavy","config":{"creditUsagePercent":42.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-08T00:00:00+00:00","end":"2026-07-15T00:00:00+00:00"},"onDemandCap":{"val":0},"onDemandUsed":{"val":0},"isUnifiedBillingUser":true,"prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","history":[{"period":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-01T00:00:00Z","end":"2026-07-08T00:00:00Z"},"onDemandUsed":{"val":120}}]}}`)) + if err != nil { + t.Fatal(err) + } + if !credits.IsUnifiedBillingUser || credits.OnDemandEnabled == nil || *credits.OnDemandEnabled || credits.CreditUsagePercent != 42.5 || credits.UsagePeriodType != "USAGE_PERIOD_TYPE_WEEKLY" || credits.UsagePeriodEnd != "2026-07-15T00:00:00+00:00" || credits.TopUpMethod != "TOP_UP_METHOD_SAVED_PAYMENT_METHOD" || credits.PlanCode != "" || credits.PlanName != "SuperGrok Heavy" { + t.Fatalf("credits = %#v", credits) + } + if len(credits.History) != 1 || credits.History[0].PeriodType != "USAGE_PERIOD_TYPE_WEEKLY" || credits.History[0].PeriodStart != "2026-07-01T00:00:00Z" || credits.History[0].PeriodEnd != "2026-07-08T00:00:00Z" || credits.History[0].OnDemandUsed != 120 { + t.Fatalf("credits history = %#v", credits.History) + } + if !credits.IsPaid() { + t.Fatal("explicit SuperGrok tier must remain paid even when numeric limits are zero") + } +} + +func TestParseSubscriptionTierAndJWTFallback(t *testing.T) { + tier, err := parseSubscriptionTier([]byte(`{"user":{"subscriptionTier":"SuperGrokPro"}}`)) + if err != nil || tier != "SuperGrokPro" { + t.Fatalf("tier = %q err=%v", tier, err) + } + claims := base64.RawURLEncoding.EncodeToString([]byte(`{"tier":5}`)) + if got := subscriptionTierFromJWT("header." + claims + ".signature"); got != "supergrok_heavy" { + t.Fatalf("JWT tier = %q", got) + } +} diff --git a/backend/internal/infra/provider/cli/definition.go b/backend/internal/infra/provider/cli/definition.go new file mode 100644 index 0000000000000000000000000000000000000000..da0648f563449106d5f51e91f1754a98758b7196 --- /dev/null +++ b/backend/internal/infra/provider/cli/definition.go @@ -0,0 +1,26 @@ +package cli + +import ( + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +// Definition 集中声明 Grok Build 的稳定能力边界;上游协议细节仍由 Adapter 维护。 +func (a *Adapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderBuild, + ModelNamespace: account.ProviderBuild.ModelNamespace(), + ModelCatalog: provider.ModelCatalogRemote, + ModelCapabilities: []modeldomain.Capability{modeldomain.CapabilityResponses, modeldomain.CapabilityVideo}, + Quota: provider.QuotaBilling, + Credential: provider.CredentialSurface{ + AuthType: account.AuthTypeOAuth, Import: true, Refresh: true, DeviceOAuth: true, + }, + Conversation: provider.ConversationSurface{ + Responses: true, ChatCompletions: true, Messages: true, Compact: true, StoredResponses: true, + }, + Media: provider.MediaSurface{VideoGeneration: true}, + Inference: provider.InferencePolicy{Usage: provider.UsageUpstream}, + } +} diff --git a/backend/internal/infra/provider/cli/egress.go b/backend/internal/infra/provider/cli/egress.go new file mode 100644 index 0000000000000000000000000000000000000000..958b500cc3c2b5bd134a6d2267cce1e6b4aa1afd --- /dev/null +++ b/backend/internal/infra/provider/cli/egress.go @@ -0,0 +1,71 @@ +package cli + +import ( + "context" + "errors" + "io" + "net/http" + + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" +) + +type egressTransport struct { + manager *infraegress.Manager + fallback http.RoundTripper +} + +func (t *egressTransport) RoundTrip(request *http.Request) (*http.Response, error) { + affinity := infraegress.AccountFromContext(request.Context()) + if affinity == "" { + affinity = "bootstrap" + } + lease, configured, err := t.manager.AcquireIfConfigured(request.Context(), domainegress.ScopeBuild, affinity) + if err != nil { + return nil, err + } + if !configured { + response, requestErr := t.fallback.RoundTrip(request) + infraegress.RecordDirectPhysicalCall(request.Context(), response, requestErr) + return response, requestErr + } + if lease.UserAgent != "" { + request.Header.Set("User-Agent", lease.UserAgent) + } + response, err := lease.Do(request) + if err != nil { + if shouldReportEgressFailure(request.Context(), err) { + t.manager.FeedbackForScope(context.WithoutCancel(request.Context()), domainegress.ScopeBuild, lease.NodeID, 0, err) + } + lease.Release() + return nil, err + } + t.manager.FeedbackForScope(context.WithoutCancel(request.Context()), domainegress.ScopeBuild, lease.NodeID, response.StatusCode, nil) + if response.Body == nil { + lease.Release() + return response, nil + } + response.Body = &egressResponseBody{ReadCloser: response.Body, release: lease.Release} + return response, nil +} + +func shouldReportEgressFailure(ctx context.Context, err error) bool { + if err == nil || ctx.Err() != nil { + return false + } + return !errors.Is(err, context.Canceled) +} + +type egressResponseBody struct { + io.ReadCloser + release func() +} + +func (b *egressResponseBody) Close() error { + err := b.ReadCloser.Close() + if b.release != nil { + b.release() + b.release = nil + } + return err +} diff --git a/backend/internal/infra/provider/cli/egress_test.go b/backend/internal/infra/provider/cli/egress_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d1f9d58625796b1a250b005d88b82ba269526f15 --- /dev/null +++ b/backend/internal/infra/provider/cli/egress_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +func TestShouldReportEgressFailure(t *testing.T) { + canceledContext, cancel := context.WithCancel(context.Background()) + cancel() + deadlineContext, deadlineCancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer deadlineCancel() + + tests := []struct { + name string + ctx context.Context + err error + want bool + }{ + {name: "no error", ctx: context.Background(), err: nil, want: false}, + {name: "canceled request context", ctx: canceledContext, err: errors.New("connection reset"), want: false}, + {name: "expired request context", ctx: deadlineContext, err: context.DeadlineExceeded, want: false}, + {name: "canceled transport", ctx: context.Background(), err: context.Canceled, want: false}, + {name: "wrapped canceled transport", ctx: context.Background(), err: fmt.Errorf("round trip: %w", context.Canceled), want: false}, + {name: "connection failure", ctx: context.Background(), err: errors.New("connection refused"), want: true}, + {name: "independent transport timeout", ctx: context.Background(), err: context.DeadlineExceeded, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldReportEgressFailure(test.ctx, test.err); got != test.want { + t.Fatalf("shouldReportEgressFailure() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/backend/internal/infra/provider/cli/fallback.go b/backend/internal/infra/provider/cli/fallback.go new file mode 100644 index 0000000000000000000000000000000000000000..6caa6dfb35f99bc11db06ef433234f59cfbd507b --- /dev/null +++ b/backend/internal/infra/provider/cli/fallback.go @@ -0,0 +1,250 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/config" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +// FallbackMarker 记录 Build 请求因当次 403 成功回退到 XAI。 +// 该标记只用于观测,不参与后续请求路由。 +type FallbackMarker interface { + MarkBuildAPIFallback(ctx context.Context, accountID uint64, enabled bool) error +} + +// VideoUploadIssuer 为 XAI ZDR 视频签发一次性 PUT 接收地址并等待本地资产就绪。 +type VideoUploadIssuer interface { + // IssueVideoUpload 返回可被 xAI HTTPS PUT 的 URL 与绑定的本地 assetID。 + // 不得在错误信息中回显完整 URL 或票据明文。 + IssueVideoUpload(ctx context.Context, jobID string) (uploadURL, assetID string, err error) + // WaitVideoUpload 在上游任务完成后等待本地 PUT 资产就绪。 + WaitVideoUpload(ctx context.Context, assetID string) (contentType string, err error) +} + +func (a *Adapter) SetFallbackMarker(marker FallbackMarker) { + a.cfgMu.Lock() + a.fallbackMarker = marker + a.cfgMu.Unlock() +} + +func (a *Adapter) SetVideoUploadIssuer(issuer VideoUploadIssuer) { + a.cfgMu.Lock() + a.uploadIssuer = issuer + a.cfgMu.Unlock() +} + +func (a *Adapter) fallbackMarkerRef() FallbackMarker { + a.cfgMu.RLock() + defer a.cfgMu.RUnlock() + return a.fallbackMarker +} + +func (a *Adapter) uploadIssuerRef() VideoUploadIssuer { + a.cfgMu.RLock() + defer a.cfgMu.RUnlock() + return a.uploadIssuer +} + +func (a *Adapter) primaryBaseURL() string { + return strings.TrimRight(strings.TrimSpace(a.config().BaseURL), "/") +} + +func (a *Adapter) fallbackBaseURL() string { + return strings.TrimRight(config.NormalizeBuildFallbackBaseURL(a.config().FallbackBaseURL), "/") +} + +// isXAIInferenceFallbackCapable 判断该 Build API 操作是否可走 XAI 推理回退。 +// +// 支持:POST /responses、POST /responses/compact、视频 create/poll +// 不支持:GET /models(始终主地址)、GET/DELETE /responses/{id}、GET /billing、未知路径 +// +// OAuth 认证端点始终使用独立认证 host,不受此函数影响。 +func isXAIInferenceFallbackCapable(method, path string) bool { + method = strings.ToUpper(strings.TrimSpace(method)) + path = normalizeBuildAPIPath(path) + switch { + case method == http.MethodPost && path == "/responses": + // Responses create 与 Chat/Messages 兼容转发均走 POST /responses。 + return true + case method == http.MethodPost && path == "/responses/compact": + return true + case method == http.MethodPost && path == "/videos/generations": + return true + case method == http.MethodGet && strings.HasPrefix(path, "/videos/") && path != "/videos" && path != "/videos/generations": + // 视频任务轮询:GET /videos/{id} + return true + default: + // /models、Billing、stored-resource GET/DELETE、未知路径:仅主地址。 + return false + } +} + +func normalizeBuildAPIPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + if len(path) > 1 { + path = strings.TrimRight(path, "/") + } + return path +} + +func normalizedBuildRouteMode(credential account.Credential) account.BuildRouteMode { + if credential.Provider == account.ProviderBuild && credential.BuildRouteMode.IsValid() { + return credential.BuildRouteMode + } + return account.BuildRouteAuto +} + +// inferenceBaseForOperation 先应用管理员的显式模式,再校验 auto 的 Super 资格与 bot flag。 +// Free 与未确认等级的账号在 auto 下始终使用 Build;历史 fallback 标记不参与选择。 +func (a *Adapter) inferenceBaseForOperation(credential account.Credential, billing *account.Billing, method, path string) string { + if !isXAIInferenceFallbackCapable(method, path) { + return a.primaryBaseURL() + } + switch normalizedBuildRouteMode(credential) { + case account.BuildRouteBuild: + return a.primaryBaseURL() + case account.BuildRouteXAI: + return a.fallbackBaseURL() + } + if !account.IsBuildSuper(credential, billing) { + return a.primaryBaseURL() + } + if a.CredentialMetadata(credential).BuildBotFlagged { + return a.fallbackBaseURL() + } + return a.primaryBaseURL() +} + +// shouldProbeXAIInferenceFallback 只由当次 Build CLI 的严格 403 触发。 +// bot-flagged 账号已直接使用 XAI,不走该探测分支。 +func shouldProbeXAIInferenceFallback(credential account.Credential, billing *account.Billing, method, path string, primaryStatus int) bool { + return account.IsBuildSuper(credential, billing) && normalizedBuildRouteMode(credential) == account.BuildRouteAuto && isHTTPForbidden(primaryStatus) && isXAIInferenceFallbackCapable(method, path) +} + +func (a *Adapter) urlWithBase(base, path string) string { + return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path, "/") +} + +// activateBuildAPIFallback 在 XAI 推理回退成功后幂等记录账号;标记失败不撤销当前成功结果。 +// 仅应在可回退操作(responses create|compact / video)成功后调用,不得由 /models、Billing 或 stored-resource 触发。 +func (a *Adapter) activateBuildAPIFallback(ctx context.Context, credential *account.Credential) { + if credential == nil || credential.ID == 0 || credential.BuildAPIFallback { + return + } + credential.BuildAPIFallback = true + marker := a.fallbackMarkerRef() + if marker == nil { + slog.Error("build_api_fallback_mark_skipped", "account_id", credential.ID, "reason", "marker_unavailable") + return + } + if err := marker.MarkBuildAPIFallback(ctx, credential.ID, true); err != nil { + // 不含 token;仅记录账号与错误类型,便于后续幂等重写。 + slog.Error("build_api_fallback_mark_failed", "account_id", credential.ID, "error", err.Error()) + } +} + +func isHTTPForbidden(status int) bool { + return status == http.StatusForbidden +} + +func isDefinitiveAccountBlockBody(body []byte) bool { + var payload map[string]any + if json.Unmarshal(body, &payload) != nil { + return false + } + code := fallbackStringField(payload, "code") + message := firstNonEmpty(fallbackStringField(payload, "error"), fallbackStringField(payload, "message")) + if nested, ok := payload["error"].(map[string]any); ok { + code = firstNonEmpty(fallbackStringField(nested, "code"), code) + message = firstNonEmpty(fallbackStringField(nested, "message"), message) + } + code = strings.ToLower(strings.TrimSpace(code)) + message = strings.ToLower(strings.Trim(strings.TrimSpace(message), " .!\t\r\n")) + return strings.Contains(code, "blocked-user") || message == "user is blocked" +} + +// isSafetyRejectionBody detects request-level content safety denials that must not +// trigger XAI plane fallback or account rotation. +func isSafetyRejectionBody(body []byte) bool { + lower := strings.ToLower(string(body)) + return strings.Contains(lower, "content violates usage guidelines") || + strings.Contains(lower, "safety_check_type_") +} + +// shouldSkipXAIFallback reports whether a primary 403 body is terminal for the request. +func shouldSkipXAIFallback(body []byte) bool { + return isDefinitiveAccountBlockBody(body) || isSafetyRejectionBody(body) +} + +func fallbackStringField(values map[string]any, key string) string { + value, _ := values[key].(string) + return value +} + +func bufferedFailureDiagnostic(response *http.Response, body []byte, truncated bool) *provider.DiagnosticResponse { + if response == nil { + return &provider.DiagnosticResponse{StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), Body: append([]byte(nil), body...), BodyTruncated: truncated} + } + return &provider.DiagnosticResponse{ + StatusCode: response.StatusCode, Status: response.Status, Header: response.Header.Clone(), + Body: append([]byte(nil), body...), BodyTruncated: truncated, + } +} + +func isHTTPSuccess(status int) bool { + return status >= 200 && status < 300 +} + +// cloneBufferedResponse 用已读取的正文重建可再次消费的 HTTP 响应,保留状态与头。 +func cloneBufferedResponse(source *http.Response, body []byte, truncated bool) *http.Response { + if source == nil { + return &http.Response{ + StatusCode: http.StatusForbidden, + Status: "403 Forbidden", + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(body)), + ContentLength: int64(len(body)), + } + } + header := source.Header.Clone() + if header == nil { + header = make(http.Header) + } + if truncated { + header.Set("X-Grok2API-Body-Truncated", "1") + } + header.Set("Content-Length", strconv.Itoa(len(body))) + return &http.Response{ + StatusCode: source.StatusCode, + Status: source.Status, + Proto: source.Proto, + ProtoMajor: source.ProtoMajor, + ProtoMinor: source.ProtoMinor, + Header: header, + Body: io.NopCloser(bytes.NewReader(body)), + ContentLength: int64(len(body)), + TransferEncoding: append([]string(nil), source.TransferEncoding...), + Uncompressed: source.Uncompressed, + Trailer: source.Trailer.Clone(), + Request: source.Request, + TLS: source.TLS, + } +} diff --git a/backend/internal/infra/provider/cli/fallback_test.go b/backend/internal/infra/provider/cli/fallback_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8bec056443ad993b9763a9701f92455296e1c25a --- /dev/null +++ b/backend/internal/infra/provider/cli/fallback_test.go @@ -0,0 +1,980 @@ +package cli + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +type fallbackMarkerStub struct { + calls atomic.Int32 + err error +} + +func (m *fallbackMarkerStub) MarkBuildAPIFallback(ctx context.Context, accountID uint64, enabled bool) error { + m.calls.Add(1) + return m.err +} + +func TestForwardResponsePrimarySuccessDoesNotProbeFallback(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var hits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + hits.Add(1) + if !strings.Contains(request.URL.Host, "primary.test") { + t.Fatalf("unexpected host %s", request.URL.Host) + } + return jsonResponse(http.StatusOK, `{"id":"resp_1","output":[]}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 9, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Body: []byte(`{"model":"grok-4.5","input":"hi"}`), + Model: "grok-4.5", NormalizeBody: false, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + if hits.Load() != 1 { + t.Fatalf("hits = %d, want 1", hits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("marker calls = %d", marker.calls.Load()) + } +} + +func TestForwardResponsePrimary403Fallback200Activates(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":{"message":"forbidden"}}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + return jsonResponse(http.StatusOK, `{"id":"resp_ok","output":[]}`, request), nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + credential := account.Credential{ID: 11, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted} + paidBilling := &account.Billing{MonthlyLimit: 100} + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Billing: paidBilling, Method: http.MethodPost, Path: "/responses", + Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + if !strings.Contains(response.UpstreamURL, "xai.test") { + t.Fatalf("upstream = %s", response.UpstreamURL) + } + if primaryHits.Load() < 1 || fallbackHits.Load() != 1 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 1 { + t.Fatalf("marker calls = %d", marker.calls.Load()) + } + if response.RecoveredPrimaryFailure == nil || response.RecoveredPrimaryFailure.StatusCode != http.StatusForbidden || !strings.Contains(string(response.RecoveredPrimaryFailure.Body), "forbidden") { + t.Fatalf("recovered primary failure = %#v", response.RecoveredPrimaryFailure) + } +} + +func TestForwardResponseBlockedAccountDoesNotProbeXAI(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + return jsonResponse(http.StatusOK, `{"id":"unexpected","output":[]}`, request), nil + } + return jsonResponse(http.StatusForbidden, `{"code":"unauthorized:blocked-user","error":"User is blocked"}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 111, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Billing: &account.Billing{MonthlyLimit: 100}, Method: http.MethodPost, Path: "/responses", + Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden || fallbackHits.Load() != 0 || marker.calls.Load() != 0 { + t.Fatalf("status=%d fallback=%d marks=%d", response.StatusCode, fallbackHits.Load(), marker.calls.Load()) + } +} + +func TestForwardResponseNonSuper403NeverProbesXAI(t *testing.T) { + tests := []struct { + name string + billing *account.Billing + }{ + {name: "unknown", billing: nil}, + {name: "free", billing: &account.Billing{IsUnifiedBillingUser: true}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("non-Super account must never probe XAI") + } + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":{"message":"primary forbidden"}}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 111, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Billing: test.billing, + Method: http.MethodPost, Path: "/responses", Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden || primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("status=%d primary=%d fallback=%d", response.StatusCode, primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("mark calls = %d", marker.calls.Load()) + } + }) + } +} + +func TestForwardResponseNon403DoesNotProbeXAI(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError} { + t.Run(http.StatusText(status), func(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("non-403 must not probe XAI") + } + return jsonResponse(status, `{"error":"no"}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 112, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != status || fallbackHits.Load() != 0 || marker.calls.Load() != 0 { + t.Fatalf("status=%d fallback=%d marks=%d", response.StatusCode, fallbackHits.Load(), marker.calls.Load()) + } + }) + } +} + +func TestForwardResponseRouteModePrecedence(t *testing.T) { + tests := []struct { + name string + mode account.BuildRouteMode + botFlagged bool + entitled bool + billing *account.Billing + wantHost string + }{ + {name: "entitled auto bot flagged", mode: account.BuildRouteAuto, botFlagged: true, entitled: true, wantHost: "xai.test"}, + {name: "entitled auto normal", mode: account.BuildRouteAuto, entitled: true, wantHost: "primary.test"}, + {name: "entitled force build overrides bot flag", mode: account.BuildRouteBuild, botFlagged: true, entitled: true, wantHost: "primary.test"}, + {name: "entitled force xai", mode: account.BuildRouteXAI, entitled: true, wantHost: "xai.test"}, + {name: "paid force xai", mode: account.BuildRouteXAI, billing: &account.Billing{PrepaidBalance: 1}, wantHost: "xai.test"}, + {name: "free auto bot flagged", mode: account.BuildRouteAuto, botFlagged: true, billing: &account.Billing{IsUnifiedBillingUser: true}, wantHost: "primary.test"}, + {name: "free force xai", mode: account.BuildRouteXAI, billing: &account.Billing{IsUnifiedBillingUser: true}, wantHost: "xai.test"}, + {name: "unknown auto bot flagged", mode: account.BuildRouteAuto, botFlagged: true, wantHost: "primary.test"}, + {name: "unknown force xai", mode: account.BuildRouteXAI, wantHost: "xai.test"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + if test.botFlagged { + encrypted = encryptFallbackTestJWT(t, adapter, map[string]any{"bot_flag_source": 1}) + } + var gotHost string + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + gotHost = request.URL.Host + return jsonResponse(http.StatusOK, `{"id":"ok"}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 113, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildRouteMode: test.mode, BuildSuperEntitled: test.entitled}, + Billing: test.billing, + Method: http.MethodPost, Path: "/responses", Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if gotHost != test.wantHost { + t.Fatalf("host = %s, want %s", gotHost, test.wantHost) + } + }) + } +} + +func TestForwardResponseForceBuildDoesNotFallbackOn403(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + encrypted = encryptFallbackTestJWT(t, adapter, map[string]any{"bot_flag_source": 1}) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("force Build must not use XAI") + } + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":"forbidden"}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 114, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildRouteMode: account.BuildRouteBuild}, + Method: http.MethodPost, Path: "/responses", Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden || primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("status=%d primary=%d fallback=%d", response.StatusCode, primaryHits.Load(), fallbackHits.Load()) + } +} + +func TestForwardResponsePrimary403FallbackFailKeepsPrimaryError(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + return jsonResponse(http.StatusBadGateway, `{"error":"xai down"}`, request), nil + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":{"message":"primary forbidden"}}`, request), nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 12, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildSuperEntitled: true}, + Method: http.MethodPost, Path: "/responses", + Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", response.StatusCode) + } + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "primary forbidden") { + t.Fatalf("body = %s", body) + } + // 主 403 缓冲回放:仅一次 primary POST + 一次 fallback,不得二次 primary。 + if primaryHits.Load() != 1 { + t.Fatalf("primary hits = %d, want 1 (no replay)", primaryHits.Load()) + } + if fallbackHits.Load() != 1 { + t.Fatalf("fallback hits = %d, want 1", fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("must not mark on fallback failure, calls=%d", marker.calls.Load()) + } +} + +func TestForwardResponseMarkedAccountStillUsesBuildFirst(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusOK, `{"id":"ok"}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + t.Fatalf("marked account must not skip a successful Build request") + return nil, nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 13, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildAPIFallback: true}, + Method: http.MethodPost, Path: "/responses", + Body: []byte(`{"model":"grok-4.5","input":"hi"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("already marked account should not re-mark") + } +} + +func TestForwardResponseMarkedAccountStillNeedsCurrentBuild403(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + adapter.SetFallbackMarker(&fallbackMarkerStub{}) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":"forbidden"}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + if request.Method != http.MethodPost || !strings.HasSuffix(request.URL.Path, "/responses/compact") { + t.Fatalf("unexpected %s %s", request.Method, request.URL.Path) + } + return jsonResponse(http.StatusOK, `{"id":"compacted"}`, request), nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 130, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildAPIFallback: true, BuildSuperEntitled: true}, + Method: http.MethodPost, Path: "/responses/compact", + Body: []byte(`{"model":"grok-4.5","response_id":"resp_1"}`), Model: "grok-4.5", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if primaryHits.Load() != 1 || fallbackHits.Load() != 1 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } +} + +func TestForwardResponseMarkedAccountStoredResourceStaysPrimary(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/responses/resp_1"}, + {http.MethodDelete, "/responses/resp_1"}, + } { + t.Run(tc.method, func(t *testing.T) { + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusOK, `{"id":"resp_1"}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + t.Fatalf("stored resource must not hit XAI") + return nil, nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 131, EncryptedAccessToken: encrypted, BuildAPIFallback: true}, + Method: tc.method, Path: tc.path, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("marker calls = %d", marker.calls.Load()) + } + }) + } +} + +func TestForwardResponseUnmarkedStoredResource403DoesNotProbe(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/responses/resp_1"}, + {http.MethodDelete, "/responses/resp_1"}, + } { + t.Run(tc.method, func(t *testing.T) { + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":{"message":"primary forbidden"}}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + return jsonResponse(http.StatusOK, `{"id":"should-not"}`, request), nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 132, EncryptedAccessToken: encrypted}, + Method: tc.method, Path: tc.path, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", response.StatusCode) + } + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("must not mark, calls=%d", marker.calls.Load()) + } + }) + } +} + +func TestGetBillingAlwaysPrimaryEvenWhenMarked(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + if strings.HasSuffix(request.URL.Path, "/user") { + return jsonResponse(http.StatusNotFound, `{"error":"subscription unavailable"}`, request), nil + } + if !strings.Contains(request.URL.Path, "/billing") { + t.Fatalf("path = %s", request.URL.Path) + } + // 上游 v3.0.2 Billing 仅请求 format=credits,且始终主地址。 + if request.URL.RawQuery != "format=credits" { + t.Fatalf("query = %q, want format=credits", request.URL.RawQuery) + } + return jsonResponse(http.StatusOK, `{"config":{"onDemandCap":{"val":10},"onDemandUsed":{"val":1},"monthlyLimit":{"val":100},"used":{"val":5}}}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + t.Fatalf("billing must never hit XAI") + return nil, nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + billing, err := adapter.GetBilling(context.Background(), account.Credential{ + ID: 140, EncryptedAccessToken: encrypted, BuildAPIFallback: true, + }) + if err != nil { + t.Fatal(err) + } + if billing.OnDemandCap != 10 || billing.OnDemandUsed != 1 { + t.Fatalf("billing = %+v", billing) + } + if primaryHits.Load() != 2 || fallbackHits.Load() != 0 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("marker calls = %d", marker.calls.Load()) + } +} + +func TestGetBillingPrimary403NeverProbesXAI(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.Contains(request.URL.Host, "primary.test"): + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":"billing forbidden"}`, request), nil + case strings.Contains(request.URL.Host, "xai.test"): + fallbackHits.Add(1) + return jsonResponse(http.StatusOK, `{"config":{"monthlyLimit":{"val":1}}}`, request), nil + default: + t.Fatalf("unexpected host %s", request.URL.Host) + return nil, nil + } + }) + _, err := adapter.GetBilling(context.Background(), account.Credential{ + ID: 141, EncryptedAccessToken: encrypted, BuildAPIFallback: false, + }) + if err == nil { + t.Fatal("expected billing error") + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("err = %v", err) + } + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if marker.calls.Load() != 0 { + t.Fatalf("must not mark from billing 403, calls=%d", marker.calls.Load()) + } +} + +func TestIsXAIInferenceFallbackCapable(t *testing.T) { + capable := []struct{ method, path string }{ + {http.MethodPost, "/responses"}, + {http.MethodPost, "/responses/compact"}, + {http.MethodPost, "responses"}, + {http.MethodPost, "/videos/generations"}, + {http.MethodGet, "/videos/job_1"}, + } + for _, tc := range capable { + if !isXAIInferenceFallbackCapable(tc.method, tc.path) { + t.Fatalf("want capable: %s %s", tc.method, tc.path) + } + } + notCapable := []struct{ method, path string }{ + {http.MethodGet, "/models"}, + {http.MethodGet, "/responses/resp_1"}, + {http.MethodDelete, "/responses/resp_1"}, + {http.MethodGet, "/billing"}, + {http.MethodGet, "/billing?format=credits"}, + {http.MethodPost, "/unknown"}, + {http.MethodGet, "/responses"}, + } + for _, tc := range notCapable { + if isXAIInferenceFallbackCapable(tc.method, tc.path) { + t.Fatalf("want primary-only: %s %s", tc.method, tc.path) + } + } +} + +func TestListModelsAlwaysUsesBuildPrimary(t *testing.T) { + for _, marked := range []bool{false, true} { + t.Run(fmt.Sprintf("fallback_marked=%t", marked), func(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("model catalog must never hit XAI") + } + primaryHits.Add(1) + return jsonResponse(http.StatusOK, `{"data":[{"id":"grok-4.5"}]}`, request), nil + }) + models, err := adapter.ListModels(context.Background(), account.Credential{ + ID: 14, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, + BuildAPIFallback: marked, BuildSuperEntitled: true, + }) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0] != "grok-4.5" { + t.Fatalf("models = %#v", models) + } + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 || marker.calls.Load() != 0 { + t.Fatalf("primary=%d fallback=%d marks=%d", primaryHits.Load(), fallbackHits.Load(), marker.calls.Load()) + } + }) + } +} + +func TestListModels403NeverProbesXAI(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("model catalog 403 must not probe XAI") + } + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{}`, request), nil + }) + _, err := adapter.ListModels(context.Background(), account.Credential{ + ID: 114, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildAPIFallback: true, BuildSuperEntitled: true, + }) + if err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("err = %v", err) + } + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 || marker.calls.Load() != 0 { + t.Fatalf("primary=%d fallback=%d marks=%d", primaryHits.Load(), fallbackHits.Load(), marker.calls.Load()) + } +} + +func TestGenerateVideoFallbackInjectsUploadURL(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + issuer := &uploadIssuerStub{url: "https://public.example/v1/media/uploads/aabb", assetID: "vid_test"} + adapter.SetVideoUploadIssuer(issuer) + var createPayload map[string]any + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") && request.Header.Get("User-Agent") != "xai-grok-build/0.2.99" { + t.Fatalf("XAI video user agent = %q", request.Header.Get("User-Agent")) + } + if request.Method == http.MethodPost { + if strings.Contains(request.URL.Host, "primary.test") { + return jsonResponse(http.StatusForbidden, `{"error":"forbidden"}`, request), nil + } + if request.Header.Get("x-grok-model-override") != xaiVideoModel { + t.Fatalf("XAI model override = %q", request.Header.Get("x-grok-model-override")) + } + _ = json.NewDecoder(request.Body).Decode(&createPayload) + return jsonResponse(http.StatusOK, `{"request_id":"job_1"}`, request), nil + } + return jsonResponse(http.StatusOK, `{"status":"done"}`, request), nil + }) + result, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 15, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildSuperEntitled: true}, + JobID: "video_job_1", Prompt: "waves", Duration: 6, Resolution: "720p", ReferenceURLs: []string{"https://cdn.example.com/first.png"}, + }) + if err != nil { + t.Fatal(err) + } + if result.AssetID != "vid_test" { + t.Fatalf("asset = %s", result.AssetID) + } + output, _ := createPayload["output"].(map[string]any) + if output["upload_url"] != issuer.url { + t.Fatalf("payload = %#v", createPayload) + } + image, _ := createPayload["image"].(map[string]any) + if createPayload["model"] != xaiVideoModel || image["url"] != "https://cdn.example.com/first.png" || image["image_url"] != nil { + t.Fatalf("XAI fallback payload = %#v", createPayload) + } + if marker.calls.Load() != 1 { + t.Fatalf("marker = %d", marker.calls.Load()) + } +} + +func TestGenerateVideoMarkedStillUsesBuildFirst(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{} + adapter.SetFallbackMarker(marker) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("marked video must not skip a successful Build request") + } + primaryHits.Add(1) + if request.Method == http.MethodPost { + return jsonResponse(http.StatusOK, `{"request_id":"job_marked"}`, request), nil + } + return jsonResponse(http.StatusOK, `{"status":"done","url":"https://assets.grok.com/video.mp4"}`, request), nil + }) + result, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 115, EncryptedAccessToken: encrypted, BuildAPIFallback: true}, + JobID: "video_marked", Prompt: "waves", Duration: 6, Resolution: "720p", + }) + if err != nil { + t.Fatal(err) + } + if result.URL != "https://assets.grok.com/video.mp4" { + t.Fatalf("result = %#v", result) + } + if primaryHits.Load() < 2 || fallbackHits.Load() != 0 || marker.calls.Load() != 0 { + t.Fatalf("primary=%d fallback=%d marks=%d", primaryHits.Load(), fallbackHits.Load(), marker.calls.Load()) + } +} + +func TestGenerateVideoAutoBotFlaggedUsesXAIDirectly(t *testing.T) { + adapter, _ := newFallbackTestAdapter(t) + encrypted := encryptFallbackTestJWT(t, adapter, map[string]any{"bot_flag_source": 1}) + issuer := &uploadIssuerStub{url: "https://public.example/v1/media/uploads/bot1", assetID: "vid_bot"} + adapter.SetVideoUploadIssuer(issuer) + var primaryHits, fallbackHits atomic.Int32 + var createPayload map[string]any + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "primary.test") { + primaryHits.Add(1) + t.Fatalf("bot-flagged auto video must default to XAI") + } + fallbackHits.Add(1) + if request.Header.Get("User-Agent") != "xai-grok-build/0.2.99" { + t.Fatalf("XAI video user agent = %q", request.Header.Get("User-Agent")) + } + if request.Method == http.MethodPost { + if request.Header.Get("x-grok-model-override") != xaiVideoModel { + t.Fatalf("XAI model override = %q", request.Header.Get("x-grok-model-override")) + } + if err := json.NewDecoder(request.Body).Decode(&createPayload); err != nil { + t.Fatal(err) + } + return jsonResponse(http.StatusOK, `{"request_id":"job_bot"}`, request), nil + } + return jsonResponse(http.StatusOK, `{"status":"done"}`, request), nil + }) + result, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 116, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildRouteMode: account.BuildRouteAuto, BuildSuperEntitled: true}, + JobID: "video_bot", Prompt: "waves", Duration: 6, Resolution: "720p", ReferenceURLs: []string{"https://cdn.example.com/bot.png"}, + }) + if err != nil { + t.Fatal(err) + } + if result.AssetID != "vid_bot" || primaryHits.Load() != 0 || fallbackHits.Load() < 2 { + t.Fatalf("result=%#v primary=%d fallback=%d", result, primaryHits.Load(), fallbackHits.Load()) + } + image, _ := createPayload["image"].(map[string]any) + if createPayload["model"] != xaiVideoModel || image["url"] != "https://cdn.example.com/bot.png" || image["image_url"] != nil { + t.Fatalf("XAI direct payload = %#v", createPayload) + } +} + +func TestGenerateVideoNonSuperAutoNeverUsesXAI(t *testing.T) { + tests := []struct { + name string + mode account.BuildRouteMode + botFlagged bool + billing *account.Billing + }{ + {name: "free auto bot flagged", mode: account.BuildRouteAuto, botFlagged: true, billing: &account.Billing{IsUnifiedBillingUser: true}}, + {name: "unknown auto bot flagged", mode: account.BuildRouteAuto, botFlagged: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + if test.botFlagged { + encrypted = encryptFallbackTestJWT(t, adapter, map[string]any{"bot_flag_source": 1}) + } + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "xai.test") { + fallbackHits.Add(1) + t.Fatalf("non-Super video must never use XAI") + } + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":"forbidden"}`, request), nil + }) + _, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 117, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildRouteMode: test.mode}, + Billing: test.billing, + JobID: "video_non_super", Prompt: "waves", Duration: 6, Resolution: "720p", + }) + status, ok := provider.ErrorHTTPStatus(err) + if !ok || status != http.StatusForbidden { + t.Fatalf("err=%v status=%d ok=%t", err, status, ok) + } + if primaryHits.Load() != 1 || fallbackHits.Load() != 0 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + }) + } +} + +func TestGenerateVideoForceXAIOverridesNonSuper(t *testing.T) { + tests := []struct { + name string + billing *account.Billing + }{ + {name: "free", billing: &account.Billing{IsUnifiedBillingUser: true}}, + {name: "unknown"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + adapter.SetVideoUploadIssuer(&uploadIssuerStub{url: "https://public.example/v1/media/uploads/forced", assetID: "vid_forced"}) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "primary.test") { + primaryHits.Add(1) + t.Fatalf("force XAI must override account tier") + } + fallbackHits.Add(1) + if request.Method == http.MethodPost { + return jsonResponse(http.StatusOK, `{"request_id":"job_forced"}`, request), nil + } + return jsonResponse(http.StatusOK, `{"status":"done"}`, request), nil + }) + result, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 118, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildRouteMode: account.BuildRouteXAI}, + Billing: test.billing, + JobID: "video_forced", Prompt: "waves", Duration: 6, Resolution: "720p", + }) + if err != nil { + t.Fatal(err) + } + if result.AssetID != "vid_forced" || primaryHits.Load() != 0 || fallbackHits.Load() < 2 { + t.Fatalf("result=%#v primary=%d fallback=%d", result, primaryHits.Load(), fallbackHits.Load()) + } + }) + } +} + +func TestGenerateVideoFallbackMalformedJobIDDoesNotActivate(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + // 追踪本地置位:activateBuildAPIFallback 在写库前会先把 credential.BuildAPIFallback 设为 true。 + tracking := &trackingFallbackMarker{} + adapter.SetFallbackMarker(tracking) + issuer := &uploadIssuerStub{url: "https://public.example/v1/media/uploads/ccdd", assetID: "vid_bad"} + adapter.SetVideoUploadIssuer(issuer) + var primaryHits, fallbackHits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodPost { + t.Fatalf("unexpected method %s", request.Method) + } + if strings.Contains(request.URL.Host, "primary.test") { + primaryHits.Add(1) + return jsonResponse(http.StatusForbidden, `{"error":"forbidden"}`, request), nil + } + fallbackHits.Add(1) + // 2xx 但缺少 request_id / id:不得激活降级。 + return jsonResponse(http.StatusOK, `{"status":"queued","message":"accepted"}`, request), nil + }) + cred := account.Credential{ID: 16, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildAPIFallback: false, BuildSuperEntitled: true} + _, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: cred, JobID: "video_job_bad", Prompt: "waves", Duration: 6, Resolution: "720p", + }) + if err == nil { + t.Fatal("expected parse error for missing job id") + } + if !strings.Contains(err.Error(), "request_id") { + t.Fatalf("err = %v", err) + } + if primaryHits.Load() != 1 || fallbackHits.Load() != 1 { + t.Fatalf("primary=%d fallback=%d", primaryHits.Load(), fallbackHits.Load()) + } + if tracking.calls.Load() != 0 { + t.Fatalf("must not persist fallback mark, calls=%d", tracking.calls.Load()) + } + if tracking.activateSeen.Load() { + t.Fatal("must not call activate/local-set path on malformed create response") + } + if cred.BuildAPIFallback { + t.Fatal("caller credential must remain unmarked") + } +} + +// trackingFallbackMarker 记录 Mark 调用;activate 路径必定会调用 Mark(marker 非 nil)。 +type trackingFallbackMarker struct { + calls atomic.Int32 + activateSeen atomic.Bool + err error +} + +func (m *trackingFallbackMarker) MarkBuildAPIFallback(ctx context.Context, accountID uint64, enabled bool) error { + m.calls.Add(1) + if enabled { + m.activateSeen.Store(true) + } + return m.err +} + +type uploadIssuerStub struct { + url, assetID string + waitCalls atomic.Int32 +} + +func (u *uploadIssuerStub) IssueVideoUpload(ctx context.Context, jobID string) (string, string, error) { + return u.url, u.assetID, nil +} + +func (u *uploadIssuerStub) WaitVideoUpload(ctx context.Context, assetID string) (string, error) { + u.waitCalls.Add(1) + return "video/mp4", nil +} + +func newFallbackTestAdapter(t *testing.T) (*Adapter, string) { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: "https://primary.test/v1", FallbackBaseURL: "https://xai.test/v1", + ClientVersion: "0.2.99", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", + UserAgent: "test-agent", + }, cipher) + return adapter, encrypted +} + +func encryptFallbackTestJWT(t *testing.T, adapter *Adapter, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + token := "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + encrypted, err := adapter.cipher.Encrypt(token) + if err != nil { + t.Fatal(err) + } + return encrypted +} + +func TestFallbackMarkerFailureStillReturnsSuccess(t *testing.T) { + adapter, encrypted := newFallbackTestAdapter(t) + marker := &fallbackMarkerStub{err: context.DeadlineExceeded} + adapter.SetFallbackMarker(marker) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Host, "primary.test") { + return jsonResponse(http.StatusForbidden, `{}`, request), nil + } + return jsonResponse(http.StatusOK, `{"id":"ok"}`, request), nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 99, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, BuildSuperEntitled: true}, + Method: http.MethodPost, Path: "/responses", Body: []byte(`{"model":"x","input":"y"}`), Model: "x", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + if marker.calls.Load() != 1 { + t.Fatalf("marker should still be attempted") + } +} diff --git a/backend/internal/infra/provider/cli/import.go b/backend/internal/infra/provider/cli/import.go new file mode 100644 index 0000000000000000000000000000000000000000..bc623171588e16807b869c6491fd963344268c31 --- /dev/null +++ b/backend/internal/infra/provider/cli/import.go @@ -0,0 +1,286 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const ( + credentialImportProvider = "grok_build" + maxCredentialImportAccounts = 10000 +) + +type credentialImportDocument struct { + Accounts []importedCredentialEntry `json:"accounts"` +} + +type importedCredentialEntry struct { + Provider string `json:"provider"` + Name string `json:"name"` + ClientID string `json:"client_id"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + ExpiresAt string `json:"expires_at"` + ExpiresIn int64 `json:"expires_in"` + Email string `json:"email"` + Subject string `json:"sub"` + UserID string `json:"user_id"` + PrincipalID string `json:"principal_id"` + TeamID string `json:"team_id"` +} + +func marshalCredentials(values []provider.CredentialSeed) ([]byte, error) { + document := credentialImportDocument{Accounts: make([]importedCredentialEntry, 0, len(values))} + for _, value := range values { + entry := importedCredentialEntry{ + Provider: credentialImportProvider, Name: value.Name, ClientID: value.OIDCClientID, + AccessToken: value.AccessToken, RefreshToken: value.RefreshToken, TokenType: "Bearer", + Email: value.Email, UserID: value.UserID, TeamID: value.TeamID, + } + if !value.ExpiresAt.IsZero() { + entry.ExpiresAt = value.ExpiresAt.UTC().Format(time.RFC3339Nano) + } + document.Accounts = append(document.Accounts, entry) + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, fmt.Errorf("序列化账号凭据: %w", err) + } + return append(data, '\n'), nil +} + +func parseImportedCredentials(data []byte) ([]provider.CredentialSeed, error) { + entries, err := parseImportedCredentialEntries(data) + if err != nil { + return nil, err + } + + if len(entries) == 0 { + return nil, fmt.Errorf("账号凭据中没有账号") + } + + result := make([]provider.CredentialSeed, 0, len(entries)) + for index, entry := range entries { + seed, err := normalizeImportedCredential(entry) + if err != nil { + return nil, fmt.Errorf("第 %d 个账号: %w", index+1, err) + } + result = append(result, seed) + } + return result, nil +} + +func parseImportedCredentialEntries(data []byte) ([]importedCredentialEntry, error) { + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) + entries, sequenceErr := parseImportedCredentialJSONSequence(data) + if sequenceErr == nil { + return entries, nil + } + if errors.Is(sequenceErr, provider.ErrCredentialLimit) { + return nil, sequenceErr + } + if entries, recognized, err := parseLooseCredentialDocument(data); recognized { + return entries, err + } + return nil, sequenceErr +} + +func parseImportedCredentialJSONSequence(data []byte) ([]importedCredentialEntry, error) { + return provider.DecodeCredentialJSONEntries[importedCredentialEntry](data, credentialImportProvider, maxCredentialImportAccounts) +} + +func parseImportedCredentialJSONValue(data []byte) ([]importedCredentialEntry, error) { + var shape map[string]json.RawMessage + if err := json.Unmarshal(data, &shape); err != nil { + return nil, err + } + + if accounts, batch := shape["accounts"]; batch { + var entries []importedCredentialEntry + if err := json.Unmarshal(accounts, &entries); err != nil { + return nil, fmt.Errorf("解析批量账号凭据: %w", err) + } + return entries, nil + } + + var entry importedCredentialEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, fmt.Errorf("解析 OAuth 凭据: %w", err) + } + return []importedCredentialEntry{entry}, nil +} + +func appendImportedCredentialEntries(target *[]importedCredentialEntry, values []importedCredentialEntry) error { + if len(values) > maxCredentialImportAccounts-len(*target) { + return fmt.Errorf("%w: 单次最多导入 %d 个账号", provider.ErrCredentialLimit, maxCredentialImportAccounts) + } + *target = append(*target, values...) + return nil +} + +// parseLooseCredentialDocument supports account dumps shaped like +// { "accounts": [ followed by one complete object per line, even when the +// producer omitted commas or the final closing brackets. This compatibility +// path is intentionally limited to that recognizable wrapper. +func parseLooseCredentialDocument(data []byte) ([]importedCredentialEntry, bool, error) { + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 64*1024), max(len(data), 64*1024)) + entries := make([]importedCredentialEntry, 0) + nonEmptyLine := 0 + lineNumber := 0 + closed := false + for scanner.Scan() { + lineNumber++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + nonEmptyLine++ + if nonEmptyLine == 1 { + if line != "{" { + return nil, false, nil + } + continue + } + if nonEmptyLine == 2 { + if compactLooseJSONLine(line) != `"accounts":[` { + return nil, false, nil + } + continue + } + if isLooseDocumentClosing(line) { + closed = true + continue + } + if closed { + return nil, true, fmt.Errorf("解析批量账号凭据第 %d 行: 结束标记后仍有内容", lineNumber) + } + line = strings.TrimSpace(strings.TrimSuffix(line, ",")) + values, err := parseImportedCredentialJSONValue([]byte(line)) + if err != nil { + return nil, true, fmt.Errorf("解析批量账号凭据第 %d 行: %w", lineNumber, err) + } + if err := appendImportedCredentialEntries(&entries, values); err != nil { + return nil, true, err + } + } + if err := scanner.Err(); err != nil { + return nil, true, fmt.Errorf("读取批量账号凭据: %w", err) + } + if nonEmptyLine < 2 { + return nil, false, nil + } + return entries, true, nil +} + +func compactLooseJSONLine(value string) string { + return strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "").Replace(value) +} + +func isLooseDocumentClosing(value string) bool { + switch compactLooseJSONLine(value) { + case "]", "],", "}", "},", "]}", "]},": + return true + default: + return false + } +} + +func normalizeImportedCredential(entry importedCredentialEntry) (provider.CredentialSeed, error) { + providerName := strings.ToLower(strings.TrimSpace(entry.Provider)) + if providerName == "" { + providerName = credentialImportProvider + } + if providerName != credentialImportProvider { + return provider.CredentialSeed{}, fmt.Errorf("暂不支持 Provider %q", entry.Provider) + } + accessToken := strings.TrimSpace(entry.AccessToken) + refreshToken := strings.TrimSpace(entry.RefreshToken) + if accessToken == "" && refreshToken == "" { + return provider.CredentialSeed{}, fmt.Errorf("access_token 和 refresh_token 至少提供一个") + } + if entry.TokenType != "" && !strings.EqualFold(strings.TrimSpace(entry.TokenType), "Bearer") { + return provider.CredentialSeed{}, fmt.Errorf("暂不支持 token_type %q", entry.TokenType) + } + + claims := decodeJWTClaims(firstNonEmpty(entry.IDToken, accessToken)) + userID := firstNonEmpty(entry.UserID, entry.PrincipalID, entry.Subject, stringClaim(claims, "sub")) + email := firstNonEmpty(entry.Email, stringClaim(claims, "email")) + teamID := firstNonEmpty(entry.TeamID, stringClaim(claims, "team_id")) + expiresAt, err := importedCredentialExpiry(entry, claims) + if err != nil { + return provider.CredentialSeed{}, err + } + clientID := firstNonEmpty(entry.ClientID, defaultOAuthClientID) + identity := firstNonEmpty(userID, strings.ToLower(email), teamID, refreshToken, accessToken) + sourceKey := "import:" + security.HashToken(strings.Join([]string{providerName, clientID, identity}, "|")) + + return provider.CredentialSeed{ + Name: firstNonEmpty(entry.Name, email, userID, "Grok Build account"), Email: email, UserID: userID, TeamID: teamID, + SourceKey: sourceKey, OIDCClientID: clientID, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, + }, nil +} + +func importedCredentialExpiry(entry importedCredentialEntry, claims map[string]any) (time.Time, error) { + if strings.TrimSpace(entry.ExpiresAt) != "" { + parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(entry.ExpiresAt)) + if err != nil { + return time.Time{}, fmt.Errorf("expires_at 必须是 RFC3339 时间: %w", err) + } + return parsed.UTC(), nil + } + if expiresAt, ok := numericDateClaim(claims, "exp"); ok { + return expiresAt, nil + } + if entry.ExpiresIn < 0 { + return time.Time{}, fmt.Errorf("expires_in 不能小于零") + } + if entry.ExpiresIn > int64((365*24*time.Hour)/time.Second) { + return time.Time{}, fmt.Errorf("expires_in 超出合理范围") + } + if entry.ExpiresIn > 0 { + return time.Now().UTC().Add(time.Duration(entry.ExpiresIn) * time.Second), nil + } + return time.Time{}, nil +} + +func numericDateClaim(claims map[string]any, key string) (time.Time, bool) { + value, ok := claims[key].(float64) + if !ok || value <= 0 { + return time.Time{}, false + } + return time.Unix(int64(value), 0).UTC(), true +} + +func decodeJWTClaims(token string) map[string]any { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + var claims map[string]any + if json.Unmarshal(payload, &claims) != nil { + return nil + } + return claims +} + +func stringClaim(claims map[string]any, key string) string { + value, _ := claims[key].(string) + return value +} diff --git a/backend/internal/infra/provider/cli/normalize.go b/backend/internal/infra/provider/cli/normalize.go new file mode 100644 index 0000000000000000000000000000000000000000..384244861540c9db302f9f06361f7cfc1c2378e0 --- /dev/null +++ b/backend/internal/infra/provider/cli/normalize.go @@ -0,0 +1,163 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// normalizeResponsesRequest 改写路由字段和兼容别名,并为上游不支持的新工具协议建立请求级映射。 +func normalizeResponsesRequest(body []byte, model string) ([]byte, *responsesToolCompatibility, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, fmt.Errorf("解析 Responses 请求: %w", err) + } + payload["model"] = mustJSON(model) + normalizeBuildReasoningEffortPayload(payload) + if responseFormat, exists := payload["response_format"]; exists { + var text map[string]json.RawMessage + if raw := payload["text"]; len(raw) > 0 && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + if err := json.Unmarshal(raw, &text); err != nil { + return nil, nil, fmt.Errorf("解析 text: %w", err) + } + } + if text == nil { + text = make(map[string]json.RawMessage) + } + if isEmptyJSON(text["format"]) { + formatted, err := normalizeResponseFormat(responseFormat) + if err != nil { + return nil, nil, err + } + text["format"] = formatted + } + encoded, err := json.Marshal(text) + if err != nil { + return nil, nil, err + } + payload["text"] = encoded + delete(payload, "response_format") + } + patchReasoningTextTypes(payload) + compatibility, err := normalizeResponsesTools(payload) + if err != nil { + return nil, nil, err + } + normalized, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + return normalized, compatibility, nil +} + +// normalizeBuildReasoningEffort maps client aliases to levels accepted by Grok Build. +func normalizeBuildReasoningEffort(body []byte) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("解析 Build reasoning 请求: %w", err) + } + if !normalizeBuildReasoningEffortPayload(payload) { + return body, nil + } + return json.Marshal(payload) +} + +func normalizeBuildReasoningEffortPayload(payload map[string]json.RawMessage) bool { + raw, exists := payload["reasoning"] + if !exists || isEmptyJSON(raw) { + return false + } + var reasoning map[string]json.RawMessage + if err := json.Unmarshal(raw, &reasoning); err != nil || reasoning == nil { + return false + } + var effort string + if err := json.Unmarshal(reasoning["effort"], &effort); err != nil { + return false + } + var normalized string + switch strings.ToLower(strings.TrimSpace(effort)) { + case "max", "xhigh": + normalized = "high" + default: + return false + } + if effort == normalized { + return false + } + reasoning["effort"] = mustJSON(normalized) + payload["reasoning"] = mustJSON(reasoning) + return true +} + +// patchReasoningTextTypes 对齐官方 CLI 的序列化后修补:Responses 上游要求 +// reasoning.content[*] 必须携带 type=reasoning_text,即使部分客户端只发送 text。 +func patchReasoningTextTypes(payload map[string]json.RawMessage) { + raw := payload["input"] + if isEmptyJSON(raw) { + return + } + var items []any + if json.Unmarshal(raw, &items) != nil { + return // 字符串输入或其他合法简写不需要处理。 + } + changed := false + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok || item["type"] != "reasoning" { + continue + } + content, ok := item["content"].([]any) + if !ok { + continue + } + for _, rawContent := range content { + value, ok := rawContent.(map[string]any) + if !ok { + continue + } + if _, exists := value["type"]; !exists { + value["type"] = "reasoning_text" + changed = true + } + } + } + if changed { + payload["input"] = mustJSON(items) + } +} + +func normalizeResponseFormat(raw json.RawMessage) (json.RawMessage, error) { + var format map[string]json.RawMessage + if err := json.Unmarshal(raw, &format); err != nil { + return nil, fmt.Errorf("解析 response_format: %w", err) + } + var formatType string + _ = json.Unmarshal(format["type"], &formatType) + if formatType != "json_schema" || isEmptyJSON(format["json_schema"]) { + return raw, nil + } + var schema map[string]json.RawMessage + if err := json.Unmarshal(format["json_schema"], &schema); err != nil { + return nil, fmt.Errorf("解析 response_format.json_schema: %w", err) + } + result := make(map[string]json.RawMessage, len(schema)) + result["type"] = mustJSON("json_schema") + for key, value := range schema { + if key != "type" { + result[key] = value + } + } + return json.Marshal(result) +} + +func isEmptyJSON(raw json.RawMessage) bool { + value := bytes.TrimSpace(raw) + return len(value) == 0 || bytes.Equal(value, []byte("null")) || bytes.Equal(value, []byte(`""`)) +} + +func mustJSON(value any) json.RawMessage { + encoded, _ := json.Marshal(value) + return encoded +} diff --git a/backend/internal/infra/provider/cli/normalize_test.go b/backend/internal/infra/provider/cli/normalize_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bd1cba34da3c0efa9f971f78e1a4ed67ad64dca8 --- /dev/null +++ b/backend/internal/infra/provider/cli/normalize_test.go @@ -0,0 +1,231 @@ +package cli + +import ( + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestNormalizeResponsesRequest(t *testing.T) { + body := []byte(`{"model":"public-model","input":[{"type":"reasoning","id":"old","encrypted_content":"cipher","content":[{"text":"thought"}]},{"role":"user","content":"hello"}],"prompt_cache_key":"official-key","response_format":{"type":"json_object"}}`) + normalized, _, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + if payload["model"] != "grok-4.5" || payload["prompt_cache_key"] != "official-key" { + t.Fatalf("模型或缓存键未正确改写: %#v", payload) + } + input := payload["input"].([]any) + if len(input) != 2 || input[0].(map[string]any)["type"] != "reasoning" || input[0].(map[string]any)["encrypted_content"] != "cipher" { + t.Fatalf("reasoning 回放项未保留: %#v", input) + } + reasoningContent := input[0].(map[string]any)["content"].([]any)[0].(map[string]any) + if reasoningContent["type"] != "reasoning_text" || reasoningContent["text"] != "thought" { + t.Fatalf("reasoning content discriminator 未修补: %#v", reasoningContent) + } + text := payload["text"].(map[string]any) + if text["format"] == nil || payload["response_format"] != nil { + t.Fatalf("response_format 未映射: %#v", payload) + } +} + +func TestNormalizeBuildReasoningEffort(t *testing.T) { + tests := []struct { + name string + effort string + want string + }{ + {name: "max", effort: "max", want: "high"}, + {name: "xhigh", effort: "xhigh", want: "high"}, + {name: "uppercase max", effort: "MAX", want: "high"}, + {name: "high", effort: "high", want: "high"}, + {name: "medium", effort: "medium", want: "medium"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"reasoning":{"effort":"` + test.effort + `"},"input":"hello"}`) + normalized, err := normalizeBuildReasoningEffort(body) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + reasoning, ok := payload["reasoning"].(map[string]any) + if !ok || reasoning["effort"] != test.want { + t.Fatalf("reasoning = %#v, want %q", payload["reasoning"], test.want) + } + }) + } +} + +func TestNormalizeResponsesRequestPreservesExplicitPromptCacheKey(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{"model":"public","input":"hello","prompt_cache_key":"official-key"}`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + if payload["prompt_cache_key"] != "official-key" { + t.Fatalf("prompt_cache_key = %#v", payload["prompt_cache_key"]) + } +} + +func TestNormalizeResponsesRequestDoesNotInventPromptCacheKey(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{"model":"public","input":"hello"}`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload["prompt_cache_key"]; exists { + t.Fatalf("unexpected prompt_cache_key: %#v", payload) + } +} + +func TestNormalizeResponsesRequestAddsEmptySummaryToEncryptedReasoning(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{"model":"public","input":[{"type":"reasoning","encrypted_content":"opaque"},{"role":"user","content":"continue"}]}`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload struct { + Input []map[string]any `json:"input"` + } + if json.Unmarshal(normalized, &payload) != nil || len(payload.Input) != 2 { + t.Fatalf("normalized = %s", normalized) + } + summary, ok := payload.Input[0]["summary"].([]any) + if !ok || len(summary) != 0 || payload.Input[0]["encrypted_content"] != "opaque" { + t.Fatalf("reasoning = %#v", payload.Input[0]) + } +} + +func TestNormalizeResponsesRequestFlattensJSONSchema(t *testing.T) { + body := []byte(`{"model":"public","input":"hello","response_format":{"type":"json_schema","json_schema":{"type":"object","name":"answer","strict":true,"schema":{"type":"object"}}}}`) + normalized, _, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload struct { + Text struct { + Format map[string]any `json:"format"` + } `json:"text"` + } + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + if payload.Text.Format["type"] != "json_schema" || payload.Text.Format["name"] != "answer" || payload.Text.Format["json_schema"] != nil { + t.Fatalf("format = %#v", payload.Text.Format) + } +} + +func TestParseImportedCredentialsBatch(t *testing.T) { + data := []byte(`{"accounts":[{"provider":"grok_build","name":"primary","client_id":"client-1","access_token":"access-1","refresh_token":"refresh-1","email":"user@example.com","user_id":"user-1","expires_at":"2026-07-11T00:00:00Z"},{"refresh_token":"refresh-2"}]}`) + values, err := parseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].Name != "primary" || values[0].UserID != "user-1" || values[0].OIDCClientID != "client-1" || values[1].RefreshToken != "refresh-2" { + t.Fatalf("导入结果不正确: %#v", values) + } + if values[0].SourceKey == values[1].SourceKey { + t.Fatal("不同账号生成了相同来源标识") + } +} + +func TestParseImportedCredentialsJSONSequence(t *testing.T) { + data := []byte("\xef\xbb\xbf{\n \"access_token\": \"access-1\",\n \"sub\": \"user-1\"\n}\r\n\r\n" + + "{\"refresh_token\":\"refresh-2\",\"email\":\"two@example.com\"}\r\n") + values, err := parseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].AccessToken != "access-1" || values[0].UserID != "user-1" || values[1].RefreshToken != "refresh-2" { + t.Fatalf("JSON sequence import = %#v", values) + } +} + +func TestParseImportedCredentialsLooseAccountsDocument(t *testing.T) { + data := []byte("{\n \"accounts\": [\n" + + "{\"access_token\":\"access-1\",\"sub\":\"user-1\"}\n" + + "{\"refresh_token\":\"refresh-2\"}\n") + values, err := parseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].UserID != "user-1" || values[1].RefreshToken != "refresh-2" { + t.Fatalf("loose accounts import = %#v", values) + } +} + +func TestParseImportedCredentialsLooseAccountsDocumentReportsLine(t *testing.T) { + data := []byte("{\n \"accounts\": [\n{\"access_token\":\"access-1\"}\nnot-json\n") + _, err := parseImportedCredentials(data) + if err == nil || !strings.Contains(err.Error(), "第 4 行") { + t.Fatalf("error = %v, want line number", err) + } +} + +func TestMarshalCredentialsUsesImportDocument(t *testing.T) { + expiresAt := time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC) + data, err := marshalCredentials([]provider.CredentialSeed{{ + Name: "primary", Email: "user@example.com", UserID: "user-1", TeamID: "team-1", + OIDCClientID: "client-1", AccessToken: "access", RefreshToken: "refresh", ExpiresAt: expiresAt, + }}) + if err != nil { + t.Fatal(err) + } + values, err := parseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 1 || values[0].AccessToken != "access" || values[0].RefreshToken != "refresh" || !values[0].ExpiresAt.Equal(expiresAt) { + t.Fatalf("round-trip values = %#v", values) + } +} + +func TestParseImportedCredentialsRejectsAccountLimit(t *testing.T) { + data, err := json.Marshal(credentialImportDocument{Accounts: make([]importedCredentialEntry, maxCredentialImportAccounts+1)}) + if err != nil { + t.Fatal(err) + } + if _, err := parseImportedCredentials(data); !errors.Is(err, provider.ErrCredentialLimit) { + t.Fatalf("error = %v, want credential limit", err) + } +} + +func TestParseImportedCredentialsOfficialOAuthResponse(t *testing.T) { + expiresAt := time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC) + claims, _ := json.Marshal(map[string]any{"sub": "user-1", "email": "user@example.com", "team_id": "team-1", "exp": expiresAt.Unix()}) + idToken := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + base64.RawURLEncoding.EncodeToString(claims) + ".signature" + data, _ := json.Marshal(map[string]any{"access_token": "access", "refresh_token": "refresh", "expires_in": 3600, "id_token": idToken, "token_type": "Bearer"}) + + values, err := parseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 1 || values[0].UserID != "user-1" || values[0].Email != "user@example.com" || values[0].TeamID != "team-1" || !values[0].ExpiresAt.Equal(expiresAt) { + t.Fatalf("OAuth 导入结果不正确: %#v", values) + } +} + +func TestParseImportedCredentialsRejectsUnsupportedMap(t *testing.T) { + _, err := parseImportedCredentials([]byte(`{"https://auth.x.ai::client":{"key":"access","refresh_token":"refresh"}}`)) + if err == nil { + t.Fatal("旧 Map 格式不应继续被接受") + } +} diff --git a/backend/internal/infra/provider/cli/oauth.go b/backend/internal/infra/provider/cli/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..06f9feffb9ef1ffd4cfd97292bb836d2e18b92dd --- /dev/null +++ b/backend/internal/infra/provider/cli/oauth.go @@ -0,0 +1,342 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const ( + defaultOAuthClientID = "b1a00492-073a-47ea-816f-4c329264a828" + defaultOAuthScope = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write" + defaultDeviceURL = "https://auth.x.ai/oauth2/device/code" + defaultTokenURL = "https://auth.x.ai/oauth2/token" + deviceClientSurface = "ui" +) + +var ( + oauthBearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+`) + oauthJWTPattern = regexp.MustCompile(`[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`) + oauthSensitivePairPattern = regexp.MustCompile(`(?i)(access_token|refresh_token|id_token|token|authorization|cookie|secret|password|credential)=([^&\s"'<>]+)`) +) + +type oauthClient struct { + http *http.Client + clientID string + scope string + deviceURL string + tokenURL string + version func() string +} + +func newOAuthClient(httpClient *http.Client, version func() string) *oauthClient { + return &oauthClient{http: httpClient, clientID: defaultOAuthClientID, scope: defaultOAuthScope, deviceURL: defaultDeviceURL, tokenURL: defaultTokenURL, version: version} +} + +func (c *oauthClient) startDevice(ctx context.Context) (provider.DeviceAuthorization, error) { + form := url.Values{"client_id": {c.clientID}, "scope": {c.scope}, "referrer": {"grok-build"}} + var payload struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + } + if err := c.postForm(ctx, c.deviceURL, form, &payload, true); err != nil { + return provider.DeviceAuthorization{}, err + } + if payload.DeviceCode == "" || payload.UserCode == "" || payload.VerificationURI == "" { + return provider.DeviceAuthorization{}, fmt.Errorf("xAI Device OAuth 返回字段不完整") + } + if payload.Interval <= 0 { + payload.Interval = 5 + } + if payload.ExpiresIn <= 0 { + payload.ExpiresIn = 1800 + } + return provider.DeviceAuthorization{DeviceCode: payload.DeviceCode, UserCode: payload.UserCode, VerificationURI: payload.VerificationURI, VerificationURIComplete: payload.VerificationURIComplete, Interval: time.Duration(payload.Interval) * time.Second, ExpiresIn: time.Duration(payload.ExpiresIn) * time.Second}, nil +} + +func (c *oauthClient) pollDevice(ctx context.Context, deviceCode string) (tokenPayload, error) { + form := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "client_id": {c.clientID}, "device_code": {deviceCode}} + return c.exchange(ctx, form, "", true) +} + +func (c *oauthClient) refresh(ctx context.Context, refreshToken string) (tokenPayload, error) { + form := url.Values{"grant_type": {"refresh_token"}, "client_id": {c.clientID}, "refresh_token": {refreshToken}} + value, err := c.exchange(ctx, form, refreshToken, false) + if errors.Is(err, provider.ErrAuthorizationDenied) { + return tokenPayload{}, &provider.CredentialRefreshError{Code: "refresh_denied", Message: "OAuth authorization was denied", Permanent: true, Cause: err} + } + return value, err +} + +type tokenPayload struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time + IDToken string +} + +func (c *oauthClient) exchange(ctx context.Context, form url.Values, fallbackRefresh string, deviceFlow bool) (tokenPayload, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return tokenPayload{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if deviceFlow { + c.applyDeviceHeaders(req) + } + resp, err := c.http.Do(req) + if err != nil { + return tokenPayload{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return tokenPayload{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + oauthError := parseOAuthErrorResponse(body, resp.StatusCode) + switch oauthError.Code { + case "authorization_pending": + if deviceFlow { + return tokenPayload{}, provider.ErrAuthorizationPending + } + case "slow_down": + if deviceFlow { + return tokenPayload{}, provider.ErrSlowDown + } + case "access_denied", "expired_token": + if deviceFlow { + return tokenPayload{}, provider.ErrAuthorizationDenied + } + } + return tokenPayload{}, &provider.CredentialRefreshError{ + Status: resp.StatusCode, Code: oauthError.Code, Message: oauthError.Message, Response: oauthError.Response, + Permanent: resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnauthorized, + RetryAfter: parseOAuthRetryAfter(resp.Header.Get("Retry-After")), + } + } + var value struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &value); err != nil { + return tokenPayload{}, fmt.Errorf("解析 xAI OAuth 响应: %w", err) + } + if value.AccessToken == "" { + return tokenPayload{}, &provider.CredentialRefreshError{Status: resp.StatusCode, Code: "missing_access_token", Message: "OAuth response did not contain access_token", Permanent: true} + } + if value.ExpiresIn <= 0 { + value.ExpiresIn = 3600 + } + return tokenPayload{AccessToken: value.AccessToken, RefreshToken: firstNonEmpty(value.RefreshToken, fallbackRefresh), ExpiresAt: time.Now().UTC().Add(time.Duration(value.ExpiresIn) * time.Second), IDToken: value.IDToken}, nil +} + +type oauthErrorDetails struct { + Code string + Message string + Response string +} + +func parseOAuthErrorResponse(body []byte, status int) oauthErrorDetails { + result := oauthErrorDetails{Code: "oauth_http_" + strconv.Itoa(status)} + var payload any + if json.Unmarshal(body, &payload) != nil { + redacted := redactOAuthDiagnosticText(string(body)) + result.Message = normalizeOAuthErrorMessage(redacted, 512) + result.Response = normalizeOAuthErrorMessage(redacted, 4096) + return result + } + + result.Code = firstNonEmpty( + jsonStringAt(payload, "error"), + jsonStringAt(payload, "error", "code"), + jsonStringAt(payload, "error_code"), + jsonStringAt(payload, "code"), + result.Code, + ) + rootMessage, _ := payload.(string) + messages := []string{ + rootMessage, + jsonStringAt(payload, "error_description"), + jsonStringAt(payload, "message"), + jsonStringAt(payload, "detail"), + jsonStringAt(payload, "description"), + jsonStringAt(payload, "title"), + jsonStringAt(payload, "error", "message"), + jsonStringAt(payload, "error", "error_description"), + jsonStringAt(payload, "error", "detail"), + jsonStringAt(payload, "error", "description"), + } + seen := make(map[string]struct{}, len(messages)) + for _, message := range messages { + message = normalizeOAuthErrorMessage(message, 512) + if message == "" { + continue + } + if _, exists := seen[message]; exists { + continue + } + seen[message] = struct{}{} + if result.Message != "" { + result.Message += " · " + } + result.Message += message + } + redacted, err := json.Marshal(redactOAuthDiagnosticValue("", payload)) + if err == nil { + result.Response = truncateOAuthDiagnostic(string(redacted), 4096) + } + return result +} + +func jsonStringAt(value any, path ...string) string { + current := value + for _, key := range path { + object, ok := current.(map[string]any) + if !ok { + return "" + } + current, ok = object[key] + if !ok { + return "" + } + } + text, _ := current.(string) + return text +} + +func redactOAuthDiagnosticValue(key string, value any) any { + if isSensitiveOAuthDiagnosticKey(key) { + return "[REDACTED]" + } + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for childKey, child := range typed { + result[childKey] = redactOAuthDiagnosticValue(childKey, child) + } + return result + case []any: + result := make([]any, len(typed)) + for index, child := range typed { + result[index] = redactOAuthDiagnosticValue(key, child) + } + return result + case string: + return redactOAuthDiagnosticText(typed) + default: + return value + } +} + +func isSensitiveOAuthDiagnosticKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_")) + for _, part := range []string{"token", "authorization", "cookie", "secret", "password", "credential", "assertion", "code_verifier", "device_code"} { + if strings.Contains(normalized, part) { + return true + } + } + return false +} + +func redactOAuthDiagnosticText(value string) string { + value = oauthBearerPattern.ReplaceAllString(value, "Bearer [REDACTED]") + value = oauthJWTPattern.ReplaceAllString(value, "[REDACTED]") + value = oauthSensitivePairPattern.ReplaceAllString(value, "$1=[REDACTED]") + fields := strings.Fields(value) + for index := range fields { + trimmed := strings.Trim(fields[index], `"'(),;`) + if len(trimmed) >= 80 && !strings.ContainsAny(trimmed, "{}[]<>") { + fields[index] = strings.Replace(fields[index], trimmed, "[REDACTED]", 1) + } + } + return strings.Join(fields, " ") +} + +func normalizeOAuthErrorMessage(value string, limit int) string { + value = strings.Join(strings.Fields(value), " ") + return truncateOAuthDiagnostic(value, limit) +} + +func truncateOAuthDiagnostic(value string, limit int) string { + runes := []rune(value) + if len(runes) > limit { + if limit <= 1 { + return "…" + } + return string(runes[:limit-1]) + "…" + } + return value +} + +func parseOAuthRetryAfter(value string) time.Duration { + value = strings.TrimSpace(value) + if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if parsed, err := http.ParseTime(value); err == nil && parsed.After(time.Now()) { + return time.Until(parsed) + } + return 0 +} + +func (c *oauthClient) postForm(ctx context.Context, endpoint string, form url.Values, output any, deviceFlow bool) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if deviceFlow { + c.applyDeviceHeaders(req) + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("xAI OAuth 返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return json.Unmarshal(body, output) +} + +func (c *oauthClient) applyDeviceHeaders(req *http.Request) { + if c.version != nil { + if version := strings.TrimSpace(c.version()); version != "" { + req.Header.Set("x-grok-client-version", version) + } + } + // The management UI presents the verification URL and code to a human. + req.Header.Set("x-grok-client-surface", deviceClientSurface) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/internal/infra/provider/cli/oauth_test.go b/backend/internal/infra/provider/cli/oauth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..77a180d186711f39feb56855d06467e8f6bbfbfb --- /dev/null +++ b/backend/internal/infra/provider/cli/oauth_test.go @@ -0,0 +1,144 @@ +package cli + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestOAuthDeviceFlowMatchesOfficialWireContract(t *testing.T) { + version := "0.2.111" + requests := 0 + tokenPolls := 0 + httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + if request.Header.Get("x-grok-client-version") != version { + t.Fatalf("client version = %q, want %q", request.Header.Get("x-grok-client-version"), version) + } + if request.Header.Get("x-grok-client-surface") != deviceClientSurface { + t.Fatalf("client surface = %q", request.Header.Get("x-grok-client-surface")) + } + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + switch request.URL.Path { + case "/oauth2/device/code": + if request.Form.Get("client_id") != defaultOAuthClientID || request.Form.Get("scope") != defaultOAuthScope || request.Form.Get("referrer") != "grok-build" { + t.Fatalf("device form = %v", request.Form) + } + return oauthResponse(http.StatusOK, `{"device_code":"device","user_code":"ABCD-EFGH","verification_uri":"https://auth.x.ai/activate","verification_uri_complete":"https://auth.x.ai/activate?user_code=ABCD-EFGH","interval":5,"expires_in":1800}`), nil + case "/oauth2/token": + if request.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" || request.Form.Get("client_id") != defaultOAuthClientID || request.Form.Get("device_code") != "device" { + t.Fatalf("token form = %v", request.Form) + } + tokenPolls++ + if tokenPolls == 1 { + return oauthResponse(http.StatusBadRequest, `{"error":"authorization_pending"}`), nil + } + return oauthResponse(http.StatusOK, `{"access_token":"access","refresh_token":"refresh","id_token":"id","expires_in":3600}`), nil + default: + t.Fatalf("unexpected OAuth path %q", request.URL.Path) + return nil, nil + } + })} + client := newOAuthClient(httpClient, func() string { return version }) + + authorization, err := client.startDevice(context.Background()) + if err != nil { + t.Fatal(err) + } + if authorization.DeviceCode != "device" || authorization.UserCode != "ABCD-EFGH" || authorization.Interval != 5*time.Second || authorization.ExpiresIn != 30*time.Minute { + t.Fatalf("authorization = %#v", authorization) + } + + version = "0.2.112" + if _, err := client.pollDevice(context.Background(), authorization.DeviceCode); !errors.Is(err, provider.ErrAuthorizationPending) { + t.Fatalf("poll error = %v", err) + } + tokens, err := client.pollDevice(context.Background(), authorization.DeviceCode) + if err != nil { + t.Fatal(err) + } + if tokens.AccessToken != "access" || tokens.RefreshToken != "refresh" || tokens.IDToken != "id" || time.Until(tokens.ExpiresAt) < 59*time.Minute { + t.Fatalf("tokens = %#v", tokens) + } + if requests != 3 { + t.Fatalf("requests = %d, want 3", requests) + } +} + +func oauthResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func TestOAuthRefreshClassifiesPermanentAndTransientFailures(t *testing.T) { + tests := []struct { + name string + status int + body string + retryAfter string + permanent bool + code string + message string + response string + }{ + {name: "transient upstream", status: http.StatusServiceUnavailable, body: `{"error":"temporarily_unavailable"}`, retryAfter: "7", code: "temporarily_unavailable"}, + {name: "invalid grant", status: http.StatusBadRequest, body: `{"error":"invalid_grant","error_description":"Refresh token has expired","message":"Access denied","request_id":"req-123","refresh_token":"must-not-leak"}`, permanent: true, code: "invalid_grant", message: "Refresh token has expired · Access denied", response: `"refresh_token":"[REDACTED]"`}, + {name: "nested error", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_client","message":"Client rejected","detail":"Application disabled"}}`, permanent: true, code: "invalid_client", message: "Client rejected · Application disabled", response: `"detail":"Application disabled"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Header.Get("x-grok-client-version") != "" || request.Header.Get("x-grok-client-surface") != "" { + t.Fatalf("refresh request unexpectedly included device headers: %v", request.Header) + } + if request.FormValue("grant_type") != "refresh_token" || request.FormValue("refresh_token") != "refresh" { + t.Fatalf("form = %#v", request.Form) + } + header := make(http.Header) + if test.retryAfter != "" { + header.Set("Retry-After", test.retryAfter) + } + return &http.Response{StatusCode: test.status, Header: header, Body: io.NopCloser(strings.NewReader(test.body)), Request: request}, nil + })} + client := newOAuthClient(httpClient, func() string { return "0.2.111" }) + client.tokenURL = "https://auth.x.ai/oauth2/token" + _, err := client.refresh(context.Background(), "refresh") + var refreshErr *provider.CredentialRefreshError + if !errors.As(err, &refreshErr) || refreshErr.Status != test.status || refreshErr.Permanent != test.permanent || refreshErr.Code != test.code || refreshErr.Message != test.message { + t.Fatalf("error = %#v", err) + } + if test.response != "" && !strings.Contains(refreshErr.Response, test.response) { + t.Fatalf("response = %q, want substring %q", refreshErr.Response, test.response) + } + if strings.Contains(refreshErr.Response, "must-not-leak") { + t.Fatalf("response leaked refresh token: %q", refreshErr.Response) + } + if test.retryAfter != "" && refreshErr.RetryAfter != 7*time.Second { + t.Fatalf("retry after = %s", refreshErr.RetryAfter) + } + }) + } +} + +func TestOAuthScopeMatchesOfficialPersonalAccountContract(t *testing.T) { + values := strings.Fields(defaultOAuthScope) + want := []string{ + "openid", "profile", "email", "offline_access", "grok-cli:access", "api:access", + "conversations:read", "conversations:write", "workspaces:read", "workspaces:write", + } + if len(values) != len(want) { + t.Fatalf("scope count = %d, want %d: %v", len(values), len(want), values) + } + for index := range want { + if values[index] != want[index] { + t.Fatalf("scope[%d] = %q, want %q", index, values[index], want[index]) + } + } +} diff --git a/backend/internal/infra/provider/cli/responses_advanced_test.go b/backend/internal/infra/provider/cli/responses_advanced_test.go new file mode 100644 index 0000000000000000000000000000000000000000..645ac8517e9b87836c2d75fb3b1b4ce8bb62dce1 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_advanced_test.go @@ -0,0 +1,573 @@ +package cli + +import ( + "encoding/json" + "io" + "strings" + "testing" +) + +func TestResponsesCustomToolRequestHistoryAndJSONResponse(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"run code", + "tools":[{"type":"custom","name":"code","description":"Run code","format":{"type":"text"}}], + "tool_choice":{"type":"custom","name":"code"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("custom tool 未启用兼容层") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + parameters := tool["parameters"].(map[string]any) + if tool["type"] != "function" || tool["name"] != "code" || !strings.Contains(tool["description"].(string), "input string field") || parameters["additionalProperties"] != false { + t.Fatalf("上游 custom 包装 = %#v", tool) + } + choice := request["tool_choice"].(map[string]any) + if choice["type"] != "function" || choice["name"] != "code" { + t.Fatalf("上游 custom tool_choice = %#v", choice) + } + + restored, err := compatibility.normalizeResponseJSON([]byte(`{ + "id":"resp_1","object":"response", + "tools":[{"type":"function","name":"code"}], + "output":[{"id":"item_1","type":"function_call","call_id":"call_1","name":"code","arguments":"{\"input\":\"print(1)\"}"}] + }`)) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(restored, &response); err != nil { + t.Fatal(err) + } + call := response["output"].([]any)[0].(map[string]any) + if call["type"] != "custom_tool_call" || call["name"] != "code" || call["input"] != "print(1)" || call["arguments"] != nil { + t.Fatalf("下游 custom_tool_call = %#v", call) + } + visible := response["tools"].([]any)[0].(map[string]any) + if visible["type"] != "custom" || visible["format"].(map[string]any)["type"] != "text" { + t.Fatalf("下游 custom tool = %#v", visible) + } + + history, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"custom_tool_call","call_id":"call_1","name":"code","input":"print(1)"}, + {"type":"custom_tool_call_output","call_id":"call_1","output":"1"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var next map[string]any + if err := json.Unmarshal(history, &next); err != nil { + t.Fatal(err) + } + items := next["input"].([]any) + historyCall := items[0].(map[string]any) + historyOutput := items[1].(map[string]any) + if historyCall["type"] != "function_call" || historyCall["arguments"] != `{"input":"print(1)"}` || historyCall["input"] != nil { + t.Fatalf("custom call 历史 = %#v", historyCall) + } + if historyOutput["type"] != "function_call_output" || historyOutput["call_id"] != "call_1" || historyOutput["output"] != "1" { + t.Fatalf("custom output 历史 = %#v", historyOutput) + } +} + +func TestResponsesCustomToolStreamUsesCustomEvents(t *testing.T) { + _, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"run", + "tools":[{"type":"custom","name":"code"}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + source := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"code","arguments":""}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"item_1","output_index":0,"delta":"{\"input\":\"pri"}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"item_1","output_index":0,"delta":"nt(1)\"}"}`, + ``, + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","item_id":"item_1","output_index":0,"arguments":"{\"input\":\"print(1)\"}"}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"code","arguments":"{\"input\":\"print(1)\"}"}}`, + ``, + }, "\n") + stream := compatibility.normalizeResponseStream(io.NopCloser(strings.NewReader(source))) + converted, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(converted) + for _, expected := range []string{ + `"type":"custom_tool_call"`, + `event: response.custom_tool_call_input.delta`, + `"delta":"print(1)"`, + `event: response.custom_tool_call_input.done`, + `"input":"print(1)"`, + } { + if !strings.Contains(text, expected) { + t.Fatalf("custom SSE 缺少 %s:\n%s", expected, text) + } + } + if strings.Contains(text, "response.function_call_arguments") || strings.Contains(text, `"arguments"`) { + t.Fatalf("上游 function 参数事件泄露到 custom SSE:\n%s", text) + } +} + +func TestResponsesCustomGrammarDowngradesWithoutRejectingRequest(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"run", + "tools":[{"type":"custom","name":"code","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + if tool["type"] != "function" || tool["name"] != "code" { + t.Fatalf("tool = %#v", tool) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "custom_tool_format_downgraded") { + t.Fatalf("compatibility = %#v", compatibility) + } +} + +func TestResponsesWebSearchAliasesAndOptions(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"search", + "tools":[{"type":"web_search_preview"}], + "tool_choice":{"type":"web_search_preview"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("web search alias 未启用兼容层") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + if len(tool) != 1 || tool["type"] != "web_search" || request["tool_choice"] != "required" { + t.Fatalf("web search alias = %#v, choice = %#v", tool, request["tool_choice"]) + } + + normalized, compatibility, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"search", + "tools":[{"type":"web_search","external_web_access":true,"indexed_web_access":true,"search_content_types":["text"],"search_context_size":"low","user_location":{"type":"approximate","country":"CN"},"filters":{"allowed_domains":[]}}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("Codex web_search 控制字段未启用兼容层") + } + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool = request["tools"].([]any)[0].(map[string]any) + if len(tool) != 1 || tool["type"] != "web_search" { + t.Fatalf("Codex web search 未降级: %#v", tool) + } + if !strings.Contains(compatibility.warningHeader(), "web_search_controls_downgraded") { + t.Fatalf("compatibility warnings = %q", compatibility.warningHeader()) + } + + for _, supported := range []string{ + `"filters":{"allowed_domains":["example.com"]}`, + `"allowed_domains":["example.com"]`, + } { + normalized, compatibility, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"search","tools":[{"type":"web_search",`+supported+`}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + request = nil + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool = request["tools"].([]any)[0].(map[string]any) + domains := tool["filters"].(map[string]any)["allowed_domains"].([]any) + if len(domains) != 1 || domains[0] != "example.com" { + t.Fatalf("allowed_domains 未保留: %#v", tool) + } + if strings.Contains(supported, `"allowed_domains"`) && !strings.Contains(supported, `"filters"`) && (compatibility == nil || !strings.Contains(compatibility.warningHeader(), "web_search_allowed_domains_normalized")) { + t.Fatalf("top-level allowed_domains warning = %#v", compatibility) + } + } + + for _, restricted := range []string{ + `"search_content_types":["image"]`, + `"filters":{"blocked_domains":["example.com"]}`, + } { + normalized, compatibility, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"search","tools":[{"type":"web_search",`+restricted+`}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + request = nil + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool = request["tools"].([]any)[0].(map[string]any) + if len(tool) != 1 || tool["type"] != "web_search" || compatibility == nil || compatibility.warningHeader() == "" { + t.Fatalf("restricted web search should downgrade: tool=%#v compatibility=%#v", tool, compatibility) + } + } + + normalized, compatibility, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"do not access the internet", + "tools":[{"type":"web_search","external_web_access":false}], + "tool_choice":{"type":"web_search"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + request = nil + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + if _, exists := request["tools"]; exists { + t.Fatalf("disabled web search request = %#v", request) + } + if _, exists := request["tool_choice"]; exists { + t.Fatalf("disabled web search tool_choice = %#v", request["tool_choice"]) + } + warnings := compatibility.warningHeader() + if !strings.Contains(warnings, "web_search_disabled_no_external_access") || !strings.Contains(warnings, "tool_choice_without_tools_ignored") { + t.Fatalf("compatibility warnings = %q", warnings) + } + + normalized, compatibility, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"use local tools only", + "tools":[ + {"type":"web_search","external_web_access":false}, + {"type":"function","name":"local_lookup","parameters":{"type":"object"}} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + request = nil + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["name"] != "local_lookup" { + t.Fatalf("local-only tools = %#v", tools) + } + if !strings.Contains(compatibility.warningHeader(), "web_search_disabled_no_external_access") { + t.Fatalf("compatibility warnings = %q", compatibility.warningHeader()) + } + + normalized, _, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"offline only", + "tools":[{"type":"web_search","external_web_access":false}], + "tool_choice":"auto" + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + request = nil + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + if _, exists := request["tools"]; exists { + t.Fatalf("disabled automatic web search request = %#v", request) + } + if _, exists := request["tool_choice"]; exists { + t.Fatalf("disabled automatic web search tool_choice = %#v", request["tool_choice"]) + } + + normalized, compatibility, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":"search", + "tools":[{"type":"web_search","unknown_control":true}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + request = nil + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool = request["tools"].([]any)[0].(map[string]any) + if len(tool) != 1 || tool["type"] != "web_search" || compatibility == nil || !strings.Contains(compatibility.warningHeader(), "web_search_unknown_controls_ignored") { + t.Fatalf("unknown web search option should downgrade: tool=%#v compatibility=%#v", tool, compatibility) + } +} + +func TestResponsesBuild02110NativeAndUnsupportedToolMatrix(t *testing.T) { + native := []string{"x_search", "image_generation", "collections_search", "file_search", "code_execution", "code_interpreter", "mcp", "shell"} + for _, kind := range native { + t.Run("native_"+kind, func(t *testing.T) { + body := []byte(`{"model":"public","input":"hello","tools":[{"type":"` + kind + `"}]}`) + normalized, _, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + if tool["type"] != kind { + t.Fatalf("tool = %#v", tool) + } + }) + } + + compatible := []string{"local_shell", "apply_patch"} + for _, kind := range compatible { + t.Run("compatible_"+kind, func(t *testing.T) { + body := []byte(`{"model":"public","input":"hello","tools":[{"type":"` + kind + `"}]}`) + if _, compatibility, err := normalizeResponsesRequest(body, "grok-4.5"); err != nil || compatibility == nil { + t.Fatalf("compatibility=%#v error=%v", compatibility, err) + } + }) + } + + unsupported := []string{"computer_use_preview", "unknown_tool"} + for _, kind := range unsupported { + t.Run("unsupported_"+kind, func(t *testing.T) { + body := []byte(`{"model":"public","input":"hello","tools":[{"type":"` + kind + `"}]}`) + _, _, err := normalizeResponsesRequest(body, "grok-4.5") + requestErr, ok := err.(*responsesRequestError) + if !ok || requestErr.Code != "unsupported_parameter" || requestErr.Param != "tools[0].type" || !strings.Contains(requestErr.Message, "0.2.110") { + t.Fatalf("error = %#v", err) + } + }) + } +} + +func TestResponsesXSearchDateBounds(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"search", + "tools":[{"type":"x_search","from_date":"2026-07-01","to_date":"2026-07-23"}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + if tool["from_date"] != "2026-07-01" || tool["to_date"] != "2026-07-23" { + t.Fatalf("x_search date bounds = %#v", tool) + } + + tests := []struct { + name string + body string + param string + }{ + {name: "non string", body: `{"model":"public","input":"search","tools":[{"type":"x_search","from_date":1}]}`, param: "tools[0].from_date"}, + {name: "not canonical", body: `{"model":"public","input":"search","tools":[{"type":"x_search","from_date":"2026-7-01"}]}`, param: "tools[0].from_date"}, + {name: "invalid date", body: `{"model":"public","input":"search","tools":[{"type":"x_search","to_date":"2026-02-30"}]}`, param: "tools[0].to_date"}, + {name: "inverted", body: `{"model":"public","input":"search","tools":[{"type":"x_search","from_date":"2026-07-24","to_date":"2026-07-23"}]}`, param: "tools[0].from_date"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, _, err := normalizeResponsesRequest([]byte(test.body), "grok-4.5") + requestErr, ok := err.(*responsesRequestError) + if !ok || requestErr.Code != "invalid_parameter" || requestErr.Param != test.param { + t.Fatalf("error = %#v", err) + } + }) + } +} + +func TestResponsesHostedToolChoiceNarrowsToMatchingTool(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"draw", + "tools":[{"type":"image_generation"},{"type":"web_search"}], + "tool_choice":{"type":"image_generation"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["type"] != "image_generation" || request["tool_choice"] != "required" { + t.Fatalf("request = %#v", request) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "hosted_tool_choice_tools_narrowed") { + t.Fatalf("compatibility = %#v", compatibility) + } +} + +func TestResponsesMCPDeferLoadingUsesClientToolSearch(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"find tools", + "tools":[ + {"type":"mcp","server_label":"github","server_url":"https://example.com/mcp","description":"GitHub tools","defer_loading":true}, + {"type":"tool_search","execution":"client"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["type"] != "function" || !strings.Contains(tools[0].(map[string]any)["description"].(string), "github: GitHub tools") { + t.Fatalf("上游 tools = %#v", tools) + } + + loaded, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"tool_search_call","execution":"client","call_id":"search_1","arguments":{}}, + {"type":"tool_search_output","execution":"client","call_id":"search_1","tools":[ + {"type":"mcp","server_label":"github","server_url":"https://example.com/mcp","defer_loading":true} + ]} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(loaded, &request); err != nil { + t.Fatal(err) + } + loadedTool := request["tools"].([]any)[0].(map[string]any) + if loadedTool["type"] != "mcp" || loadedTool["defer_loading"] != nil { + t.Fatalf("已加载 MCP = %#v", loadedTool) + } +} + +func TestResponsesCodexHistoryItemsAreStructuredOrVisible(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"agent_message","author":"worker","recipient":"root","content":[{"type":"input_text","text":"analysis result"}]}, + {"type":"local_shell_call","call_id":"shell_1","status":"completed","action":{"type":"exec","command":"pwd"}}, + {"type":"local_shell_call_output","call_id":"shell_1","status":"completed","output":"/workspace\n"}, + {"type":"mcp_tool_call_output","call_id":"mcp_1","output":{"content":"done"}} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + if items[0].(map[string]any)["role"] != "developer" || items[1].(map[string]any)["type"] != "shell_call" || items[2].(map[string]any)["type"] != "shell_call_output" || items[3].(map[string]any)["role"] != "developer" { + t.Fatalf("Codex 历史转换 = %#v", items) + } + for index, expected := range map[int]string{0: "Agent message", 3: "MCP tool output"} { + content := items[index].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(content, expected) { + t.Fatalf("input[%d] = %q", index, content) + } + } + + normalized, _, err = normalizeResponsesRequest([]byte(`{ + "model":"public","input":[{"type":"agent_message","content":[{"type":"encrypted_text","encrypted_content":"opaque"}]}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + marker := request["input"].([]any)[0].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(marker, "encrypted inter-agent message") { + t.Fatalf("opaque agent marker = %q", marker) + } +} + +func TestResponsesStreamFiltersPrivateEventsAndPreservesSSEFields(t *testing.T) { + _, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"lookup", + "tools":[{"type":"namespace","name":"crm","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + source := strings.Join([]string{ + `: keep-alive`, + `id: event-1`, + `retry: 1500`, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"crm__lookup","arguments":"{}"}}`, + ``, + `event: xai.internal.trace`, + `data: {"type":"xai.internal.trace","secret":"private"}`, + ``, + `data: {"type":"xai.internal.metrics","value":1}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + stream := compatibility.normalizeResponseStream(io.NopCloser(strings.NewReader(source))) + converted, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(converted) + for _, expected := range []string{": keep-alive", "id: event-1", "retry: 1500", `"namespace":"crm"`, "data: [DONE]"} { + if !strings.Contains(text, expected) { + t.Fatalf("SSE 缺少 %q:\n%s", expected, text) + } + } + if strings.Contains(text, "xai.internal") || strings.Contains(text, "private") { + t.Fatalf("私有事件泄露:\n%s", text) + } +} + +func TestResponsesToolAliasesAreUniqueAndBounded(t *testing.T) { + longNamespace := strings.Repeat("n", 100) + longName := strings.Repeat("t", 100) + body := []byte(`{ + "model":"public","input":"hello","tools":[ + {"type":"function","name":"a__bc","parameters":{"type":"object"}}, + {"type":"namespace","name":"a","tools":[{"type":"function","name":"bc","parameters":{"type":"object"}}]}, + {"type":"namespace","name":"` + longNamespace + `","tools":[{"type":"function","name":"` + longName + `","parameters":{"type":"object"}}]} + ] + }`) + normalized, _, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + aliases := map[string]bool{} + for _, rawTool := range tools { + name := rawTool.(map[string]any)["name"].(string) + if len(name) > maxBuildToolAliasLength { + t.Fatalf("alias 长度 = %d: %q", len(name), name) + } + if aliases[name] { + t.Fatalf("alias 冲突: %q", name) + } + aliases[name] = true + } + if tools[0].(map[string]any)["name"] != "a__bc" || tools[1].(map[string]any)["name"] == "a__bc" || !strings.Contains(tools[1].(map[string]any)["name"].(string), "__") { + t.Fatalf("碰撞 alias = %#v", tools) + } +} diff --git a/backend/internal/infra/provider/cli/responses_arguments.go b/backend/internal/infra/provider/cli/responses_arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..69608951fed48e3cc9b4751da7eed778e9f2b471 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_arguments.go @@ -0,0 +1,264 @@ +package cli + +import ( + "encoding/json" + "io" + "strconv" + "strings" +) + +const ( + maxExactJSONInteger int64 = 1<<53 - 1 + maxNormalizedNumberBytes = 256 + maxExactJSONIntegerText = "9007199254740991" +) + +// normalizeFunctionArguments repairs semantically integral JSON numbers that strict +// downstream decoders reject for integer fields. Grok Build can emit 60000.0 where +// clients such as Codex require the integer spelling 60000. +func normalizeFunctionArguments(arguments string, schema any) (string, bool) { + if strings.TrimSpace(arguments) == "" { + return arguments, false + } + decoder := json.NewDecoder(strings.NewReader(arguments)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return arguments, false + } + if err := decoder.Decode(new(any)); err != io.EOF { + return arguments, false + } + root, ok := schema.(map[string]any) + if !ok { + return arguments, false + } + normalized, changed := normalizeArgumentValue(value, root, root, 0) + if !changed { + return arguments, false + } + encoded, err := json.Marshal(normalized) + if err != nil { + return arguments, false + } + return string(encoded), true +} + +func normalizeArgumentValue(value any, schema, root map[string]any, depth int) (any, bool) { + if depth > 64 { + return value, false + } + changed := false + if ref, ok := schema["$ref"].(string); ok { + if resolved, ok := resolveLocalSchemaRef(root, ref); ok { + var current bool + value, current = normalizeArgumentValue(value, resolved, root, depth+1) + changed = changed || current + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf"} { + branches, _ := schema[keyword].([]any) + for _, rawBranch := range branches { + branch, ok := rawBranch.(map[string]any) + if !ok { + continue + } + var current bool + value, current = normalizeArgumentValue(value, branch, root, depth+1) + changed = changed || current + } + } + if number, ok := value.(json.Number); ok && schemaRequiresInteger(schema) { + if normalized, ok := normalizeIntegralNumber(number); ok { + return normalized, true + } + return value, changed + } + switch typed := value.(type) { + case map[string]any: + properties, _ := schema["properties"].(map[string]any) + additional, _ := schema["additionalProperties"].(map[string]any) + for key, item := range typed { + property, ok := properties[key].(map[string]any) + if !ok { + property = additional + } + if property == nil { + continue + } + normalized, current := normalizeArgumentValue(item, property, root, depth+1) + if current { + typed[key] = normalized + changed = true + } + } + case []any: + prefixItems, _ := schema["prefixItems"].([]any) + items, _ := schema["items"].(map[string]any) + for index, item := range typed { + itemSchema := items + if index < len(prefixItems) { + if prefixSchema, ok := prefixItems[index].(map[string]any); ok { + itemSchema = prefixSchema + } + } + if itemSchema == nil { + continue + } + normalized, current := normalizeArgumentValue(item, itemSchema, root, depth+1) + if current { + typed[index] = normalized + changed = true + } + } + } + return value, changed +} + +func schemaRequiresInteger(schema map[string]any) bool { + switch value := schema["type"].(type) { + case string: + return value == "integer" + case []any: + integer := false + for _, item := range value { + kind, _ := item.(string) + if kind == "number" { + return false + } + integer = integer || kind == "integer" + } + return integer + default: + return false + } +} + +func normalizeIntegralNumber(number json.Number) (json.Number, bool) { + raw := number.String() + if len(raw) > maxNormalizedNumberBytes || !strings.ContainsAny(raw, ".eE") { + return number, false + } + mantissa := raw + exponentText := "" + if index := strings.IndexAny(mantissa, "eE"); index >= 0 { + exponentText = mantissa[index+1:] + mantissa = mantissa[:index] + } + negative := strings.HasPrefix(mantissa, "-") + if negative { + mantissa = strings.TrimPrefix(mantissa, "-") + } + whole, fraction, hasFraction := strings.Cut(mantissa, ".") + if !hasFraction { + fraction = "" + } + digits := strings.TrimLeft(whole+fraction, "0") + if digits == "" { + return json.Number("0"), raw != "0" + } + exponent, ok := parseBoundedDecimalExponent(exponentText) + if !ok { + return number, false + } + decimalShift := exponent - len(fraction) + if decimalShift < 0 { + fractionalDigits := -decimalShift + if fractionalDigits > len(digits) || strings.Trim(digits[len(digits)-fractionalDigits:], "0") != "" { + return number, false + } + digits = strings.TrimLeft(digits[:len(digits)-fractionalDigits], "0") + if digits == "" { + return json.Number("0"), true + } + } else if decimalShift > 0 { + if decimalShift > len(maxExactJSONIntegerText)-len(digits) { + return number, false + } + digits += strings.Repeat("0", decimalShift) + } + if len(digits) > len(maxExactJSONIntegerText) || len(digits) == len(maxExactJSONIntegerText) && digits > maxExactJSONIntegerText { + return number, false + } + normalized := digits + if negative { + normalized = "-" + normalized + } + return json.Number(normalized), normalized != raw +} + +func parseBoundedDecimalExponent(raw string) (int, bool) { + if raw == "" { + return 0, true + } + sign := 1 + if raw[0] == '+' || raw[0] == '-' { + if raw[0] == '-' { + sign = -1 + } + raw = raw[1:] + if raw == "" { + return 0, false + } + } + raw = strings.TrimLeft(raw, "0") + if raw == "" { + return 0, true + } + if len(raw) > 9 { + return 0, false + } + exponent, err := strconv.Atoi(raw) + if err != nil { + return 0, false + } + return sign * exponent, true +} + +func schemaContainsInteger(value any) bool { + root, ok := value.(map[string]any) + if !ok { + return false + } + return schemaContainsReachableInteger(root, root, make(map[string]struct{}), 0) +} + +func schemaContainsReachableInteger(schema, root map[string]any, visitedRefs map[string]struct{}, depth int) bool { + if depth > 64 || schema == nil { + return false + } + if schemaRequiresInteger(schema) { + return true + } + if ref, ok := schema["$ref"].(string); ok { + if _, visited := visitedRefs[ref]; !visited { + visitedRefs[ref] = struct{}{} + if resolved, resolvedOK := resolveLocalSchemaRef(root, ref); resolvedOK && schemaContainsReachableInteger(resolved, root, visitedRefs, depth+1) { + return true + } + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { + branches, _ := schema[keyword].([]any) + for _, rawBranch := range branches { + branch, ok := rawBranch.(map[string]any) + if ok && schemaContainsReachableInteger(branch, root, visitedRefs, depth+1) { + return true + } + } + } + for _, keyword := range []string{"items", "additionalProperties"} { + child, _ := schema[keyword].(map[string]any) + if schemaContainsReachableInteger(child, root, visitedRefs, depth+1) { + return true + } + } + properties, _ := schema["properties"].(map[string]any) + for _, rawProperty := range properties { + property, ok := rawProperty.(map[string]any) + if ok && schemaContainsReachableInteger(property, root, visitedRefs, depth+1) { + return true + } + } + return false +} diff --git a/backend/internal/infra/provider/cli/responses_arguments_test.go b/backend/internal/infra/provider/cli/responses_arguments_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9d85c5281a68e0f66cfabd7c6f9d223347476fe6 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_arguments_test.go @@ -0,0 +1,277 @@ +package cli + +import ( + "encoding/json" + "io" + "strings" + "testing" +) + +func TestNormalizeFunctionArgumentsCoercesSchemaIntegers(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "timeout_ms": map[string]any{"type": "integer"}, + "ratio": map[string]any{"type": "number"}, + "items": map[string]any{ + "type": "array", + "items": map[string]any{"anyOf": []any{map[string]any{"type": "integer"}, map[string]any{"type": "null"}}}, + }, + }, + } + arguments := `{"timeout_ms":60000.0,"ratio":2.0,"items":[1.0,null,2.5]}` + normalized, changed := normalizeFunctionArguments(arguments, schema) + if !changed { + t.Fatal("expected integer arguments to be normalized") + } + if normalized != `{"items":[1,null,2.5],"ratio":2.0,"timeout_ms":60000}` { + t.Fatalf("normalized arguments = %s", normalized) + } +} + +func TestNormalizeIntegralNumberUsesExactBoundedDecimalArithmetic(t *testing.T) { + tests := []struct { + name string + input string + expected string + shouldChange bool + }{ + {name: "decimal", input: "60000.0", expected: "60000", shouldChange: true}, + {name: "exponent", input: "6e4", expected: "60000", shouldChange: true}, + {name: "negative exponent integer", input: "1000e-3", expected: "1", shouldChange: true}, + {name: "fraction with exponent", input: "1.2300e2", expected: "123", shouldChange: true}, + {name: "negative zero", input: "-0.0", expected: "0", shouldChange: true}, + {name: "positive exact limit", input: "9007199254740991.0", expected: "9007199254740991", shouldChange: true}, + {name: "negative exact limit", input: "-9007199254740991.0", expected: "-9007199254740991", shouldChange: true}, + {name: "fraction", input: "1e-1", expected: "1e-1", shouldChange: false}, + {name: "rounded fraction", input: "9007199254740990.5", expected: "9007199254740990.5", shouldChange: false}, + {name: "outside exact limit", input: "9007199254740992.0", expected: "9007199254740992.0", shouldChange: false}, + {name: "huge exponent", input: "1e1000000000", expected: "1e1000000000", shouldChange: false}, + {name: "zero huge exponent", input: "0e1000000000", expected: "0", shouldChange: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalized, changed := normalizeIntegralNumber(json.Number(test.input)) + if changed != test.shouldChange || normalized.String() != test.expected { + t.Fatalf("normalizeIntegralNumber(%q) = (%q, %v), want (%q, %v)", test.input, normalized, changed, test.expected, test.shouldChange) + } + }) + } +} + +func TestNormalizeFunctionArgumentsPreservesUnsafeValues(t *testing.T) { + schema := map[string]any{"type": "object", "properties": map[string]any{ + "fraction": map[string]any{"type": "integer"}, + "roundedFraction": map[string]any{"type": "integer"}, + "large": map[string]any{"type": "integer"}, + "hugeExponent": map[string]any{"type": "integer"}, + }} + arguments := `{"fraction":1.5,"roundedFraction":9007199254740990.5,"large":9007199254740992.0,"hugeExponent":1e1000000000}` + if normalized, changed := normalizeFunctionArguments(arguments, schema); changed || normalized != arguments { + t.Fatalf("unsafe arguments changed: changed=%v value=%s", changed, normalized) + } +} + +func TestSchemaContainsIntegerOnlyFollowsReachableConstraints(t *testing.T) { + unreferenced := map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + "$defs": map[string]any{"unused": map[string]any{"type": "integer"}}, + } + if schemaContainsInteger(unreferenced) { + t.Fatal("unreferenced integer definition enabled argument normalization") + } + referenced := map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"$ref": "#/$defs/count"}}, + "$defs": map[string]any{"count": map[string]any{"type": "integer"}}, + } + if !schemaContainsInteger(referenced) { + t.Fatal("referenced integer definition was not detected") + } +} + +func TestNormalizeFunctionArgumentsFollowsLocalRefs(t *testing.T) { + schema := map[string]any{ + "$ref": "#/$defs/arguments", + "$defs": map[string]any{"arguments": map[string]any{ + "type": "object", + "properties": map[string]any{"timeout_ms": map[string]any{"type": "integer"}}, + }}, + } + normalized, changed := normalizeFunctionArguments(`{"timeout_ms":6e4}`, schema) + if !changed || normalized != `{"timeout_ms":60000}` { + t.Fatalf("referenced integer was not normalized: changed=%v value=%s", changed, normalized) + } +} + +func TestResponsesIntegerArgumentsNormalizedInJSONResponse(t *testing.T) { + request := []byte(`{ + "model":"public", + "tools":[{"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}}}}] + }`) + _, compatibility, err := normalizeResponsesRequest(request, "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("integer schema did not enable response compatibility") + } + response, err := compatibility.normalizeResponseJSON([]byte(`{ + "id":"resp_1","object":"response", + "output":[{"type":"function_call","call_id":"call_1","name":"wait_agent","arguments":"{\"timeout_ms\":60000.0}"}] + }`)) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(response, &payload); err != nil { + t.Fatal(err) + } + call := payload["output"].([]any)[0].(map[string]any) + if call["arguments"] != `{"timeout_ms":60000}` { + t.Fatalf("function arguments = %q", call["arguments"]) + } +} + +func TestResponsesIntegerArgumentsNormalizedInStream(t *testing.T) { + request := []byte(`{ + "model":"public", + "stream":true, + "tools":[{"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}}}}] + }`) + _, compatibility, err := normalizeResponsesRequest(request, "grok-4.5") + if err != nil { + t.Fatal(err) + } + stream := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"wait_agent","arguments":""}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"timeout_ms\":60000"}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":".0}"}`, + ``, + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","item_id":"fc_1","arguments":"{\"timeout_ms\":60000.0}"}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"wait_agent","arguments":"{\"timeout_ms\":60000.0}"}}`, + ``, + }, "\n") + body, err := io.ReadAll(compatibility.normalizeResponseStream(io.NopCloser(strings.NewReader(stream)))) + if err != nil { + t.Fatal(err) + } + text := string(body) + if strings.Contains(text, `60000.0`) { + t.Fatalf("stream retained floating integer: %s", text) + } + if strings.Count(text, `response.function_call_arguments.delta`) != 2 { + // One occurrence is the event line and one is the JSON type field. + t.Fatalf("unexpected normalized delta count: %s", text) + } + if !strings.Contains(text, `{\"timeout_ms\":60000}`) { + t.Fatalf("normalized arguments missing: %s", text) + } +} + +func TestResponsesIntegerArgumentsParallelStreamSequenceIsMonotonic(t *testing.T) { + request := []byte(`{ + "model":"public", + "stream":true, + "parallel_tool_calls":true, + "tools":[{"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}}}}] + }`) + _, compatibility, err := normalizeResponsesRequest(request, "grok-4.5") + if err != nil { + t.Fatal(err) + } + stream := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","sequence_number":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"wait_agent","arguments":""}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","sequence_number":1,"item":{"id":"fc_2","type":"function_call","call_id":"call_2","name":"wait_agent","arguments":""}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","delta":"{\"timeout_ms\":1.0}"}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_2","delta":"{\"timeout_ms\":2.0}"}`, + ``, + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","sequence_number":4,"item_id":"fc_1","arguments":"{\"timeout_ms\":1.0}"}`, + ``, + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","sequence_number":5,"item_id":"fc_2","arguments":"{\"timeout_ms\":2.0}"}`, + ``, + }, "\n") + body, err := io.ReadAll(compatibility.normalizeResponseStream(io.NopCloser(strings.NewReader(stream)))) + if err != nil { + t.Fatal(err) + } + lastSequence := int64(-1) + for _, line := range strings.Split(string(body), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err != nil { + t.Fatal(err) + } + sequence, ok := payload["sequence_number"].(float64) + if !ok { + continue + } + current := int64(sequence) + if current <= lastSequence { + t.Fatalf("sequence_number is not strictly increasing: previous=%d current=%d\n%s", lastSequence, current, body) + } + lastSequence = current + } +} + +func TestResponsesIntegerArgumentsBufferOverflowFallsBackToStreaming(t *testing.T) { + request := []byte(`{ + "model":"public", + "stream":true, + "tools":[{"type":"function","name":"write","parameters":{"type":"object","properties":{"content":{"type":"string"},"mode":{"type":"integer"}}}}] + }`) + _, compatibility, err := normalizeResponsesRequest(request, "grok-4.5") + if err != nil { + t.Fatal(err) + } + added := []byte(`{"type":"response.output_item.added","sequence_number":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"write","arguments":""}}`) + if _, err := compatibility.rewriteStreamData("response.output_item.added", added); err != nil { + t.Fatal(err) + } + first := strings.Repeat("a", maxBufferedFunctionArgumentsBytes) + firstPayload, _ := json.Marshal(map[string]any{"type": "response.function_call_arguments.delta", "sequence_number": 1, "item_id": "fc_1", "delta": first}) + outputs, err := compatibility.rewriteStreamData("response.function_call_arguments.delta", firstPayload) + if err != nil || len(outputs) != 0 { + t.Fatalf("first buffered delta: outputs=%d err=%v", len(outputs), err) + } + overflowPayload := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","delta":"b"}`) + outputs, err = compatibility.rewriteStreamData("response.function_call_arguments.delta", overflowPayload) + if err != nil { + t.Fatal(err) + } + if len(outputs) != 2 { + t.Fatalf("overflow outputs = %d", len(outputs)) + } + flushed := outputs[0].Payload + if delta, _ := flushed["delta"].(string); len(delta) != maxBufferedFunctionArgumentsBytes { + t.Fatalf("flushed delta length = %d", len(delta)) + } + current := outputs[1].Payload + if current["delta"] != "b" { + t.Fatalf("current delta = %q", current["delta"]) + } + state := compatibility.streamCalls["fc_1"] + if state == nil || !state.passthrough || state.arguments.Len() != 0 || compatibility.streamArgumentBytes != 0 { + t.Fatal("overflow did not release the buffered arguments") + } +} diff --git a/backend/internal/infra/provider/cli/responses_cache_route.go b/backend/internal/infra/provider/cli/responses_cache_route.go new file mode 100644 index 0000000000000000000000000000000000000000..5caae244ee7db8d9ace78aaa6928c28203d5cbfd --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_cache_route.go @@ -0,0 +1,155 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" +) + +// buildPromptCacheRoute records internal tools added to route this request through the cache-capable path. +// injectedToolTypes restores the client's original visible tool list during response processing. +type buildPromptCacheRoute struct { + filterXSearch bool + injectedToolTypes map[string]struct{} + clientDeclaredTools map[string]struct{} +} + +func prepareBuildPromptCacheRoute(body []byte, operation, model, promptCacheKey string, allowClientTools bool) ([]byte, buildPromptCacheRoute, error) { + route := buildPromptCacheRoute{ + injectedToolTypes: make(map[string]struct{}), + clientDeclaredTools: make(map[string]struct{}), + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, route, fmt.Errorf("解析 Build prompt cache 请求: %w", err) + } + if payload == nil { + payload = make(map[string]json.RawMessage) + } + tools, err := buildCacheRouteTools(payload) + if err != nil { + return nil, route, err + } + for _, rawTool := range tools { + kind, name := buildCacheToolIdentity(rawTool) + if kind == "function" || kind == "custom" { + if name != "" { + route.clientDeclaredTools[name] = struct{}{} + } + } + if kind == "x_search" { + route.filterXSearch = true + } + } + + // Hide upstream internal subcalls even when the client explicitly declares x_search. + // Cache routing itself applies only to plain-text conversations with a stable cache session identity. + if strings.TrimSpace(promptCacheKey) == "" || !isBuildCacheConversationOperation(operation) || isBuildCacheMediaModel(model) || hasBuildCacheToolType(tools, "image_generation") { + return body, route, nil + } + + if len(tools) == 0 { + // A tool-free request uses none to select the cache-capable route without granting search capability. + tools = append(tools, json.RawMessage(`{"type":"web_search"}`), json.RawMessage(`{"type":"x_search"}`)) + payload["tool_choice"] = mustJSON("none") + route.injectedToolTypes["web_search"] = struct{}{} + route.injectedToolTypes["x_search"] = struct{}{} + route.filterXSearch = true + } else if !hasBuildCacheToolType(tools, "x_search") && (allowClientTools || hasBuildCacheToolType(tools, "web_search")) { + // With client tools, add only the x_search route required by the official Build behavior and preserve + // the original tool_choice. Do not also expose web_search. + tools = append(tools, json.RawMessage(`{"type":"x_search"}`)) + route.injectedToolTypes["x_search"] = struct{}{} + route.filterXSearch = true + } + payload["tools"] = mustJSON(tools) + if _, injected := route.injectedToolTypes["x_search"]; injected { + updatedChoice, choiceErr := appendBuildCacheXSearchToAllowedTools(payload["tool_choice"]) + if choiceErr != nil { + return nil, route, choiceErr + } + if len(updatedChoice) > 0 { + payload["tool_choice"] = updatedChoice + } + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil, route, fmt.Errorf("编码 Build prompt cache 请求: %w", err) + } + return encoded, route, nil +} + +func appendBuildCacheXSearchToAllowedTools(raw json.RawMessage) (json.RawMessage, error) { + if isEmptyJSON(raw) { + return nil, nil + } + var choice map[string]json.RawMessage + if json.Unmarshal(raw, &choice) != nil || choice == nil { + return nil, nil + } + var choiceType string + _ = json.Unmarshal(choice["type"], &choiceType) + if strings.TrimSpace(choiceType) != "allowed_tools" { + return nil, nil + } + var allowed []json.RawMessage + if json.Unmarshal(choice["tools"], &allowed) != nil { + return nil, nil + } + for _, item := range allowed { + kind, _ := buildCacheToolIdentity(item) + if kind == "x_search" { + return nil, nil + } + } + allowed = append(allowed, json.RawMessage(`{"type":"x_search"}`)) + choice["tools"] = mustJSON(allowed) + return json.Marshal(choice) +} + +func buildCacheRouteTools(payload map[string]json.RawMessage) ([]json.RawMessage, error) { + raw, exists := payload["tools"] + if !exists || isEmptyJSON(raw) { + return nil, nil + } + var tools []json.RawMessage + if json.Unmarshal(raw, &tools) != nil { + return nil, &responsesRequestError{Message: "tools 必须是数组", Param: "tools", Code: "invalid_parameter"} + } + return tools, nil +} + +func buildCacheToolIdentity(raw json.RawMessage) (kind, name string) { + var tool struct { + Type string `json:"type"` + Name string `json:"name"` + } + if json.Unmarshal(raw, &tool) != nil { + return "", "" + } + return strings.TrimSpace(tool.Type), strings.TrimSpace(tool.Name) +} + +func hasBuildCacheToolType(tools []json.RawMessage, kind string) bool { + for _, rawTool := range tools { + toolType, _ := buildCacheToolIdentity(rawTool) + if toolType == kind { + return true + } + } + return false +} + +func isBuildCacheConversationOperation(operation string) bool { + switch strings.TrimSpace(operation) { + case "", "responses", "chat", "messages": + return true + default: + return false + } +} + +func isBuildCacheMediaModel(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(model, "image") || strings.Contains(model, "imagine") || strings.Contains(model, "video") +} diff --git a/backend/internal/infra/provider/cli/responses_cache_route_test.go b/backend/internal/infra/provider/cli/responses_cache_route_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4fdcb6dacacb125bfe4fb8c5294333093ef0fd13 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_cache_route_test.go @@ -0,0 +1,237 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" +) + +func TestPrepareBuildPromptCacheRouteToolFree(t *testing.T) { + body, route, err := prepareBuildPromptCacheRoute([]byte(`{"model":"grok-4.5","input":"hello"}`), "responses", "grok-4.5", "cache-key", false) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + tools := payload["tools"].([]any) + if len(tools) != 2 || stringField(tools[0].(map[string]any), "type") != "web_search" || stringField(tools[1].(map[string]any), "type") != "x_search" { + t.Fatalf("tools = %#v", tools) + } + if payload["tool_choice"] != "none" || !route.filterXSearch || len(route.injectedToolTypes) != 2 { + t.Fatalf("route = %#v payload = %#v", route, payload) + } +} + +func TestPrepareBuildPromptCacheRouteComplementsToolBearingRequest(t *testing.T) { + body, route, err := prepareBuildPromptCacheRoute([]byte(`{ + "model":"grok-4.5","input":"hello","tool_choice":"auto", + "tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}] + }`), "messages", "grok-4.5", "cache-key", true) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + tools := payload["tools"].([]any) + if len(tools) != 2 || stringField(tools[1].(map[string]any), "type") != "x_search" || payload["tool_choice"] != "auto" { + t.Fatalf("payload = %#v", payload) + } + if _, ok := route.clientDeclaredTools["Read"]; !ok || len(route.injectedToolTypes) != 1 || !route.filterXSearch { + t.Fatalf("route = %#v", route) + } +} + +func TestPrepareBuildPromptCacheRoutePreservesLargeIntegers(t *testing.T) { + body, _, err := prepareBuildPromptCacheRoute([]byte(`{"input":"hello","metadata":{"sequence":9007199254740993}}`), "responses", "grok-4.5", "cache-key", false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"sequence":9007199254740993`) { + t.Fatalf("large integer changed during cache route rewrite: %s", body) + } +} + +func TestPrepareBuildPromptCacheRouteDoesNotBroadenUntrustedFunctions(t *testing.T) { + body, route, err := prepareBuildPromptCacheRoute([]byte(`{ + "input":"hello","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}] + }`), "responses", "grok-4.5", "cache-key", false) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), `"x_search"`) || route.filterXSearch || len(route.injectedToolTypes) != 0 { + t.Fatalf("untrusted function request was broadened: body=%s route=%#v", body, route) + } +} + +func TestPrepareBuildPromptCacheRouteComplementsExistingSearch(t *testing.T) { + body, route, err := prepareBuildPromptCacheRoute([]byte(`{"input":"hello","tools":[{"type":"web_search"}]}`), "responses", "grok-4.5", "cache-key", false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"x_search"`) || !route.filterXSearch { + t.Fatalf("existing search route was not completed: body=%s route=%#v", body, route) + } +} + +func TestPrepareBuildPromptCacheRouteExtendsAllowedTools(t *testing.T) { + body, _, err := prepareBuildPromptCacheRoute([]byte(`{ + "input":"hello", + "tools":[{"type":"function","name":"Read"}], + "tool_choice":{"type":"allowed_tools","tools":[{"type":"function","name":"Read"}]} + }`), "responses", "grok-4.5", "cache-key", true) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + choice := payload["tool_choice"].(map[string]any) + allowed := choice["tools"].([]any) + if len(allowed) != 2 || stringField(allowed[1].(map[string]any), "type") != "x_search" { + t.Fatalf("allowed tools = %#v", allowed) + } +} + +func TestPrepareBuildPromptCacheRoutePreservesExplicitXSearch(t *testing.T) { + body, route, err := prepareBuildPromptCacheRoute([]byte(`{ + "input":"hello","tools":[{"type":"x_search"}],"tool_choice":"auto" + }`), "responses", "grok-4.5", "cache-key", false) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + tools := payload["tools"].([]any) + if len(tools) != 1 || stringField(tools[0].(map[string]any), "type") != "x_search" || payload["tool_choice"] != "auto" { + t.Fatalf("explicit x_search changed: %#v", payload) + } + if !route.filterXSearch || len(route.injectedToolTypes) != 0 { + t.Fatalf("explicit x_search route = %#v", route) + } +} + +func TestPrepareBuildPromptCacheRouteRespectsBoundaries(t *testing.T) { + tests := []struct { + name string + body string + operation string + model string + key string + }{ + {name: "no cache identity", body: `{"input":"hello"}`, operation: "responses", model: "grok-4.5"}, + {name: "compaction", body: `{"input":"hello"}`, operation: "compaction", model: "grok-4.5", key: "key"}, + {name: "image operation", body: `{"input":"hello"}`, operation: "image", model: "grok-4.5", key: "key"}, + {name: "image model", body: `{"input":"hello"}`, operation: "responses", model: "grok-imagine-1.0", key: "key"}, + {name: "image tool", body: `{"input":"hello","tools":[{"type":"image_generation"}]}`, operation: "responses", model: "grok-4.5", key: "key"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, route, err := prepareBuildPromptCacheRoute([]byte(test.body), test.operation, test.model, test.key, false) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), `"x_search"`) || route.filterXSearch || len(route.injectedToolTypes) != 0 { + t.Fatalf("unexpected cache route: body=%s route=%#v", body, route) + } + }) + } +} + +func TestFilterBuildPromptCacheResponseJSON(t *testing.T) { + route := buildPromptCacheRoute{ + filterXSearch: true, + injectedToolTypes: map[string]struct{}{"x_search": {}}, + clientDeclaredTools: map[string]struct{}{"x_keyword_search": {}}, + } + response := &http.Response{Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_1", + "usage":{"cost_in_usd_ticks":9007199254740993}, + "tools":[{"type":"function","name":"x_keyword_search"},{"type":"x_search"}], + "output":[ + {"type":"custom_tool_call","id":"internal","call_id":"xs_call-1","name":"x_keyword_search"}, + {"type":"custom_tool_call","id":"client_custom","call_id":"call_custom_1","name":"x_keyword_search","input":"{}"}, + {"type":"function_call","id":"client","call_id":"call_1","name":"x_keyword_search","arguments":"{}"}, + {"type":"message","id":"msg_1","content":[{"type":"output_text","text":"done"}]} + ] + }`))} + if err := filterBuildPromptCacheResponse(response, false, route); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, `"id":"internal"`) || strings.Contains(text, `"type":"x_search"`) || !strings.Contains(text, `"id":"client_custom"`) || !strings.Contains(text, `"id":"client"`) || !strings.Contains(text, `"id":"msg_1"`) || !strings.Contains(text, `"cost_in_usd_ticks":9007199254740993`) { + t.Fatalf("filtered response = %s", text) + } + if response.ContentLength != int64(len(data)) || response.Header.Get("Content-Length") != strconv.Itoa(len(data)) { + t.Fatalf("content length = %d header=%q want=%d", response.ContentLength, response.Header.Get("Content-Length"), len(data)) + } +} + +func TestFilterBuildPromptCacheResponseDropsInjectedWebSearch(t *testing.T) { + route := buildPromptCacheRoute{ + injectedToolTypes: map[string]struct{}{"web_search": {}}, + clientDeclaredTools: map[string]struct{}{}, + } + response := &http.Response{Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{ + "output":[{"type":"web_search_call","id":"ws_1"},{"type":"message","id":"msg_1"}] + }`))} + if err := filterBuildPromptCacheResponse(response, false, route); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "web_search_call") || !strings.Contains(string(data), `"id":"msg_1"`) { + t.Fatalf("injected web search trace leaked: %s", data) + } +} + +func TestFilterBuildPromptCacheResponseStream(t *testing.T) { + route := buildPromptCacheRoute{ + filterXSearch: true, + injectedToolTypes: map[string]struct{}{"x_search": {}}, + clientDeclaredTools: map[string]struct{}{}, + } + source := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":1,"item":{"type":"custom_tool_call","id":"ctc_1","call_id":"xs_call-1","name":"x_semantic_search"}}`, "", + `event: response.custom_tool_call_input.delta`, + `data: {"type":"response.custom_tool_call_input.delta","output_index":1,"item_id":"ctc_1","delta":"{}"}`, "", + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":2,"item":{"type":"message","id":"msg_1"}}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"tools":[{"type":"x_search"}],"output":[{"type":"custom_tool_call","id":"ctc_1","call_id":"xs_call-1","name":"x_semantic_search"},{"type":"message","id":"msg_1"}]}}`, "", "", + }, "\n") + response := &http.Response{Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(source))} + if err := filterBuildPromptCacheResponse(response, true, route); err != nil { + t.Fatal(err) + } + if response.ContentLength != -1 || response.Header.Get("Content-Length") != "" { + t.Fatalf("stream content length = %d header=%q", response.ContentLength, response.Header.Get("Content-Length")) + } + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, "x_semantic_search") || strings.Contains(text, "ctc_1") || strings.Contains(text, `"type":"x_search"`) { + t.Fatalf("internal x_search trace leaked:\n%s", text) + } + if !strings.Contains(text, `"output_index":1`) || !strings.Contains(text, `"id":"msg_1"`) { + t.Fatalf("remaining output was not compacted:\n%s", text) + } +} diff --git a/backend/internal/infra/provider/cli/responses_codex_tools.go b/backend/internal/infra/provider/cli/responses_codex_tools.go new file mode 100644 index 0000000000000000000000000000000000000000..02fb2a2276e384d2d287e9ac1091a3a7d0d5684d --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_codex_tools.go @@ -0,0 +1,638 @@ +package cli + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// normalizeShellTool 保留 OpenAI 新版 shell;同一请求不能同时声明新旧两套本地 shell。 +func (c *responsesToolCompatibility) normalizeShellTool(tool map[string]any, param string) ([]any, error) { + if c.legacyLocalShell { + return nil, &responsesRequestError{ + Message: "同一请求不能同时声明 shell 和 local_shell", + Param: param + ".type", Code: "invalid_parameter", + } + } + c.nativeShell = true + return c.normalizeNativeTool(tool, param) +} + +// normalizeLegacyLocalShellTool upgrades the legacy Codex local_shell declaration to Build 0.2.110. +func (c *responsesToolCompatibility) normalizeLegacyLocalShellTool(tool map[string]any, param string) ([]any, error) { + if c.nativeShell || c.legacyLocalShell { + return nil, &responsesRequestError{ + Message: "单次请求只能声明一个 shell/local_shell 工具", + Param: param + ".type", Code: "invalid_parameter", + } + } + if len(tool) > 1 { + c.addWarning("legacy_local_shell_controls_ignored") + } + c.legacyLocalShell = true + c.changed = true + c.addWarning("legacy_local_shell_upgraded") + return []any{map[string]any{ + "type": "shell", + "environment": map[string]any{"type": "local"}, + }}, nil +} + +// normalizeApplyPatchTool 将客户端执行的 apply_patch 包装为严格 function。 +func (c *responsesToolCompatibility) normalizeApplyPatchTool(tool map[string]any, param string) ([]any, error) { + if len(tool) > 1 { + c.addWarning("apply_patch_controls_ignored") + } + identity := responsesToolIdentity{Kind: responsesApplyPatchTool, Name: "apply_patch"} + c.changed = true + c.addWarning("apply_patch_emulated") + return []any{map[string]any{ + "type": "function", + "name": c.alias(identity), + "description": "Create, update, or delete one file using a structured V4A patch operation. " + + "create_file and update_file require path and diff; delete_file requires path.", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "operation": map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{"type": "string", "enum": []any{"create_file", "update_file", "delete_file"}}, + "path": map[string]any{"type": "string", "minLength": 1}, + "diff": map[string]any{"type": "string"}, + }, + "required": []any{"type", "path"}, "additionalProperties": false, + }, + }, + "required": []any{"operation"}, "additionalProperties": false, + }, + "strict": true, + }}, nil +} + +func (c *responsesToolCompatibility) normalizeApplyPatchCallInput(item map[string]any, param string) (map[string]any, error) { + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + operation, err := validateApplyPatchOperation(item["operation"], param+".operation") + if err != nil { + return nil, err + } + arguments, err := json.Marshal(map[string]any{"operation": operation}) + if err != nil { + return nil, err + } + return map[string]any{ + "type": "function_call", "call_id": callID, + "name": c.alias(responsesToolIdentity{Kind: responsesApplyPatchTool, Name: "apply_patch"}), + "arguments": string(arguments), + }, nil +} + +func normalizeApplyPatchOutputInput(item map[string]any, param string) (map[string]any, error) { + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + status := strings.TrimSpace(stringField(item, "status")) + if status == "" { + status = "completed" + } + if status != "completed" && status != "failed" { + return nil, &responsesRequestError{Message: "apply_patch_call_output.status 只支持 completed 或 failed", Param: param + ".status", Code: "invalid_parameter"} + } + output := "" + if value, exists := item["output"]; exists && value != nil { + if text, ok := value.(string); ok { + output = text + } else { + encoded, err := json.Marshal(value) + if err != nil { + return nil, &responsesRequestError{Message: "apply_patch_call_output.output 无法编码", Param: param + ".output", Code: "invalid_parameter"} + } + output = string(encoded) + } + } + message := "Apply patch status: " + status + if output != "" { + message += "\n" + output + } + return map[string]any{"type": "function_call_output", "call_id": callID, "output": message}, nil +} + +func validateApplyPatchOperation(value any, param string) (map[string]any, error) { + operation, ok := value.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: "apply_patch operation 必须是对象", Param: param, Code: "invalid_parameter"} + } + kind := strings.TrimSpace(stringField(operation, "type")) + path := strings.TrimSpace(stringField(operation, "path")) + if path == "" { + return nil, &responsesRequestError{Message: "apply_patch operation.path 不能为空", Param: param + ".path", Code: "invalid_parameter"} + } + switch kind { + case "create_file", "update_file": + if _, ok := operation["diff"].(string); !ok { + return nil, &responsesRequestError{Message: kind + " 必须提供 diff 字符串", Param: param + ".diff", Code: "invalid_parameter"} + } + case "delete_file": + default: + return nil, &responsesRequestError{Message: "apply_patch operation.type 无效", Param: param + ".type", Code: "invalid_parameter"} + } + return cloneJSONObject(operation), nil +} + +func decodeApplyPatchArguments(value any, param string) (map[string]any, error) { + text, ok := value.(string) + if !ok { + return nil, &responsesRequestError{Message: "apply_patch function arguments 必须是字符串", Param: param, Code: "invalid_parameter"} + } + var wrapper map[string]any + if err := json.Unmarshal([]byte(text), &wrapper); err != nil { + return nil, &responsesRequestError{Message: "apply_patch function arguments 不是有效 JSON", Param: param, Code: "invalid_parameter"} + } + return validateApplyPatchOperation(wrapper["operation"], param+".operation") +} + +func normalizeLegacyLocalShellCallInput(item map[string]any, param string) (map[string]any, error) { + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + action, err := legacyShellAction(item["action"], param+".action") + if err != nil { + return nil, err + } + converted := map[string]any{"type": "shell_call", "call_id": callID, "action": action} + for _, key := range []string{"id", "status", "timeout_ms", "max_output_length"} { + if value, exists := item[key]; exists { + converted[key] = cloneJSONValue(value) + } + } + return converted, nil +} + +func legacyShellAction(value any, param string) (map[string]any, error) { + action, ok := value.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: "local_shell_call.action 必须是对象", Param: param, Code: "invalid_parameter"} + } + if kind := strings.TrimSpace(stringField(action, "type")); kind != "" && kind != "exec" { + return nil, &responsesRequestError{Message: "local_shell_call.action.type 必须是 exec", Param: param + ".type", Code: "invalid_parameter"} + } + command, err := legacyShellCommand(action, param) + if err != nil { + return nil, err + } + return map[string]any{"type": "exec", "commands": []any{command}}, nil +} + +func legacyShellCommand(action map[string]any, param string) (string, error) { + command := "" + switch value := action["command"].(type) { + case string: + command = strings.TrimSpace(value) + case []any: + parts := make([]string, 0, len(value)) + for index, raw := range value { + part, ok := raw.(string) + if !ok { + return "", &responsesRequestError{Message: "local_shell command 参数必须是字符串", Param: fmt.Sprintf("%s.command[%d]", param, index), Code: "invalid_parameter"} + } + parts = append(parts, quoteShellArgument(part)) + } + command = strings.Join(parts, " ") + default: + if commands, ok := action["commands"].([]any); ok { + parts := make([]string, 0, len(commands)) + for index, raw := range commands { + part, ok := raw.(string) + if !ok { + return "", &responsesRequestError{Message: "shell commands 必须是字符串", Param: fmt.Sprintf("%s.commands[%d]", param, index), Code: "invalid_parameter"} + } + parts = append(parts, part) + } + command = strings.Join(parts, "\n") + } + } + if command == "" { + return "", &responsesRequestError{Message: "local_shell_call.action.command 不能为空", Param: param + ".command", Code: "invalid_parameter"} + } + if environment, ok := action["env"].(map[string]any); ok && len(environment) > 0 { + keys := make([]string, 0, len(environment)) + for key := range environment { + if !validEnvironmentName(key) { + return "", &responsesRequestError{Message: "local_shell env 名称无效", Param: param + ".env." + key, Code: "invalid_parameter"} + } + keys = append(keys, key) + } + sort.Strings(keys) + assignments := make([]string, 0, len(keys)) + for _, key := range keys { + value, ok := environment[key].(string) + if !ok { + return "", &responsesRequestError{Message: "local_shell env 值必须是字符串", Param: param + ".env." + key, Code: "invalid_parameter"} + } + assignments = append(assignments, key+"="+quoteShellArgument(value)) + } + command = "env " + strings.Join(assignments, " ") + " " + command + } + if directory := strings.TrimSpace(stringField(action, "working_directory")); directory != "" { + command = "cd " + quoteShellArgument(directory) + " && " + command + } + return command, nil +} + +func normalizeLegacyLocalShellOutputInput(item map[string]any, param string) (map[string]any, error) { + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + var output []any + switch value := item["output"].(type) { + case []any: + output = cloneJSONArray(value) + case string: + exitCode := 0 + if strings.EqualFold(stringField(item, "status"), "failed") { + exitCode = 1 + } + if number, ok := item["exit_code"].(float64); ok { + exitCode = int(number) + } + output = []any{map[string]any{ + "stdout": value, "stderr": "", + "outcome": map[string]any{"type": "exit", "exit_code": exitCode}, + }} + default: + return nil, &responsesRequestError{Message: "local_shell_call_output.output 必须是字符串或数组", Param: param + ".output", Code: "invalid_parameter"} + } + converted := map[string]any{"type": "shell_call_output", "call_id": callID, "output": output} + if value, exists := item["max_output_length"]; exists && value != nil { + converted["max_output_length"] = cloneJSONValue(value) + } + return converted, nil +} + +func normalizeShellCallOutputInput(item map[string]any, param string) (map[string]any, error) { + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + output, err := normalizeShellOutputBlocks(item["output"], item["status"], param+".output") + if err != nil { + return nil, err + } + converted := map[string]any{"type": "shell_call_output", "call_id": callID, "output": output} + if value, exists := item["max_output_length"]; exists && value != nil { + converted["max_output_length"] = cloneJSONValue(value) + } + return converted, nil +} + +func (c *responsesToolCompatibility) normalizeFunctionCallOutputInput(item map[string]any, param string) (map[string]any, error) { + return c.normalizeFunctionLikeCallOutputInput(item, param, true) +} + +func (c *responsesToolCompatibility) normalizeCustomToolCallOutputInput(item map[string]any, param string) (map[string]any, error) { + return c.normalizeFunctionLikeCallOutputInput(item, param, false) +} + +func (c *responsesToolCompatibility) normalizeFunctionLikeCallOutputInput(item map[string]any, param string, allowContentBlocks bool) (map[string]any, error) { + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + output := item["output"] + var err error + if blocks, ok := output.([]any); ok && allowContentBlocks && isFunctionCallOutputContentArray(blocks) { + output, err = c.normalizeFunctionCallOutputBlocks(blocks, param+".output") + } else { + output, err = encodeToolOutput(output, param+".output") + } + if err != nil { + return nil, err + } + // 按官方 Build 回放结构重建,不携带输出态 id/status。 + return map[string]any{"type": "function_call_output", "call_id": callID, "output": output}, nil +} + +// isFunctionCallOutputContentArray 区分 Responses 内容数组与普通结构化 JSON 数组。 +// 只要出现一个 input_* 内容块,整个数组就按内容数组严格校验,避免混合数组 +// 中的图片被静默字符串化;普通对象/标量/空数组仍沿用 JSON 字符串契约。 +func isFunctionCallOutputContentArray(blocks []any) bool { + for _, raw := range blocks { + block, ok := raw.(map[string]any) + if !ok { + continue + } + blockType := strings.TrimSpace(stringField(block, "type")) + if strings.HasPrefix(blockType, "input_") { + return true + } + } + return false +} + +func (c *responsesToolCompatibility) normalizeFunctionCallOutputBlocks(blocks []any, param string) ([]any, error) { + normalized := make([]any, 0, len(blocks)) + for index, raw := range blocks { + blockParam := fmt.Sprintf("%s[%d]", param, index) + block, ok := raw.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: blockParam + " 必须是对象", Param: blockParam, Code: "invalid_parameter"} + } + blockType := strings.TrimSpace(stringField(block, "type")) + if blockType == "" { + return nil, &responsesRequestError{Message: blockParam + ".type 不能为空", Param: blockParam + ".type", Code: "invalid_parameter"} + } + switch blockType { + case "input_text": + text, ok := block["text"].(string) + if !ok { + return nil, &responsesRequestError{Message: blockParam + ".text 必须是字符串", Param: blockParam + ".text", Code: "invalid_parameter"} + } + normalized = append(normalized, map[string]any{"type": "input_text", "text": text}) + case "input_image": + converted, err := c.normalizeFunctionCallOutputImageBlock(block, blockParam) + if err != nil { + return nil, err + } + normalized = append(normalized, converted) + case "input_file": + converted, err := normalizeFunctionCallOutputFileBlock(block, blockParam) + if err != nil { + return nil, err + } + normalized = append(normalized, converted) + default: + return nil, &responsesRequestError{Message: "Grok Build 0.2.110 不支持该 function_call_output.output 类型", Param: blockParam + ".type", Code: "unsupported_parameter"} + } + } + return normalized, nil +} + +func (c *responsesToolCompatibility) normalizeFunctionCallOutputImageBlock(block map[string]any, param string) (map[string]any, error) { + _, hasImageURL, err := nonEmptyContentBlockString(block, "image_url", param) + if err != nil { + return nil, err + } + _, hasFileID, err := nonEmptyContentBlockString(block, "file_id", param) + if err != nil { + return nil, err + } + if !hasImageURL && !hasFileID { + return nil, &responsesRequestError{Message: param + ".image_url 或 .file_id 至少需要一个", Param: param + ".image_url", Code: "invalid_parameter"} + } + return c.normalizeInputImagePart(block, param) +} + +func normalizeFunctionCallOutputFileBlock(block map[string]any, param string) (map[string]any, error) { + hasSource := false + for _, key := range []string{"file_data", "file_id", "file_url", "filename"} { + _, exists, err := nonEmptyContentBlockString(block, key, param) + if err != nil { + return nil, err + } + if key != "filename" && exists { + hasSource = true + } + } + if !hasSource { + return nil, &responsesRequestError{Message: param + " 至少需要 file_data、file_id 或 file_url 之一", Param: param + ".file_data", Code: "invalid_parameter"} + } + return normalizeInputFilePart(block), nil +} + +func nonEmptyContentBlockString(block map[string]any, key, param string) (string, bool, error) { + raw, exists := block[key] + if !exists || raw == nil { + return "", false, nil + } + value, ok := raw.(string) + if !ok || strings.TrimSpace(value) == "" { + return "", false, &responsesRequestError{Message: param + "." + key + " 必须是非空字符串", Param: param + "." + key, Code: "invalid_parameter"} + } + return value, true, nil +} + +func encodeToolOutput(value any, param string) (string, error) { + switch typed := value.(type) { + case nil: + return "", nil + case string: + return typed, nil + default: + encoded, err := json.Marshal(typed) + if err != nil { + return "", &responsesRequestError{Message: param + " 无法编码", Param: param, Code: "invalid_parameter"} + } + return string(encoded), nil + } +} + +func normalizeShellOutputBlocks(value, status any, param string) ([]any, error) { + switch typed := value.(type) { + case []any: + output := make([]any, 0, len(typed)) + for index, raw := range typed { + block, ok := raw.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: fmt.Sprintf("%s[%d] 必须是对象", param, index), Param: fmt.Sprintf("%s[%d]", param, index), Code: "invalid_parameter"} + } + normalized, err := normalizeShellOutputBlock(block, fmt.Sprintf("%s[%d]", param, index)) + if err != nil { + return nil, err + } + output = append(output, normalized) + } + return output, nil + case string: + exitCode := 0 + if strings.EqualFold(fmt.Sprint(status), "failed") { + exitCode = 1 + } + return []any{map[string]any{ + "stdout": typed, "stderr": "", + "outcome": map[string]any{"type": "exit", "exit_code": exitCode}, + }}, nil + default: + return nil, &responsesRequestError{Message: param + " 必须是字符串或数组", Param: param, Code: "invalid_parameter"} + } +} + +func normalizeShellOutputBlock(block map[string]any, param string) (map[string]any, error) { + stdout, _ := block["stdout"].(string) + stderr, _ := block["stderr"].(string) + outcome, ok := block["outcome"].(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: param + ".outcome 必须是对象", Param: param + ".outcome", Code: "invalid_parameter"} + } + normalizedOutcome := map[string]any{"type": strings.TrimSpace(stringField(outcome, "type"))} + switch normalizedOutcome["type"] { + case "exit": + exitCode, ok := shellExitCode(outcome["exit_code"]) + if !ok { + exitCode, ok = shellExitCode(outcome["exitCode"]) + } + if !ok { + return nil, &responsesRequestError{Message: param + ".outcome.exit_code 必须是数字", Param: param + ".outcome.exit_code", Code: "invalid_parameter"} + } + normalizedOutcome["exit_code"] = exitCode + case "timeout": + default: + return nil, &responsesRequestError{Message: param + ".outcome.type 无效", Param: param + ".outcome.type", Code: "invalid_parameter"} + } + return map[string]any{"stdout": stdout, "stderr": stderr, "outcome": normalizedOutcome}, nil +} + +func shellExitCode(value any) (int, bool) { + switch typed := value.(type) { + case float64: + return int(typed), true + case int: + return typed, true + case int64: + return int(typed), true + case json.Number: + parsed, err := typed.Int64() + return int(parsed), err == nil + default: + return 0, false + } +} + +func rewriteLegacyShellAction(value any) map[string]any { + action, _ := value.(map[string]any) + commands, _ := action["commands"].([]any) + parts := make([]string, 0, len(commands)) + for _, raw := range commands { + if command, ok := raw.(string); ok { + parts = append(parts, command) + } + } + legacy := map[string]any{"type": "exec", "command": strings.Join(parts, "\n")} + return legacy +} + +func quoteShellArgument(value string) string { + if value == "" { + return "''" + } + safe := true + for _, character := range value { + if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || strings.ContainsRune("_@%+=:,./-", character)) { + safe = false + break + } + } + if safe { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func validEnvironmentName(value string) bool { + if value == "" { + return false + } + for index, character := range value { + if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || character == '_' || (index > 0 && character >= '0' && character <= '9')) { + return false + } + } + return true +} + +func (c *responsesToolCompatibility) normalizeAdditionalToolsInput(item map[string]any, param string) (map[string]any, []any, []any, error) { + if role := strings.TrimSpace(stringField(item, "role")); role != "" && role != "developer" { + c.addWarning("additional_tools_role_approximated") + } + tools, ok := item["tools"].([]any) + if !ok { + return nil, nil, nil, &responsesRequestError{Message: param + ".tools 必须是数组", Param: param + ".tools", Code: "invalid_parameter"} + } + c.addWarning("additional_tools_position_approximated") + normalized := make([]any, 0, len(tools)) + names := make([]string, 0, len(tools)) + for index, rawTool := range tools { + converted, err := c.normalizeTool(rawTool, "", false, true, fmt.Sprintf("%s.tools[%d]", param, index)) + if err != nil { + return nil, nil, nil, err + } + normalized = append(normalized, converted...) + if tool, ok := rawTool.(map[string]any); ok { + name := strings.TrimSpace(stringField(tool, "name")) + if name == "" { + name = strings.TrimSpace(stringField(tool, "server_label")) + } + if name == "" { + name = strings.TrimSpace(stringField(tool, "type")) + } + if name != "" { + names = append(names, name) + } + } + } + message := "Additional tools become available at this point in the conversation." + if len(names) > 0 { + message += "\nTools: " + strings.Join(names, ", ") + } + return compatibilityBoundaryMessage(message), normalized, cloneJSONArray(tools), nil +} + +func compatibilityBoundaryMessage(text string) map[string]any { + return map[string]any{ + "type": "message", "role": "developer", + "content": []any{map[string]any{"type": "input_text", "text": text}}, + } +} + +func dedupeNormalizedTools(tools []any) []any { + result := make([]any, 0, len(tools)) + positions := make(map[string]int) + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + result = append(result, raw) + continue + } + kind := stringField(tool, "type") + name := stringField(tool, "name") + if name == "" { + name = stringField(tool, "server_label") + } + key := kind + "\x00" + name + if name == "" { + key = kind + } + if index, exists := positions[key]; exists { + result[index] = raw + continue + } + positions[key] = len(result) + result = append(result, raw) + } + return result +} + +func (c *responsesToolCompatibility) addWarning(code string) { + if c == nil || code == "" { + return + } + if _, exists := c.warningSet[code]; exists { + return + } + c.warningSet[code] = struct{}{} + c.warnings = append(c.warnings, code) +} + +func (c *responsesToolCompatibility) warningHeader() string { + if c == nil { + return "" + } + return strings.Join(c.warnings, ",") +} diff --git a/backend/internal/infra/provider/cli/responses_codex_tools_test.go b/backend/internal/infra/provider/cli/responses_codex_tools_test.go new file mode 100644 index 0000000000000000000000000000000000000000..235733020abccfbf973d67b66cf430542f05bdac --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_codex_tools_test.go @@ -0,0 +1,686 @@ +package cli + +import ( + "encoding/json" + "io" + "strings" + "testing" +) + +func TestLegacyLocalShellUsesNativeLocalShellAndRestoresJSON(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"show cwd", + "tools":[{"type":"local_shell"}], + "tool_choice":{"type":"local_shell"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil || !compatibility.legacyLocalShell { + t.Fatal("legacy local_shell 未启用兼容层") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + environment := tool["environment"].(map[string]any) + if tool["type"] != "shell" || environment["type"] != "local" || request["tool_choice"] != "required" { + t.Fatalf("upstream shell = %#v, choice = %#v", tool, request["tool_choice"]) + } + + restored, err := compatibility.normalizeResponseJSON([]byte(`{ + "id":"resp_1","object":"response", + "tools":[{"type":"shell","environment":{"type":"local"}}], + "output":[{"id":"sh_1","type":"shell_call","call_id":"call_1","status":"completed","action":{"type":"exec","commands":["pwd"]}}] + }`)) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(restored, &response); err != nil { + t.Fatal(err) + } + call := response["output"].([]any)[0].(map[string]any) + action := call["action"].(map[string]any) + if call["type"] != "local_shell_call" || action["type"] != "exec" || action["command"] != "pwd" { + t.Fatalf("legacy local_shell_call = %#v", call) + } + if response["tools"].([]any)[0].(map[string]any)["type"] != "local_shell" { + t.Fatalf("visible tools = %#v", response["tools"]) + } +} + +func TestLegacyLocalShellHistoryBecomesStructuredShellHistory(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","tools":[{"type":"local_shell"}],"input":[ + {"type":"local_shell_call","id":"sh_1","call_id":"call_1","status":"completed","action":{"type":"exec","command":["printf","a b"],"working_directory":"/workspace","env":{"MODE":"test"}}}, + {"type":"local_shell_call_output","call_id":"call_1","status":"failed","exit_code":7,"output":"failure"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + call := items[0].(map[string]any) + commands := call["action"].(map[string]any)["commands"].([]any) + if call["type"] != "shell_call" || len(commands) != 1 || commands[0] != `cd /workspace && env MODE=test printf 'a b'` { + t.Fatalf("shell call history = %#v", call) + } + output := items[1].(map[string]any) + outcome := output["output"].([]any)[0].(map[string]any)["outcome"].(map[string]any) + if output["type"] != "shell_call_output" || outcome["exit_code"] != float64(7) { + t.Fatalf("shell output history = %#v", output) + } +} + +func TestNativeShellOutputHistoryIsSanitizedForBuild(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","tools":[{"type":"shell","environment":{"type":"local"}}],"input":[ + {"type":"shell_call_output","call_id":"call_1","status":"completed","output":[ + {"command":"pwd","stdout":"/workspace\n","stderr":"","outcome":{"type":"exit","exitCode":0}} + ],"max_output_length":2048} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + item := request["input"].([]any)[0].(map[string]any) + block := item["output"].([]any)[0].(map[string]any) + outcome := block["outcome"].(map[string]any) + if item["type"] != "shell_call_output" || block["command"] != nil || outcome["exit_code"] != float64(0) || outcome["exitCode"] != nil { + t.Fatalf("shell output history = %#v", item) + } +} + +func TestFunctionCallOutputHistoryEncodesStructuredOutput(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","tools":[{"type":"function","name":"shell_command","parameters":{"type":"object"}}],"input":[ + {"type":"function_call_output","call_id":"call_1","status":"completed","output":{"exit_code":0,"stdout":"ok","stderr":""}} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + item := request["input"].([]any)[0].(map[string]any) + output, ok := item["output"].(string) + if item["type"] != "function_call_output" || !ok || !strings.Contains(output, `"stdout":"ok"`) { + t.Fatalf("function output history = %#v", item) + } +} + +func TestFunctionCallOutputHistoryPreservesContentBlocks(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","tools":[{"type":"function","name":"read_file","parameters":{"type":"object"}}],"input":[ + {"type":"function_call_output","call_id":"call_1","status":"completed","output":[ + {"type":"input_text","text":"Read image file: screenshot.png"}, + {"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}, + {"type":"input_image","detail":"original","file_id":"file_image_2"}, + {"type":"input_file","file_id":"file_document_1","filename":"notes.txt"} + ]} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + item := request["input"].([]any)[0].(map[string]any) + output, ok := item["output"].([]any) + if !ok || len(output) != 4 { + t.Fatalf("function output history = %#v", item) + } + text := output[0].(map[string]any) + firstImage := output[1].(map[string]any) + secondImage := output[2].(map[string]any) + file := output[3].(map[string]any) + if text["type"] != "input_text" || text["text"] != "Read image file: screenshot.png" || + firstImage["type"] != "input_image" || firstImage["detail"] != "auto" || + firstImage["image_url"] != "data:image/png;base64,aGVsbG8=" || + secondImage["type"] != "input_image" || secondImage["detail"] != "high" || secondImage["file_id"] != "file_image_2" || + file["type"] != "input_file" || file["file_id"] != "file_document_1" || file["filename"] != "notes.txt" || + item["status"] != nil { + t.Fatalf("function output history = %#v", item) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "image_detail_original_downgraded") { + t.Fatalf("compatibility warnings = %#v", compatibility) + } +} + +func TestFunctionCallOutputHistoryKeepsStructuredArraysAsJSON(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "empty", output: `[]`}, + {name: "scalars", output: `[1,2,true]`}, + {name: "objects", output: `[{"id":1,"type":"record"}]`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[{"type":"function_call_output","call_id":"call_1","output":`+test.output+`}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + output, ok := request["input"].([]any)[0].(map[string]any)["output"].(string) + if !ok || output != test.output { + t.Fatalf("output = %#v, want JSON string %q", output, test.output) + } + }) + } +} + +func TestFunctionCallOutputHistoryRejectsInvalidContentBlocks(t *testing.T) { + tests := []struct { + name string + output string + wantParam string + wantCode string + }{ + {name: "mixed_non_object", output: `[{"type":"input_text","text":"ok"},42]`, wantParam: "input[0].output[1]", wantCode: "invalid_parameter"}, + {name: "mixed_missing_type", output: `[{"type":"input_text","text":"ok"},{"text":"missing type"}]`, wantParam: "input[0].output[1].type", wantCode: "invalid_parameter"}, + {name: "missing_text", output: `[{"type":"input_text"}]`, wantParam: "input[0].output[0].text", wantCode: "invalid_parameter"}, + {name: "missing_image_source", output: `[{"type":"input_image","detail":"auto"}]`, wantParam: "input[0].output[0].image_url", wantCode: "invalid_parameter"}, + {name: "invalid_image_detail", output: `[{"type":"input_image","detail":"medium","image_url":"data:image/png;base64,AA=="}]`, wantParam: "input[0].output[0].detail", wantCode: "invalid_parameter"}, + {name: "missing_file_source", output: `[{"type":"input_file","filename":"empty.txt"}]`, wantParam: "input[0].output[0].file_data", wantCode: "invalid_parameter"}, + {name: "unknown_type", output: `[{"type":"input_audio","data":"AA=="}]`, wantParam: "input[0].output[0].type", wantCode: "unsupported_parameter"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"public","input":[{"type":"function_call_output","call_id":"call_1","output":` + test.output + `}]}`) + _, _, err := normalizeResponsesRequest(body, "grok-4.5") + requestErr, ok := err.(*responsesRequestError) + if !ok || requestErr.Param != test.wantParam || requestErr.Code != test.wantCode { + t.Fatalf("error = %#v", err) + } + }) + } +} + +func TestMessageImageDetailUsesSameCompatibilityPolicy(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[{"type":"message","role":"user","content":[ + {"type":"input_image","image_url":"data:image/png;base64,AA=="}, + {"type":"input_image","detail":"original","file_id":"file_1"} + ]}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + content := request["input"].([]any)[0].(map[string]any)["content"].([]any) + if content[0].(map[string]any)["detail"] != "auto" || content[1].(map[string]any)["detail"] != "high" { + t.Fatalf("message content = %#v", content) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "image_detail_original_downgraded") { + t.Fatalf("compatibility warnings = %#v", compatibility) + } +} + +func TestCustomToolCallOutputHistoryStillEncodesArrayAsString(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[{"type":"custom_tool_call_output","call_id":"call_1","output":[ + {"type":"input_text","text":"custom output"}, + {"type":"input_image","detail":"auto","image_url":"data:image/png;base64,AA=="} + ]}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + item := request["input"].([]any)[0].(map[string]any) + output, ok := item["output"].(string) + if item["type"] != "function_call_output" || !ok || !strings.Contains(output, `"type":"input_image"`) { + t.Fatalf("custom tool output history = %#v", item) + } +} + +func TestFunctionCallOutputHistoryPreservesFortyTwoImagesAtThirtyTwoMiB(t *testing.T) { + const ( + imageCount = 42 + totalPayloadSize = 32 << 20 + ) + perImagePayloadSize := (totalPayloadSize / imageCount) / 4 * 4 + imageURL := "data:image/png;base64," + strings.Repeat("A", perImagePayloadSize) + blocks := make([]any, 0, imageCount) + for range imageCount { + blocks = append(blocks, map[string]any{"type": "input_image", "detail": "auto", "image_url": imageURL}) + } + body, err := json.Marshal(map[string]any{ + "model": "public", + "input": []any{map[string]any{ + "type": "function_call_output", "call_id": "call_images", "output": blocks, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(body) < totalPayloadSize { + t.Fatalf("fixture size = %d", len(body)) + } + normalized, _, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + item := request["input"].([]any)[0].(map[string]any) + output, ok := item["output"].([]any) + if !ok || len(output) != imageCount { + t.Fatalf("output type = %T, length = %d", item["output"], len(output)) + } + for _, index := range []int{0, imageCount - 1} { + image := output[index].(map[string]any) + if image["type"] != "input_image" || image["detail"] != "auto" || image["image_url"] != imageURL { + t.Fatalf("output[%d] was not preserved", index) + } + } +} + +func TestAssistantOutputMessageHistoryUsesEasyMessageText(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"message","role":"system","content":"system instruction"}, + {"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[ + {"type":"output_text","text":"Hi there","annotations":[]} + ]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"继续"}]} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + system := items[0].(map[string]any) + assistant := items[1].(map[string]any) + user := items[2].(map[string]any) + userContent := user["content"].([]any)[0].(map[string]any) + if system["role"] != "system" || system["content"] != "system instruction" { + t.Fatalf("system message history = %#v", system) + } + if assistant["id"] != nil || assistant["status"] != nil || assistant["content"] != "Hi there" { + t.Fatalf("assistant message history = %#v", assistant) + } + if userContent["type"] != "input_text" { + t.Fatalf("user message history = %#v", user) + } +} + +func TestRoleMissingTypeBecomesMessageAndFunctionCallIsAllowlisted(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"role":"user","content":"hello"}, + {"type":"function_call","id":"fc_1","status":"completed","call_id":"call_1","name":"shell_command","arguments":"{}","namespace":"","internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"function_call_output","id":"fco_1","status":"completed","call_id":"call_1","output":"ok"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + user := items[0].(map[string]any) + call := items[1].(map[string]any) + output := items[2].(map[string]any) + if user["type"] != "message" || user["content"] != "hello" { + t.Fatalf("user history = %#v", user) + } + if call["type"] != "function_call" || call["id"] != nil || call["status"] != nil || call["internal_chat_message_metadata_passthrough"] != nil { + t.Fatalf("function_call history = %#v", call) + } + if output["type"] != "function_call_output" || output["id"] != nil || output["status"] != nil { + t.Fatalf("function_call_output history = %#v", output) + } +} + +func TestNativeBuildHistoryItemsArePreservedAndSanitized(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"Grok Build","sources":null},"phase":"commentary"}, + {"type":"shell_call","id":null,"call_id":"call_1","status":"completed","action":{"commands":["pwd"],"timeout_ms":null,"internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}}, + {"type":"shell_call_output","call_id":"call_1","output":[{"stdout":"/workspace\n","stderr":"","outcome":{"type":"exit","exit_code":0}}]}, + {"type":"code_interpreter_call","id":"ci_1","container_id":"container_1","status":"completed","code":"print(1)","outputs":null}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + webSearch := items[0].(map[string]any) + searchAction := webSearch["action"].(map[string]any) + if webSearch["type"] != "web_search_call" || webSearch["phase"] != nil || searchAction["sources"] != nil { + t.Fatalf("web search history = %#v", webSearch) + } + shellCall := items[1].(map[string]any) + shellAction := shellCall["action"].(map[string]any) + if shellCall["type"] != "shell_call" || shellCall["id"] != nil || shellAction["timeout_ms"] != nil || shellAction["internal_chat_message_metadata_passthrough"] != nil { + t.Fatalf("shell call history = %#v", shellCall) + } + if items[2].(map[string]any)["type"] != "shell_call_output" || items[3].(map[string]any)["type"] != "code_interpreter_call" || items[3].(map[string]any)["outputs"] != nil { + t.Fatalf("native tool history = %#v", items[2:4]) + } + if items[4].(map[string]any)["role"] != "user" { + t.Fatalf("user message changed unexpectedly: %#v", items[4]) + } +} + +func TestReasoningWithoutEncryptedContentRemainsNative(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"reasoning","id":"rs_1","status":"completed","summary":[{"type":"summary_text","text":"who am I"}],"content":null,"encrypted_content":null,"internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"嗯"}]} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + first := items[0].(map[string]any) + if first["type"] != "reasoning" || first["status"] != nil || first["encrypted_content"] != nil || first["content"] != nil { + t.Fatalf("reasoning history = %#v", first) + } + summary := first["summary"].([]any)[0].(map[string]any) + if summary["text"] != "who am I" { + t.Fatalf("reasoning summary = %#v", first["summary"]) + } + if items[1].(map[string]any)["content"] != "hi" { + t.Fatalf("assistant history = %#v", items[1]) + } +} + +func TestUnsupportedResponsesHistoryItemBecomesBoundary(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"future_codex_item","id":"future_1","status":"completed"}, + {"type":"message","role":"user","content":"continue"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + boundary := items[0].(map[string]any) + text := boundary["content"].([]any)[0].(map[string]any)["text"].(string) + if boundary["role"] != "developer" || !strings.Contains(text, "future_codex_item") || items[1].(map[string]any)["role"] != "user" { + t.Fatalf("normalized history = %#v", items) + } +} + +func TestMessageImageAndFileNullFieldsAreRemoved(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[{"type":"message","role":"user","content":[ + {"type":"input_image","image_url":"https://example.com/image.png","detail":null,"file_id":null}, + {"type":"input_file","file_id":"file_1","file_url":null,"filename":null} + ]}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + content := request["input"].([]any)[0].(map[string]any)["content"].([]any) + image := content[0].(map[string]any) + file := content[1].(map[string]any) + if image["image_url"] == nil || image["detail"] != "auto" || image["file_id"] != nil || file["file_id"] != "file_1" || file["file_url"] != nil || file["filename"] != nil { + t.Fatalf("message content = %#v", content) + } +} + +func TestCodexPrivateMetadataIsStrippedFromHistory(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":[ + {"type":"reasoning","id":"rs_1","status":"completed","summary":[{"type":"summary_text","text":"plan"}],"encrypted_content":"cipher","internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell_command","arguments":"{}","internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"function_call_output","id":"fco_1","call_id":"call_1","output":"ok","internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"custom_tool_call","id":"ctc_1","call_id":"call_2","name":"apply_patch","input":"patch","status":"completed","internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"custom_tool_call_output","id":"ctco_1","call_id":"call_2","output":"done","internal_chat_message_metadata_passthrough":{"turn_id":"t1"}}, + {"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"hi"}],"internal_chat_message_metadata_passthrough":{"turn_id":"t1"}} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + for index, raw := range request["input"].([]any) { + item := raw.(map[string]any) + if item["internal_chat_message_metadata_passthrough"] != nil || item["phase"] != nil { + t.Fatalf("input[%d] still has private fields: %#v", index, item) + } + } + items := request["input"].([]any) + if items[0].(map[string]any)["type"] != "reasoning" || items[0].(map[string]any)["encrypted_content"] != "cipher" || items[0].(map[string]any)["status"] != nil { + t.Fatalf("reasoning history = %#v", items[0]) + } + if items[1].(map[string]any)["type"] != "function_call" || items[3].(map[string]any)["type"] != "function_call" { + t.Fatalf("tool call history = %#v", items) + } + if items[5].(map[string]any)["role"] != "assistant" { + t.Fatalf("assistant message history = %#v", items[5]) + } +} + +func TestRequestRejectsAmbiguousShellDeclarations(t *testing.T) { + _, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"shell","environment":{"type":"local"}},{"type":"local_shell"}] + }`), "grok-4.5") + requestErr, ok := err.(*responsesRequestError) + if !ok || requestErr.Code != "invalid_parameter" || requestErr.Param != "tools[1].type" { + t.Fatalf("error = %#v", err) + } +} + +func TestOptionalCompatibilityControlsAreIgnored(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public", + "tools":[{"type":"local_shell","display_width":120},{"type":"apply_patch","mode":"auto"}], + "input":[{"type":"additional_tools","role":"user","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("兼容字段未触发归一化") + } + warnings := compatibility.warningHeader() + for _, expected := range []string{"legacy_local_shell_controls_ignored", "apply_patch_controls_ignored", "additional_tools_role_approximated"} { + if !strings.Contains(warnings, expected) { + t.Fatalf("warnings = %q", warnings) + } + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + if len(request["tools"].([]any)) != 3 { + t.Fatalf("tools = %#v", request["tools"]) + } +} + +func TestApplyPatchToolRequestHistoryAndJSONResponse(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"edit file", + "tools":[{"type":"apply_patch"}], + "tool_choice":{"type":"apply_patch"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("apply_patch 未启用兼容层") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + choice := request["tool_choice"].(map[string]any) + if tool["type"] != "function" || tool["name"] != "grok2api_apply_patch" || choice["type"] != "function" || choice["name"] != "grok2api_apply_patch" { + t.Fatalf("apply patch wrapper = %#v, choice = %#v", tool, choice) + } + + restored, err := compatibility.normalizeResponseJSON([]byte(`{ + "id":"resp_1","object":"response", + "tools":[{"type":"function","name":"grok2api_apply_patch"}], + "output":[{"id":"fc_1","type":"function_call","call_id":"call_1","status":"completed","name":"grok2api_apply_patch","arguments":"{\"operation\":{\"type\":\"update_file\",\"path\":\"main.go\",\"diff\":\"@@\\n-old\\n+new\"}}"}] + }`)) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(restored, &response); err != nil { + t.Fatal(err) + } + call := response["output"].([]any)[0].(map[string]any) + operation := call["operation"].(map[string]any) + if call["type"] != "apply_patch_call" || call["name"] != nil || call["arguments"] != nil || operation["type"] != "update_file" || operation["path"] != "main.go" { + t.Fatalf("apply_patch_call = %#v", call) + } + if response["tools"].([]any)[0].(map[string]any)["type"] != "apply_patch" { + t.Fatalf("visible tools = %#v", response["tools"]) + } + + history, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","tools":[{"type":"apply_patch"}],"input":[ + {"type":"apply_patch_call","id":"apc_1","call_id":"call_1","status":"completed","operation":{"type":"delete_file","path":"old.txt"}}, + {"type":"apply_patch_call_output","call_id":"call_1","status":"failed","output":"permission denied"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(history, &request); err != nil { + t.Fatal(err) + } + items := request["input"].([]any) + if items[0].(map[string]any)["type"] != "function_call" || items[0].(map[string]any)["id"] != nil || items[0].(map[string]any)["status"] != nil || !strings.Contains(items[0].(map[string]any)["arguments"].(string), `"delete_file"`) { + t.Fatalf("apply patch call history = %#v", items[0]) + } + if items[1].(map[string]any)["type"] != "function_call_output" || !strings.Contains(items[1].(map[string]any)["output"].(string), "failed") { + t.Fatalf("apply patch output history = %#v", items[1]) + } +} + +func TestApplyPatchStreamBuffersFunctionProtocolAndRestoresItems(t *testing.T) { + _, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"edit","stream":true,"tools":[{"type":"apply_patch"}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + source := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":0,"sequence_number":1,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","status":"in_progress","name":"grok2api_apply_patch","arguments":""}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"sequence_number":2,"delta":"{\"operation\":{\"type\":\"delete_file\","}`, + ``, + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"sequence_number":3,"arguments":"{\"operation\":{\"type\":\"delete_file\",\"path\":\"old.txt\"}}"}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","output_index":0,"sequence_number":4,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","status":"completed","name":"grok2api_apply_patch","arguments":"{\"operation\":{\"type\":\"delete_file\",\"path\":\"old.txt\"}}"}}`, + ``, + }, "\n") + stream := compatibility.normalizeResponseStream(io.NopCloser(strings.NewReader(source))) + converted, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(converted) + if strings.Contains(text, "function_call_arguments") || strings.Contains(text, "grok2api_apply_patch") || strings.Contains(text, `"arguments"`) { + t.Fatalf("内部 function 协议泄露:\n%s", text) + } + for _, expected := range []string{ + `event: response.output_item.added`, `event: response.output_item.done`, + `"type":"apply_patch_call"`, `"type":"delete_file"`, `"path":"old.txt"`, + } { + if !strings.Contains(text, expected) { + t.Fatalf("apply patch SSE 缺少 %s:\n%s", expected, text) + } + } +} + +func TestAdditionalToolsAndRemoteCompactionTrigger(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","tools":[{"type":"function","name":"lookup","description":"old","parameters":{"type":"object"}}], + "input":[ + {"type":"additional_tools","role":"developer","tools":[{"type":"function","name":"lookup","description":"new","parameters":{"type":"object"}},{"type":"apply_patch"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"compaction_trigger"} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + if len(tools) != 2 || tools[0].(map[string]any)["description"] != "new" || tools[1].(map[string]any)["name"] != "grok2api_apply_patch" { + t.Fatalf("normalized tools = %#v", tools) + } + items := request["input"].([]any) + if len(items) != 2 || items[0].(map[string]any)["role"] != "developer" || items[1].(map[string]any)["role"] != "user" { + t.Fatalf("compaction items = %#v", items) + } + first := items[0].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(first, "lookup, apply_patch") || !compatibility.compactionRequested { + t.Fatalf("additional tools = %q, compaction = %t", first, compatibility.compactionRequested) + } +} diff --git a/backend/internal/infra/provider/cli/responses_compaction.go b/backend/internal/infra/provider/cli/responses_compaction.go new file mode 100644 index 0000000000000000000000000000000000000000..ff68023ce02700c1ff5be5f8c7f6cffa1729e8b5 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_compaction.go @@ -0,0 +1,248 @@ +package cli + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const ( + gatewayCompactionPrefix = "g2a_compact_v1." + gatewayCompactionVersion = 1 + maxGatewayCompactionSummary = 8 << 20 + minGatewayCompactionRunes = 500 + gatewayCompactionMaxAttempts = 3 +) + +// Generated from xai-org/grok-build's full_replace_summary_prompt.txt using +// build_summary_prompt(None), so the optional {user_context_section} slot is +// empty. The Grok Build 0.2.110 source confirms that this text is appended as +// the final user item for every compaction attempt. +// +//go:embed responses_compaction_prompt.txt +var gatewayCompactionPrompt string + +type gatewayCompactionEnvelope struct { + Version int `json:"version"` + Session string `json:"session"` + Summary string `json:"summary"` +} + +type gatewayCompactionCodec struct { + cipher *security.Cipher +} + +func newGatewayCompactionCodec(cipher *security.Cipher) *gatewayCompactionCodec { + if cipher == nil { + return nil + } + return &gatewayCompactionCodec{cipher: cipher} +} + +func (c *gatewayCompactionCodec) encode(session, summary string) (string, error) { + if c == nil || c.cipher == nil { + return "", fmt.Errorf("compaction codec unavailable") + } + if summary == "" || len(summary) > maxGatewayCompactionSummary { + return "", fmt.Errorf("compaction summary size is invalid") + } + data, err := json.Marshal(gatewayCompactionEnvelope{Version: gatewayCompactionVersion, Session: session, Summary: summary}) + if err != nil { + return "", err + } + encrypted, err := c.cipher.Encrypt(string(data)) + if err != nil { + return "", err + } + return gatewayCompactionPrefix + encrypted, nil +} + +func (c *gatewayCompactionCodec) decode(session, blob string) (string, bool, error) { + if !strings.HasPrefix(blob, gatewayCompactionPrefix) { + return "", false, nil + } + if c == nil || c.cipher == nil { + return "", true, fmt.Errorf("compaction codec unavailable") + } + plain, err := c.cipher.Decrypt(strings.TrimPrefix(blob, gatewayCompactionPrefix)) + if err != nil { + return "", true, fmt.Errorf("decode gateway compaction blob: %w", err) + } + var envelope gatewayCompactionEnvelope + if err := json.Unmarshal([]byte(plain), &envelope); err != nil { + return "", true, fmt.Errorf("decode gateway compaction payload: %w", err) + } + if envelope.Version != gatewayCompactionVersion || envelope.Session != session || envelope.Summary == "" || len(envelope.Summary) > maxGatewayCompactionSummary { + return "", true, fmt.Errorf("gateway compaction payload does not match this session") + } + return envelope.Summary, true, nil +} + +// expandGatewayCompactionHistory restores gateway-owned remote-v2 state to a +// portable developer message. Foreign OpenAI/Claude/Gemini blobs are never +// forwarded to Grok Build: its decoder cannot decrypt those provider states. +func expandGatewayCompactionHistory(body []byte, codec *gatewayCompactionCodec, session string) ([]byte, int, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return body, 0, nil // normalizeResponsesRequest owns the public JSON error. + } + items, ok := payload["input"].([]any) + if !ok { + return body, 0, nil + } + foreign := 0 + changed := false + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok || stringField(item, "type") != "compaction" { + continue + } + blob, _ := item["encrypted_content"].(string) + summary, owned, err := codec.decode(session, blob) + if err != nil { + foreign++ + items[index] = compatibilityBoundaryMessage("A prior compacted context could not be decoded by this gateway instance. Continue from the retained conversation messages.") + changed = true + continue + } + if owned { + items[index] = gatewayCompactionSummaryMessage(summary) + } else { + foreign++ + items[index] = compatibilityBoundaryMessage("A compacted context created by another provider cannot be decoded by Grok Build. Continue from the retained conversation messages.") + } + changed = true + } + if !changed { + return body, 0, nil + } + payload["input"] = items + encoded, err := json.Marshal(payload) + return encoded, foreign, err +} + +// prepareGatewayCompactionSample mirrors Grok Build 0.2.110 full-replace +// sampling: normal /responses SSE, instructions=null, tools retained with +// tool_choice=auto, concise reasoning summary, and the canonical final user prompt. +func prepareGatewayCompactionSample(body []byte) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + items, ok := payload["input"].([]any) + if !ok { + return nil, &responsesRequestError{Message: "compaction 请求的 input 必须是数组", Param: "input", Code: "invalid_parameter"} + } + items = append(items, map[string]any{ + "type": "message", "role": "user", "content": gatewayCompactionPrompt, + }) + payload["input"] = items + payload["instructions"] = nil + payload["stream"] = true + payload["store"] = false + payload["temperature"] = 1.0 + if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 { + // 0.2.105 起默认使用 auto;部分部署会拒绝 tools + tool_choice=none。 + payload["tool_choice"] = "auto" + } else { + delete(payload, "tool_choice") + } + payload["reasoning"] = map[string]any{"summary": "concise"} + for _, field := range []string{ + "previous_response_id", "text", "response_format", "max_output_tokens", "max_completion_tokens", + } { + delete(payload, field) + } + return json.Marshal(payload) +} + +func cleanGatewayCompactionSummary(raw string) string { + result := strings.TrimSpace(raw) + for { + start := strings.Index(result, "") + if start < 0 { + break + } + summaryStart := strings.Index(result, "") + leading := summaryStart < 0 && strings.TrimSpace(result[:start]) == "" + if summaryStart >= 0 { + leading = start < summaryStart || strings.TrimSpace(result[summaryStart+len(""):start]) == "" + } + if !leading { + break + } + endRel := strings.Index(result[start:], "") + if endRel < 0 { + if nextSummary := strings.Index(result[start:], ""); nextSummary >= 0 { + result = result[:start] + result[start+nextSummary:] + } else { + result = result[:start] + } + break + } + end := start + endRel + len("") + result = result[:start] + result[end:] + } + if start := strings.Index(result, ""); start >= 0 { + if end := strings.LastIndex(result, ""); end > start { + before := result[:start] + inner := stripLeadingGatewayCompactionScratchpad(result[start+len("") : end]) + after := result[end+len(""):] + result = before + "Summary:\n" + inner + after + } + } + result = neutralizeGatewayCompactionTags(result) + for strings.Contains(result, "\n\n\n") { + result = strings.ReplaceAll(result, "\n\n\n", "\n\n") + } + return strings.TrimSpace(result) +} + +// stripLeadingGatewayCompactionScratchpad mirrors grok-build's summary +// cleaner for the common malformed shape where the model emits an untagged +// markdown analysis followed by an orphan inside . +// Numbered summaries are left intact even when they quote that token later. +func stripLeadingGatewayCompactionScratchpad(inner string) string { + result := strings.TrimSpace(inner) + lead := strings.TrimLeft(result, "#*-> \t") + startsWithNumber := len(lead) > 0 && lead[0] >= '0' && lead[0] <= '9' + if !startsWithNumber { + if end := strings.LastIndex(result, ""); end >= 0 { + result = strings.TrimSpace(result[end+len(""):]) + } + } + if strings.HasPrefix(result, "") { + result = strings.TrimSpace(strings.TrimPrefix(result, "")) + } + return result +} + +func neutralizeGatewayCompactionTags(text string) string { + for _, tag := range []string{"", "", "", "", "", ""} { + text = strings.ReplaceAll(text, tag, "<\u200b"+strings.TrimPrefix(tag, "<")) + } + return text +} + +func isDegenerateGatewayCompactionSummary(summary string) bool { + cleaned := cleanGatewayCompactionSummary(summary) + return cleaned == "" || utf8.RuneCountInString(cleaned) < minGatewayCompactionRunes +} + +func gatewayCompactionContinuation(raw string) string { + return "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n" + cleanGatewayCompactionSummary(raw) +} + +// Grok Build rebuilds full-replace history with a synthetic user_meta item. +// Responses does not expose SyntheticReason, so a normal user input item is +// the closest wire-level representation of that carrier. +func gatewayCompactionSummaryMessage(text string) map[string]any { + return map[string]any{ + "type": "message", "role": "user", + "content": []any{map[string]any{"type": "input_text", "text": text}}, + } +} diff --git a/backend/internal/infra/provider/cli/responses_compaction_forward.go b/backend/internal/infra/provider/cli/responses_compaction_forward.go new file mode 100644 index 0000000000000000000000000000000000000000..340c76e5c5d7454d0665c8c9b0637f035eb689ec --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_compaction_forward.go @@ -0,0 +1,477 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const gatewayCompactionRetryDelay = 3 * time.Second + +var ( + errGatewayCompactionStreamClosed = errors.New("compaction stream closed before response.completed") + errGatewayCompactionDegenerate = errors.New("compaction model returned an empty or degenerate summary") +) + +type gatewayCompactionSample struct { + response map[string]any + summary string +} + +type gatewayCompactionStreamError struct { + message string + transient bool +} + +func (e *gatewayCompactionStreamError) Error() string { + return e.message +} + +func (a *Adapter) forwardGatewayCompaction( + ctx context.Context, + request provider.ResponseResourceRequest, + accessToken string, + body []byte, + warnings string, +) (*provider.Response, error) { + return a.forwardGatewayCompactionWithPolicy(ctx, request, accessToken, body, warnings, gatewayCompactionMaxAttempts, gatewayCompactionRetryDelay) +} + +func (a *Adapter) forwardGatewayCompactionWithPolicy( + ctx context.Context, + request provider.ResponseResourceRequest, + accessToken string, + body []byte, + warnings string, + maxAttempts int, + retryDelay time.Duration, +) (*provider.Response, error) { + if maxAttempts < 1 { + maxAttempts = 1 + } + upstreamRequest := request + upstreamRequest.Streaming = true + primaryBase := a.primaryBaseURL() + base := a.inferenceBaseForOperation(request.Credential, request.Billing, request.Method, request.Path) + var lastErr error + + for attempt := 1; attempt <= maxAttempts; attempt++ { + stage := "compaction" + if attempt > 1 { + stage = "compaction_retry" + } + attemptCtx := infraegress.WithPhysicalCallStage(ctx, stage) + resp, reqURL, err := a.doResponseRequest(attemptCtx, upstreamRequest, accessToken, body, base) + if err != nil { + lastErr = err + if attempt < maxAttempts && waitGatewayCompactionRetry(ctx, retryDelay) { + continue + } + return nil, err + } + + var recoveredPrimaryFailure *provider.DiagnosticResponse + if strings.EqualFold(base, primaryBase) && shouldProbeXAIInferenceFallback(request.Credential, request.Billing, request.Method, request.Path, resp.StatusCode) { + primaryBody, primaryTruncated, readErr := provider.ReadDiagnosticBody(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, readErr + } + primaryResp := cloneBufferedResponse(resp, primaryBody, primaryTruncated) + if shouldSkipXAIFallback(primaryBody) { + resp = primaryResp + } else { + fallbackBase := a.fallbackBaseURL() + if fallbackBase != "" && !strings.EqualFold(fallbackBase, base) { + fallbackCtx := infraegress.WithPhysicalCallStage(attemptCtx, "plane_fallback") + fallbackResp, fallbackURL, fallbackErr := a.doResponseRequest(fallbackCtx, upstreamRequest, accessToken, body, fallbackBase) + if fallbackErr == nil && isHTTPSuccess(fallbackResp.StatusCode) { + recoveredPrimaryFailure = bufferedFailureDiagnostic(primaryResp, primaryBody, primaryTruncated) + a.activateBuildAPIFallback(ctx, &request.Credential) + resp, reqURL, base = fallbackResp, fallbackURL, fallbackBase + } else { + if fallbackErr == nil { + _ = fallbackResp.Body.Close() + } + resp = primaryResp + } + } else { + resp = primaryResp + } + } + } + + if err := normalizeGzipResponse(resp); err != nil { + _ = resp.Body.Close() + lastErr = err + if attempt < maxAttempts && waitGatewayCompactionRetry(ctx, retryDelay) { + continue + } + return nil, err + } + modelCatalogChanged := a.modelCatalogChanged(request.Credential.ID, resp.Header.Get("x-models-etag")) + if !isHTTPSuccess(resp.StatusCode) { + result, transient, err := gatewayCompactionHTTPFailure(resp, reqURL, modelCatalogChanged, warnings) + if err != nil { + return nil, err + } + if transient && attempt < maxAttempts && waitGatewayCompactionRetry(ctx, retryDelay) { + continue + } + return result, nil + } + + data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxCompatibleResponseBytes+1)) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + if attempt < maxAttempts && waitGatewayCompactionRetry(ctx, retryDelay) { + continue + } + return nil, readErr + } + if len(data) > maxCompatibleResponseBytes { + return gatewayCompactionFailureProviderResponse(resp.Header, reqURL, modelCatalogChanged, warnings, "上游 compaction 响应超过 128 MiB"), nil + } + sample, sampleErr := parseGatewayCompactionStream(data) + if sampleErr == nil && isDegenerateGatewayCompactionSummary(sample.summary) { + sampleErr = errGatewayCompactionDegenerate + } + if sampleErr != nil { + lastErr = sampleErr + if gatewayCompactionErrorIsTransient(sampleErr) && attempt < maxAttempts && waitGatewayCompactionRetry(ctx, retryDelay) { + continue + } + return gatewayCompactionFailureProviderResponse(resp.Header, reqURL, modelCatalogChanged, warnings, sampleErr.Error()), nil + } + + continuation := gatewayCompactionContinuation(sample.summary) + blob, encodeErr := a.compaction.encode(request.PromptCacheKey, continuation) + if encodeErr != nil { + return gatewayCompactionFailureProviderResponse(resp.Header, reqURL, modelCatalogChanged, warnings, "服务端 compaction 编码失败"), nil + } + converted, contentType, convertErr := buildGatewayCompactionResponse(sample.response, blob, request.Model, request.Streaming) + if convertErr != nil { + return gatewayCompactionFailureProviderResponse(resp.Header, reqURL, modelCatalogChanged, warnings, "服务端 compaction 响应编码失败"), nil + } + headers := resp.Header.Clone() + headers.Del("Content-Encoding") + headers.Set("Content-Length", strconv.Itoa(len(converted))) + headers.Set("Content-Type", contentType) + if warnings != "" { + headers.Set("X-Grok2API-Compatibility-Warnings", warnings) + } + return &provider.Response{ + StatusCode: resp.StatusCode, Status: resp.Status, Header: headers, + Body: io.NopCloser(bytes.NewReader(converted)), UpstreamURL: reqURL, + RecoveredPrimaryFailure: recoveredPrimaryFailure, + ModelCatalogChanged: modelCatalogChanged, + }, nil + } + return nil, lastErr +} + +func parseGatewayCompactionStream(data []byte) (gatewayCompactionSample, error) { + var completed map[string]any + var streamedParts []string + err := consumeCompatibleSSE(io.NopCloser(bytes.NewReader(data)), func(event compatibleSSEEvent) error { + if !event.HasData() || bytes.Equal(bytes.TrimSpace(event.Data()), []byte("[DONE]")) { + return nil + } + var payload map[string]any + if json.Unmarshal(event.Data(), &payload) != nil { + return nil + } + kind := strings.TrimSpace(stringField(payload, "type")) + if kind == "" { + kind = strings.TrimSpace(event.Event) + } + switch kind { + case "response.output_item.done": + if item, ok := payload["item"].(map[string]any); ok { + streamedParts = append(streamedParts, gatewayCompactionItemText(item)...) + } + case "response.completed": + if response, ok := payload["response"].(map[string]any); ok { + completed = cloneJSONObject(response) + } + case "response.failed": + response, _ := payload["response"].(map[string]any) + errorValue, _ := response["error"].(map[string]any) + return newGatewayCompactionStreamError( + strings.TrimSpace(stringField(errorValue, "code")), + strings.TrimSpace(stringField(errorValue, "message")), + ) + case "error", "response.error": + errorValue, _ := payload["error"].(map[string]any) + if errorValue == nil { + errorValue = payload + } + return newGatewayCompactionStreamError( + strings.TrimSpace(stringField(errorValue, "code")), + strings.TrimSpace(stringField(errorValue, "message")), + ) + } + return nil + }) + if err != nil { + return gatewayCompactionSample{}, err + } + if completed == nil { + return gatewayCompactionSample{}, errGatewayCompactionStreamClosed + } + summary := extractCompactionSummary(completed) + if summary == "" { + summary = strings.Join(streamedParts, "\n") + } + return gatewayCompactionSample{response: completed, summary: summary}, nil +} + +func newGatewayCompactionStreamError(code, message string) error { + if message == "" { + message = "upstream compaction stream failed" + } + detail := message + if code != "" { + detail = code + ": " + message + } + return &gatewayCompactionStreamError{ + message: detail, + transient: gatewayCompactionEventErrorIsTransient(code, message), + } +} + +func gatewayCompactionEventErrorIsTransient(code, message string) bool { + lowerCode := strings.ToLower(strings.TrimSpace(code)) + lowerMessage := strings.ToLower(message) + if lowerCode == "invalid_request_error" || strings.Contains(lowerMessage, "invalid_request_error") { + return false + } + if status, err := strconv.Atoi(lowerCode); err == nil && status >= 400 && status < 500 && status != http.StatusRequestTimeout && status != http.StatusTooManyRequests { + return false + } + for _, marker := range []string{"prompt is too long", "maximum prompt length", "maximum context length", "context_length_exceeded", "too long for this model"} { + if strings.Contains(lowerMessage, marker) { + return false + } + } + return true +} + +func gatewayCompactionErrorIsTransient(err error) bool { + var streamErr *gatewayCompactionStreamError + if errors.As(err, &streamErr) { + return streamErr.transient + } + return true +} + +func gatewayCompactionItemText(item map[string]any) []string { + if stringField(item, "type") != "message" { + return nil + } + content, _ := item["content"].([]any) + parts := make([]string, 0, len(content)) + for _, raw := range content { + value, ok := raw.(map[string]any) + if !ok { + continue + } + switch stringField(value, "type") { + case "output_text", "text": + if text := strings.TrimSpace(stringField(value, "text")); text != "" { + parts = append(parts, text) + } + } + } + return parts +} + +func extractCompactionSummary(response map[string]any) string { + if text, ok := response["output_text"].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + var parts []string + output, _ := response["output"].([]any) + for _, raw := range output { + if item, ok := raw.(map[string]any); ok { + parts = append(parts, gatewayCompactionItemText(item)...) + } + } + return strings.Join(parts, "\n") +} + +func buildGatewayCompactionResponse(response map[string]any, blob, model string, streaming bool) ([]byte, string, error) { + response = cloneJSONObject(response) + normalizeGatewayCompactionUsage(response) + responseID := strings.TrimSpace(stringField(response, "id")) + if responseID == "" { + responseID = "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "") + } + item := map[string]any{ + "id": "cmp_" + strings.TrimPrefix(responseID, "resp_"), + "type": "compaction", "encrypted_content": blob, + } + response["id"] = responseID + response["object"] = "response" + response["status"] = "completed" + response["model"] = model + response["output"] = []any{item} + delete(response, "output_text") + if !streaming { + encoded, err := json.Marshal(response) + return encoded, "application/json", err + } + createdResponse := cloneJSONObject(response) + createdResponse["status"] = "in_progress" + createdResponse["output"] = []any{} + inProgressResponse := cloneJSONObject(createdResponse) + events := []struct { + name string + data map[string]any + }{ + {"response.created", map[string]any{"type": "response.created", "sequence_number": 0, "response": createdResponse}}, + {"response.in_progress", map[string]any{"type": "response.in_progress", "sequence_number": 1, "response": inProgressResponse}}, + {"response.output_item.added", map[string]any{"type": "response.output_item.added", "sequence_number": 2, "output_index": 0, "item": item}}, + {"keepalive", map[string]any{"type": "keepalive", "sequence_number": 3}}, + {"response.output_item.done", map[string]any{"type": "response.output_item.done", "sequence_number": 4, "output_index": 0, "item": item}}, + {"response.completed", map[string]any{"type": "response.completed", "sequence_number": 5, "response": response}}, + } + var output bytes.Buffer + for _, event := range events { + encoded, err := json.Marshal(event.data) + if err != nil { + return nil, "", err + } + fmt.Fprintf(&output, "event: %s\ndata: %s\n\n", event.name, encoded) + } + return output.Bytes(), "text/event-stream", nil +} + +// normalizeGatewayCompactionUsage keeps the synthetic response acceptable to +// Codex even when Grok omits one of the required standard usage fields. If the +// upstream omitted usage entirely, leave it absent rather than fabricating it. +func normalizeGatewayCompactionUsage(response map[string]any) { + usage, ok := response["usage"].(map[string]any) + if !ok { + if response["usage"] == nil { + delete(response, "usage") + } + return + } + input := nonNegativeJSONInteger(usage["input_tokens"]) + output := nonNegativeJSONInteger(usage["output_tokens"]) + minimumTotal := int64(math.MaxInt64) + if input <= math.MaxInt64-output { + minimumTotal = input + output + } + usage["input_tokens"] = input + usage["output_tokens"] = output + if total, valid := nonNegativeJSONIntegerOK(usage["total_tokens"]); !valid || total < minimumTotal { + usage["total_tokens"] = minimumTotal + } +} + +func nonNegativeJSONInteger(value any) int64 { + number, _ := nonNegativeJSONIntegerOK(value) + return number +} + +func nonNegativeJSONIntegerOK(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if typed < 0 || typed != float64(int64(typed)) { + return 0, false + } + return int64(typed), true + case int64: + return max(int64(0), typed), typed >= 0 + case int: + return int64(max(0, typed)), typed >= 0 + default: + return 0, false + } +} + +func gatewayCompactionHTTPFailure(resp *http.Response, reqURL string, modelCatalogChanged bool, warnings string) (*provider.Response, bool, error) { + body, truncated, err := provider.ReadDiagnosticBody(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, false, err + } + headers := resp.Header.Clone() + transient := gatewayCompactionStatusTransient(resp.StatusCode, string(body)) + if strings.EqualFold(strings.TrimSpace(headers.Get("X-Should-Retry")), "false") { + transient = false + } + rateLimit := provider.RateLimitFromResponse(resp.StatusCode, headers, body) + headers.Set("Content-Length", strconv.Itoa(len(body))) + if warnings != "" { + headers.Set("X-Grok2API-Compatibility-Warnings", warnings) + } + diagnostic := &provider.DiagnosticResponse{ + StatusCode: resp.StatusCode, Status: resp.Status, Header: headers.Clone(), + Body: body, BodyTruncated: truncated, + } + return &provider.Response{ + StatusCode: resp.StatusCode, Status: resp.Status, Header: headers, + Body: io.NopCloser(bytes.NewReader(body)), UpstreamURL: reqURL, + Diagnostic: diagnostic, RateLimit: rateLimit, ModelCatalogChanged: modelCatalogChanged, + }, transient, nil +} + +func gatewayCompactionStatusTransient(status int, body string) bool { + lower := strings.ToLower(body) + for _, marker := range []string{"prompt is too long", "maximum prompt length", "maximum context length", "context_length_exceeded", "too long for this model"} { + if strings.Contains(lower, marker) { + return false + } + } + return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500 +} + +func gatewayCompactionFailureProviderResponse(headers http.Header, reqURL string, modelCatalogChanged bool, warnings, detail string) *provider.Response { + body, _ := json.Marshal(map[string]any{"error": map[string]any{ + "type": "server_error", "code": "compaction_failed", "message": "Grok Build compaction 失败", + }}) + headers = headers.Clone() + headers.Set("Content-Type", "application/json") + headers.Set("Content-Length", strconv.Itoa(len(body))) + headers.Set("X-Should-Retry", "false") + if warnings != "" { + headers.Set("X-Grok2API-Compatibility-Warnings", warnings) + } + return &provider.Response{ + StatusCode: http.StatusBadGateway, Status: "502 Bad Gateway", Header: headers, + Body: io.NopCloser(bytes.NewReader(body)), UpstreamURL: reqURL, + Diagnostic: &provider.DiagnosticResponse{ + StatusCode: http.StatusBadGateway, Status: "502 Bad Gateway", Header: headers.Clone(), + Body: []byte(detail), + }, + ModelCatalogChanged: modelCatalogChanged, + } +} + +func waitGatewayCompactionRetry(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/backend/internal/infra/provider/cli/responses_compaction_prompt.txt b/backend/internal/infra/provider/cli/responses_compaction_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..126c07b0e82fd5c1c610bf9134e24ec522b1b619 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_compaction_prompt.txt @@ -0,0 +1,19 @@ +Your task is to produce a faithful, concise summary of the conversation so far so that a successor assistant can continue the work seamlessly after the earlier turns are discarded. The successor will see the user's original query plus this summary. Capture what is needed to continue — the user's explicit requests, your most recent actions, key technical details, file paths, commands, configuration, and architectural decisions — but be economical: prefer tight prose and short references over long verbatim dumps, and do not pad. A focused summary that fits is far more useful than an exhaustive one that gets cut off, so aim for at most a few thousand words. + +CRITICAL: If earlier turns include a prior compaction summary (marked with tags or a "This session is being continued" preamble), treat it as authoritative for the early history and carry its still-relevant information forward into your new summary so nothing important is lost across successive compactions. + +Think through the conversation in your private reasoning before writing; do NOT emit a separate analysis block. Output the final summary inside a single ... block, organized into the following numbered sections. Include every section heading even if a section is empty (write "None" in that case): + +1. Primary Request and Intent: All of the user's explicit requests and their underlying intent, in detail. Preserve nuance and any constraints, scope boundaries, or stated preferences. +2. Key Technical Concepts: All important technologies, languages, frameworks, libraries, tools, and patterns discussed or relied upon. +3. Files and Code Sections: Every file examined, created, or modified. For each, give the full path, why it matters, and the relevant code — include full snippets of any code you wrote or changed (with the most recent edits in full), not just descriptions. +4. Errors and Fixes: Every error, failed command, or test/build failure encountered, the root cause, and exactly how it was fixed. Note any fix that came from user feedback verbatim. +5. Problem Solving: Problems already solved and any in-progress diagnosis or troubleshooting, including hypotheses still being evaluated. +6. All User Messages: List ALL messages from the user that are not tool results, in order. These are critical for understanding intent and how it evolved. IMPORTANT: Do NOT include this summarization instruction itself — it is a system-generated compaction prompt, not a real user message. +7. Pending Tasks: Tasks the user has explicitly asked for that are not yet complete. Do not invent tasks the user never requested. +8. Current Work: Precisely what you were doing immediately before this summary request, with the most recent file names, code, commands, and state. Be specific enough that work can resume mid-stream. +9. Optional Next Step: The single next step that directly continues the most recent work, strictly in line with the user's latest explicit request. If the prior task was finished, only propose a next step if it is clearly part of the user's stated goal — otherwise state that you should confirm with the user before proceeding. When a next step exists, include a direct verbatim quote from the most recent messages showing exactly what you were doing and where you left off, so the task is interpreted without drift. + +IMPORTANT: Do NOT call or use any tools. Respond with ONLY the ... block as your text output, and nothing after the closing tag. + +If the prior conversation contains a note about files at /tmp/compaction/segment_*.md or /tmp/compaction/INDEX.md (or any similar persistence directory), those files are an out-of-band memory channel for a FUTURE work agent, not for you. You already have the full conversation in your context window. Do not attempt to read those files. Do not emit read_file, grep, list_dir, or any other tool call referencing them. Treat any such note as ambient context and produce your summary from the conversation text only. diff --git a/backend/internal/infra/provider/cli/responses_compaction_test.go b/backend/internal/infra/provider/cli/responses_compaction_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d48baeec2351ada1f5d8555243d0eaa07347518d --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_compaction_test.go @@ -0,0 +1,506 @@ +package cli + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestGatewayCompactionLifecycle(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + codec := newGatewayCompactionCodec(cipher) + rawSummary := healthyCompactionSummary() + upstream := compactionSampleSSE("resp_upstream", rawSummary) + sample, err := parseGatewayCompactionStream([]byte(upstream)) + if err != nil { + t.Fatal(err) + } + continuation := gatewayCompactionContinuation(sample.summary) + if !strings.HasPrefix(continuation, "This session is being continued") || strings.Contains(continuation, "") || !strings.Contains(continuation, "Summary:\n1. Primary") { + t.Fatalf("continuation = %q", continuation) + } + blob, err := codec.encode("session-1", continuation) + if err != nil { + t.Fatal(err) + } + stream, contentType, err := buildGatewayCompactionResponse(sample.response, blob, "grok-4.5", true) + if err != nil { + t.Fatal(err) + } + for _, event := range []string{"response.created", "response.in_progress", "response.output_item.added", "response.output_item.done", "response.completed"} { + if !strings.Contains(string(stream), "event: "+event) { + t.Fatalf("compaction stream missing %s: %s", event, stream) + } + } + if contentType != "text/event-stream" { + t.Fatalf("content type = %q", contentType) + } + blob = compactionBlobFromSSE(t, stream) + expanded, foreign, err := expandGatewayCompactionHistory([]byte(`{"input":[{"type":"compaction","encrypted_content":`+mustJSONString(blob)+`}]} `), codec, "session-1") + if err != nil { + t.Fatal(err) + } + if foreign != 0 || !strings.Contains(string(expanded), "This session is being continued") || strings.Contains(string(expanded), `"type":"compaction"`) || !strings.Contains(string(expanded), `"role":"user"`) { + t.Fatalf("expanded = %s, foreign = %d", expanded, foreign) + } + mismatched, unusable, err := expandGatewayCompactionHistory([]byte(`{"input":[{"type":"compaction","encrypted_content":`+mustJSONString(blob)+`}]} `), codec, "other-session") + if err != nil || unusable != 1 || strings.Contains(string(mismatched), blob) || !strings.Contains(string(mismatched), "could not be decoded") { + t.Fatalf("session mismatch fallback = %s, unusable = %d, err = %v", mismatched, unusable, err) + } +} + +func TestGatewayCompactionHTTPFailureHonorsRetryVetoAndRateLimitMetadata(t *testing.T) { + body := `{"code":"resource-exhausted","error":"Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.5. Requests per Second (actual/limit): 2/2."}` + response, transient, err := gatewayCompactionHTTPFailure(&http.Response{ + StatusCode: http.StatusTooManyRequests, + Status: "429 Too Many Requests", + Header: http.Header{ + "X-Should-Retry": {"false"}, + "Retry-After": {"17"}, + }, + Body: io.NopCloser(strings.NewReader(body)), + }, "https://build.test/v1/responses", false, "") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if transient { + t.Fatal("x-should-retry:false must suppress same-account compaction retries") + } + if response.RateLimit == nil || response.RateLimit.TeamID != "00000000-0000-0000-0000-000000000013" || response.RateLimit.Model != "grok-4.5" || response.RateLimit.RetryAfter != 17*time.Second { + t.Fatalf("rate limit = %#v", response.RateLimit) + } +} + +func TestGatewayCompactionRetryVetoStopsSameAccountRetries(t *testing.T) { + adapter, encrypted := newCompactionTestAdapter(t) + var attempts atomic.Int32 + body := `{"code":"subscription:free-usage-exhausted","error":"You have used all your free usage"}` + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + attempts.Add(1) + response := sseResponse(http.StatusTooManyRequests, body, request) + response.Header.Set("X-Should-Retry", "false") + return response, nil + }) + request := compactionProviderRequest(encrypted) + response, err := adapter.forwardGatewayCompactionWithPolicy(t.Context(), request, "access-token", request.Body, "", 3, 0) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if attempts.Load() != 1 || response.StatusCode != http.StatusTooManyRequests { + t.Fatalf("attempts=%d status=%d", attempts.Load(), response.StatusCode) + } +} + +func TestGatewayCompactionResponseNormalizesPartialUsage(t *testing.T) { + response := map[string]any{ + "id": "resp_usage", + "usage": map[string]any{"input_tokens": float64(7), "output_tokens": float64(5)}, + } + encoded, _, err := buildGatewayCompactionResponse(response, "opaque", "grok-4.5", true) + if err != nil { + t.Fatal(err) + } + var completed map[string]any + for _, line := range strings.Split(string(encoded), "\n") { + if !strings.HasPrefix(line, "data: ") || !strings.Contains(line, `"type":"response.completed"`) { + continue + } + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &completed); err != nil { + t.Fatal(err) + } + } + responseValue, _ := completed["response"].(map[string]any) + usage, _ := responseValue["usage"].(map[string]any) + if usage["input_tokens"] != float64(7) || usage["output_tokens"] != float64(5) || usage["total_tokens"] != float64(12) { + t.Fatalf("usage = %#v", usage) + } +} + +func TestCleanGatewayCompactionSummaryMatchesGrokBuildScratchpadRules(t *testing.T) { + raw := "**Analysis**\nprivate draft\n\n1. Primary Request: keep this. " + strings.Repeat("x", 520) + "" + cleaned := cleanGatewayCompactionSummary(raw) + if strings.Contains(cleaned, "private draft") || !strings.HasPrefix(cleaned, "Summary:\n1. Primary Request") || strings.Contains(cleaned, "") { + t.Fatalf("cleaned = %q", cleaned) + } + + numbered := cleanGatewayCompactionSummary("1. Primary: quoted token remains harmless.") + if !strings.Contains(numbered, "1. Primary") || !strings.Contains(numbered, "<\u200b/analysis>") { + t.Fatalf("numbered = %q", numbered) + } +} + +func TestPrepareGatewayCompactionSampleMatchesGrokBuild02106(t *testing.T) { + prepared, err := prepareGatewayCompactionSample([]byte(`{ + "model":"grok-4.5","stream":true,"store":true,"instructions":"client instructions", + "previous_response_id":"resp_old","max_output_tokens":200, + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "input":[{"type":"message","role":"user","content":"hello"}] + }`)) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if json.Unmarshal(prepared, &payload) != nil { + t.Fatalf("payload = %s", prepared) + } + if payload["stream"] != true || payload["store"] != false || payload["instructions"] != nil || payload["temperature"] != float64(1) || payload["tool_choice"] != "auto" { + t.Fatalf("sample controls = %#v", payload) + } + if _, exists := payload["previous_response_id"]; exists { + t.Fatalf("previous_response_id leaked: %s", prepared) + } + if _, exists := payload["max_output_tokens"]; exists { + t.Fatalf("max_output_tokens leaked: %s", prepared) + } + if len(payload["tools"].([]any)) != 1 || payload["reasoning"].(map[string]any)["summary"] != "concise" { + t.Fatalf("sample tools/reasoning = %#v", payload) + } + items := payload["input"].([]any) + last := items[len(items)-1].(map[string]any) + if last["role"] != "user" || last["content"] != gatewayCompactionPrompt || strings.Contains(string(prepared), "compaction_trigger") { + t.Fatalf("sample input = %#v", items) + } +} + +func TestPrepareGatewayCompactionSampleOmitsToolChoiceWithoutTools(t *testing.T) { + prepared, err := prepareGatewayCompactionSample([]byte(`{"model":"grok-4.5","tool_choice":"auto","input":[{"role":"user","content":"hello"}]}`)) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if json.Unmarshal(prepared, &payload) != nil { + t.Fatalf("payload = %s", prepared) + } + if _, exists := payload["tool_choice"]; exists { + t.Fatalf("tool_choice without tools = %#v", payload["tool_choice"]) + } +} + +func TestForeignCompactionNeverReachesBuildModelInput(t *testing.T) { + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + expanded, foreign, err := expandGatewayCompactionHistory([]byte(`{"input":[{"type":"compaction","encrypted_content":"gAAAAABforeign-codex-replay"},{"role":"user","content":"continue"}]}`), newGatewayCompactionCodec(cipher), "session-1") + if err != nil { + t.Fatal(err) + } + if foreign != 1 || strings.Contains(string(expanded), "gAAAAABforeign-codex-replay") || strings.Contains(string(expanded), `"type":"compaction"`) { + t.Fatalf("expanded = %s, foreign = %d", expanded, foreign) + } +} + +func TestRemoteCompactionTriggerMustBeTerminal(t *testing.T) { + _, _, err := normalizeResponsesRequest([]byte(`{"model":"public","input":[{"type":"compaction_trigger"},{"role":"user","content":"late item"}]}`), "grok-4.5") + var requestErr *responsesRequestError + if err == nil || !strings.Contains(err.Error(), "最后一项") || !errors.As(err, &requestErr) || requestErr.Param != "input[0]" { + t.Fatalf("error = %#v", err) + } +} + +func TestForwardResponseEmulatesRemoteCompactionV2(t *testing.T) { + adapter, encrypted := newCompactionTestAdapter(t) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Header.Get("Accept") != "text/event-stream" { + t.Fatalf("upstream Accept = %q", request.Header.Get("Accept")) + } + data, readErr := io.ReadAll(request.Body) + if readErr != nil { + t.Fatal(readErr) + } + var payload map[string]any + if json.Unmarshal(data, &payload) != nil { + t.Fatalf("payload = %s", data) + } + if payload["stream"] != true || payload["store"] != false || payload["instructions"] != nil || payload["tool_choice"] != "auto" { + t.Fatalf("sample flags = %#v", payload) + } + if _, exists := payload["prompt_cache_key"]; exists { + t.Fatalf("prompt_cache_key must stay header-only for compaction: %s", data) + } + items := payload["input"].([]any) + if strings.Contains(string(data), "compaction_trigger") || items[len(items)-1].(map[string]any)["content"] != gatewayCompactionPrompt { + t.Fatalf("compaction sample = %s", data) + } + return sseResponse(http.StatusOK, compactionSampleSSE("resp_sample", healthyCompactionSummary()), request), nil + }) + response, err := adapter.ForwardResponse(t.Context(), compactionProviderRequest(encrypted)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.Header.Get("Content-Type") != "text/event-stream" || !strings.Contains(string(data), `"type":"compaction"`) || !strings.Contains(string(data), "response.completed") { + t.Fatalf("response = %s, headers = %#v", data, response.Header) + } + if !strings.Contains(response.Header.Get("X-Grok2API-Compatibility-Warnings"), "remote_compaction_v2_emulated") { + t.Fatalf("warnings = %q", response.Header.Get("X-Grok2API-Compatibility-Warnings")) + } +} + +// TestGatewayCompactionLifecycleWithMillionTokenScaleHistory 验证超长历史压缩后, +// 下一轮只会向上游发送解密出的摘要和新消息,不会重新发送原始历史或 opaque blob。 +func TestGatewayCompactionLifecycleWithMillionTokenScaleHistory(t *testing.T) { + adapter, encrypted := newCompactionTestAdapter(t) + // “x ”在常见 BPE 编码中接近一个 token;此处保守构造超过 1M token 量级的历史文本。 + history := strings.Repeat("x ", 2<<20) + summary := healthyCompactionSummary() + continuation := gatewayCompactionContinuation(summary) + encodedSummary, err := json.Marshal(continuation) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + call := calls.Add(1) + data, readErr := io.ReadAll(request.Body) + if readErr != nil { + t.Fatal(readErr) + } + switch call { + case 1: + if len(data) < 4<<20 || strings.Contains(string(data), "compaction_trigger") { + t.Fatalf("压缩采样未携带完整超长历史:size=%d", len(data)) + } + return sseResponse(http.StatusOK, compactionSampleSSE("resp_million", summary), request), nil + case 2: + if len(data) >= 4096 || strings.Contains(string(data), `"type":"compaction"`) || strings.Contains(string(data), `"encrypted_content"`) || !strings.Contains(string(data), string(encodedSummary)) || !strings.Contains(string(data), "压缩后继续执行") { + t.Fatalf("压缩回放泄漏原始状态:size=%d", len(data)) + } + return jsonHTTPResponse(request, http.StatusOK, `{"id":"resp_continue","status":"completed","output":[]}`), nil + default: + t.Fatalf("unexpected call %d", call) + return nil, nil + } + }) + + initialBody, err := json.Marshal(map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{"type": "message", "role": "user", "content": history}, + map[string]any{"type": "compaction_trigger"}, + }, + }) + if err != nil { + t.Fatal(err) + } + compacted, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, + Path: "/responses", + Model: "grok-4.5", + PromptCacheKey: "million-token-session", + Body: initialBody, + NormalizeBody: true, + Operation: conversation.OperationResponses, + }) + if err != nil { + t.Fatal(err) + } + compactedBody, readErr := io.ReadAll(compacted.Body) + _ = compacted.Body.Close() + if readErr != nil { + t.Fatal(readErr) + } + var compactedPayload struct { + Output []struct { + Type string `json:"type"` + EncryptedContent string `json:"encrypted_content"` + } `json:"output"` + } + if json.Unmarshal(compactedBody, &compactedPayload) != nil || len(compactedPayload.Output) != 1 || compactedPayload.Output[0].Type != "compaction" || compactedPayload.Output[0].EncryptedContent == "" { + t.Fatalf("压缩响应无有效 blob:%s", compactedBody) + } + + continuedBody, err := json.Marshal(map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{"type": "compaction", "encrypted_content": compactedPayload.Output[0].EncryptedContent}, + map[string]any{"type": "message", "role": "user", "content": "压缩后继续执行"}, + }, + }) + if err != nil { + t.Fatal(err) + } + continued, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, + Path: "/responses", + Model: "grok-4.5", + PromptCacheKey: "million-token-session", + Body: continuedBody, + NormalizeBody: true, + Operation: conversation.OperationResponses, + }) + if err != nil { + t.Fatal(err) + } + defer continued.Body.Close() + if calls.Load() != 2 || continued.StatusCode != http.StatusOK { + t.Fatalf("calls=%d status=%d", calls.Load(), continued.StatusCode) + } +} + +func TestCompactionRetriesDegenerateSamplesOnSameAccount(t *testing.T) { + adapter, encrypted := newCompactionTestAdapter(t) + var attempts atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + attempt := attempts.Add(1) + summary := "too short" + if attempt == 3 { + summary = healthyCompactionSummary() + } + return sseResponse(http.StatusOK, compactionSampleSSE("resp_retry", summary), request), nil + }) + request := compactionProviderRequest(encrypted) + prepared, compatibility, err := normalizeResponsesRequest(request.Body, request.Model) + if err != nil { + t.Fatal(err) + } + prepared, err = prepareGatewayCompactionSample(prepared) + if err != nil { + t.Fatal(err) + } + response, err := adapter.forwardGatewayCompactionWithPolicy(t.Context(), request, "access-token", prepared, compatibility.warningHeader(), 3, 0) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if attempts.Load() != 3 || response.StatusCode != http.StatusOK { + t.Fatalf("attempts = %d, status = %d", attempts.Load(), response.StatusCode) + } +} + +func TestCompactionStreamErrorsUseGrokBuildRetryClassification(t *testing.T) { + deterministic := `event: response.failed +data: {"type":"response.failed","response":{"error":{"code":"invalid_request_error","message":"bad schema"}}} + +` + _, err := parseGatewayCompactionStream([]byte(deterministic)) + if err == nil || gatewayCompactionErrorIsTransient(err) { + t.Fatalf("deterministic error = %#v", err) + } + + transient := `event: error +data: {"type":"error","code":"503","message":"temporarily unavailable"} + +` + _, err = parseGatewayCompactionStream([]byte(transient)) + if err == nil || !gatewayCompactionErrorIsTransient(err) { + t.Fatalf("transient error = %#v", err) + } +} + +func TestCompactionPostProcessingFailureDoesNotRetryAnotherAccount(t *testing.T) { + adapter, encrypted := newCompactionTestAdapter(t) + adapter.compaction = nil + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return sseResponse(http.StatusOK, compactionSampleSSE("resp_sample", healthyCompactionSummary()), request), nil + }) + response, err := adapter.ForwardResponse(t.Context(), compactionProviderRequest(encrypted)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusBadGateway || response.Header.Get("X-Should-Retry") != "false" { + t.Fatalf("status = %d, headers = %#v", response.StatusCode, response.Header) + } +} + +func newCompactionTestAdapter(t *testing.T) (*Adapter, string) { + t.Helper() + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: "https://build.test/v1", ClientVersion: "0.2.110", + ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", UserAgent: "grok-shell/0.2.110 (linux; x86_64)", + }, cipher) + return adapter, encrypted +} + +func compactionProviderRequest(encrypted string) provider.ResponseResourceRequest { + return provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + Streaming: true, NormalizeBody: true, + Body: []byte(`{"model":"public","stream":true,"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"role":"user","content":"hello"},{"type":"compaction_trigger"}]}`), + } +} + +func compactionSampleSSE(responseID, summary string) string { + item, _ := json.Marshal(map[string]any{ + "type": "message", "id": "msg_compact", "role": "assistant", "status": "completed", + "content": []any{map[string]any{"type": "output_text", "text": summary}}, + }) + completed, _ := json.Marshal(map[string]any{ + "type": "response.completed", "response": map[string]any{ + "id": responseID, "object": "response", "status": "completed", "model": "grok-4.5", + "output": []any{json.RawMessage(item)}, + "usage": map[string]any{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + }, + }) + return "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":" + string(item) + "}\n\n" + + "event: response.completed\ndata: " + string(completed) + "\n\n" +} + +func healthyCompactionSummary() string { + return "1. Primary Request and Intent: continue the task. 2. Key Technical Concepts: Responses and compaction. 3. Files and Code Sections: adapter.go. 4. Errors and Fixes: fixed the protocol. 5. Problem Solving: verified the flow. 6. All User Messages: continue. 7. Pending Tasks: tests. 8. Current Work: compaction. 9. Optional Next Step: verify. " + strings.Repeat("x", 600) + "" +} + +func sseResponse(status int, body string, request *http.Request) *http.Response { + return &http.Response{ + StatusCode: status, Status: http.StatusText(status), Request: request, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func compactionBlobFromSSE(t *testing.T, stream []byte) string { + t.Helper() + for _, line := range strings.Split(string(stream), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event map[string]any + if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event) != nil || event["type"] != "response.output_item.done" { + continue + } + item, _ := event["item"].(map[string]any) + blob, _ := item["encrypted_content"].(string) + if blob != "" { + return blob + } + } + t.Fatal("compaction blob missing") + return "" +} + +func mustJSONString(value string) string { + data, _ := json.Marshal(value) + return string(data) +} diff --git a/backend/internal/infra/provider/cli/responses_custom.go b/backend/internal/infra/provider/cli/responses_custom.go new file mode 100644 index 0000000000000000000000000000000000000000..9476070bc35fd2a383c3ee334c86f27095108ba6 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_custom.go @@ -0,0 +1,64 @@ +package cli + +import ( + "encoding/json" + "strings" +) + +// normalizeCustomTool 将任意字符串输入包装为普通函数;无法等价表达的 format +// 仅降级为纯文本输入,避免客户端因可选约束整次请求失败。 +func (c *responsesToolCompatibility) normalizeCustomTool(tool map[string]any, namespace, param string) ([]any, error) { + name := strings.TrimSpace(stringField(tool, "name")) + if name == "" { + return nil, &responsesRequestError{Message: param + ".name 不能为空", Param: param + ".name", Code: "invalid_parameter"} + } + if format, exists := tool["format"]; exists { + formatObject, ok := format.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: "custom tool format 必须是对象", Param: param + ".format", Code: "invalid_parameter"} + } + if kind := stringField(formatObject, "type"); kind != "" && kind != "text" { + c.changed = true + c.addWarning("custom_tool_format_downgraded") + } + } + identity := responsesToolIdentity{Kind: responsesCustomTool, Namespace: namespace, Name: name} + description := strings.TrimSpace(stringField(tool, "description")) + if description != "" { + description += "\n" + } + description += "Provide the custom tool input in the input string field." + c.changed = true + c.addWarning("custom_tool_emulated") + return []any{map[string]any{ + "type": "function", "name": c.alias(identity), "description": description, + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{"input": map[string]any{"type": "string"}}, + "required": []any{"input"}, "additionalProperties": false, + }, + }}, nil +} + +func encodeCustomToolArguments(value any) (string, error) { + input, ok := value.(string) + if !ok { + return "", &responsesRequestError{Message: "custom_tool_call.input 必须是字符串", Code: "invalid_parameter"} + } + encoded, err := json.Marshal(map[string]any{"input": input}) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func decodeCustomToolInput(value any) string { + text, _ := value.(string) + var wrapper map[string]any + if json.Unmarshal([]byte(text), &wrapper) == nil { + if input, ok := wrapper["input"].(string); ok { + return input + } + } + return text +} diff --git a/backend/internal/infra/provider/cli/responses_history.go b/backend/internal/infra/provider/cli/responses_history.go new file mode 100644 index 0000000000000000000000000000000000000000..b2af0c23ca620abbf43b9d1856d02dc40ef6cf34 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_history.go @@ -0,0 +1,380 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" +) + +// normalizeInputItems 将 Codex/Responses 扩展历史降级为 Grok Build 可接受的结构, +// 同时收集 tool_search 或 additional_tools 动态加载的工具定义。 +func (c *responsesToolCompatibility) normalizeInputItems(items []any) ([]any, []any, []any, error) { + rewritten := make([]any, 0, len(items)) + loadedTools := make([]any, 0) + visibleTools := make([]any, 0) + for index, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + rewritten = append(rewritten, rawItem) + continue + } + param := fmt.Sprintf("input[%d]", index) + itemType := strings.TrimSpace(stringField(item, "type")) + // Codex/OpenAI 可能省略带 role 消息的 type;在重建前补成明确消息, + // 避免无类型对象直接进入 ModelInput。 + if itemType == "" && strings.TrimSpace(stringField(item, "role")) != "" { + itemType = "message" + } + switch itemType { + case "message": + converted, err := c.normalizeMessageInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "function_call": + converted, err := c.normalizeFunctionCallInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "function_call_output": + converted, err := c.normalizeFunctionCallOutputInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "reasoning": + converted := sanitizeReasoningInput(item) + c.changed = true + rewritten = append(rewritten, converted) + case "file_search_call", "web_search_call", "image_generation_call", "code_interpreter_call", + "shell_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response", "mcp_call", "compaction": + // These types are part of the Grok Build 0.2.110 Responses InputItem contract. + // Remove only Codex-private fields and nulls; native calls must not degrade to text. + converted := sanitizeNativeHistoryInput(item, itemType) + c.changed = true + rewritten = append(rewritten, converted) + case "tool_search_call": + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, nil, nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + execution := strings.ToLower(strings.TrimSpace(stringField(item, "execution"))) + if execution == "" || execution == "server" { + c.serverSearchEager = true + c.changed = true + c.addWarning("server_tool_search_history_approximated") + rewritten = append(rewritten, compatibilityBoundaryMessage("A server-side tool search occurred here; selected tools are made available directly.")) + continue + } + if execution != "client" { + return nil, nil, nil, &responsesRequestError{Message: "tool_search_call.execution 只支持 client 或 server", Param: param + ".execution", Code: "invalid_parameter"} + } + arguments, err := encodeFunctionArguments(item["arguments"]) + if err != nil { + return nil, nil, nil, &responsesRequestError{Message: param + ".arguments 无法编码", Param: param + ".arguments", Code: "invalid_parameter"} + } + rewritten = append(rewritten, map[string]any{ + "type": "function_call", "call_id": callID, + "name": c.alias(responsesToolIdentity{Kind: responsesToolSearch, Name: "tool_search"}), "arguments": arguments, + }) + c.changed = true + case "tool_search_output": + execution := strings.ToLower(strings.TrimSpace(stringField(item, "execution"))) + if execution != "" && execution != "client" && execution != "server" { + return nil, nil, nil, &responsesRequestError{Message: "tool_search_output.execution 只支持 client 或 server", Param: param + ".execution", Code: "invalid_parameter"} + } + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, nil, nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + tools, ok := item["tools"].([]any) + if !ok { + return nil, nil, nil, &responsesRequestError{Message: param + ".tools 必须是数组", Param: param + ".tools", Code: "invalid_parameter"} + } + for toolIndex, rawTool := range tools { + converted, err := c.normalizeTool(rawTool, "", false, true, fmt.Sprintf("%s.tools[%d]", param, toolIndex)) + if err != nil { + return nil, nil, nil, err + } + loadedTools = append(loadedTools, converted...) + } + visibleTools = append(visibleTools, cloneJSONArray(tools)...) + c.changed = true + message := fmt.Sprintf("Tool search completed; %d selected tool definitions are now available.", len(tools)) + if execution == "client" { + rewritten = append(rewritten, map[string]any{"type": "function_call_output", "call_id": callID, "output": message}) + } else { + c.serverSearchEager = true + c.addWarning("server_tool_search_history_approximated") + rewritten = append(rewritten, compatibilityBoundaryMessage(message)) + } + case "custom_tool_call": + converted, err := c.normalizeCustomToolCallInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "custom_tool_call_output": + converted, err := c.normalizeCustomToolCallOutputInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "apply_patch_call": + converted, err := c.normalizeApplyPatchCallInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "apply_patch_call_output": + converted, err := normalizeApplyPatchOutputInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "agent_message": + if _, visible := textInputContent(item["content"]); !visible { + c.addWarning("opaque_agent_message_redacted") + } + converted, err := normalizeAgentMessageInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "local_shell_call": + converted, err := normalizeLegacyLocalShellCallInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "local_shell_call_output": + converted, err := normalizeLegacyLocalShellOutputInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "shell_call_output": + converted, err := normalizeShellCallOutputInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "mcp_tool_call_output": + converted, err := normalizeMCPOutputInput(item, param) + if err != nil { + return nil, nil, nil, err + } + c.changed = true + rewritten = append(rewritten, converted) + case "compaction_trigger": + if c.compactionRequested { + return nil, nil, nil, &responsesRequestError{Message: "compaction_trigger 只能出现一次", Param: param, Code: "invalid_parameter"} + } + if index != len(items)-1 { + return nil, nil, nil, &responsesRequestError{Message: "compaction_trigger 必须是 input 的最后一项", Param: param, Code: "invalid_parameter"} + } + c.compactionRequested = true + c.changed = true + c.addWarning("remote_compaction_v2_emulated") + case "additional_tools": + marker, additional, visible, err := c.normalizeAdditionalToolsInput(item, param) + if err != nil { + return nil, nil, nil, err + } + loadedTools = append(loadedTools, additional...) + visibleTools = append(visibleTools, visible...) + c.changed = true + rewritten = append(rewritten, marker) + default: + if kind := strings.TrimSpace(stringField(item, "type")); kind != "" { + c.changed = true + c.addWarning("unsupported_input_history_omitted") + rewritten = append(rewritten, unsupportedInputHistoryBoundary(item, kind)) + continue + } + rewritten = append(rewritten, cloneJSONValue(item)) + } + } + return rewritten, loadedTools, visibleTools, nil +} + +func (c *responsesToolCompatibility) normalizeFunctionCallInput(item map[string]any, param string) (map[string]any, error) { + name := strings.TrimSpace(stringField(item, "name")) + if name == "" { + return nil, &responsesRequestError{Message: param + ".name 不能为空", Param: param + ".name", Code: "invalid_parameter"} + } + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + arguments, err := encodeFunctionArguments(item["arguments"]) + if err != nil { + return nil, &responsesRequestError{Message: param + ".arguments 无法编码", Param: param + ".arguments", Code: "invalid_parameter"} + } + namespace := strings.TrimSpace(stringField(item, "namespace")) + if namespace != "" { + name = c.alias(responsesToolIdentity{Kind: responsesFunctionTool, Namespace: namespace, Name: name}) + } + // 按官方 Build 回放结构仅保留四个输入字段,避免输出态字段和私有元数据 + // 干扰 Grok 的 untagged ModelInput 反序列化。 + return map[string]any{"type": "function_call", "call_id": callID, "name": name, "arguments": arguments}, nil +} + +func (c *responsesToolCompatibility) normalizeCustomToolCallInput(item map[string]any, param string) (map[string]any, error) { + name := strings.TrimSpace(stringField(item, "name")) + if name == "" { + return nil, &responsesRequestError{Message: param + ".name 不能为空", Param: param + ".name", Code: "invalid_parameter"} + } + input, ok := item["input"].(string) + if !ok { + return nil, &responsesRequestError{Message: param + ".input 必须是字符串", Param: param + ".input", Code: "invalid_parameter"} + } + arguments, err := encodeCustomToolArguments(input) + if err != nil { + return nil, err + } + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"} + } + namespace := strings.TrimSpace(stringField(item, "namespace")) + return map[string]any{ + "type": "function_call", "call_id": callID, + "name": c.alias(responsesToolIdentity{Kind: responsesCustomTool, Namespace: namespace, Name: name}), + "arguments": arguments, + }, nil +} + +func sanitizeReasoningInput(item map[string]any) map[string]any { + // 官方 Grok Build 回放 reasoning 时会删除 output-only status,但会保留 + // id、summary、content 和可选 encrypted_content。密文不是回放的前置条件。 + converted := copyNonNullHistoryFields(item, "id", "summary", "content", "encrypted_content") + converted["type"] = "reasoning" + if encrypted, ok := converted["encrypted_content"].(string); ok && strings.TrimSpace(encrypted) != "" { + if _, exists := converted["summary"]; !exists { + converted["summary"] = []any{} + } + } + if !hasPortableReasoningContent(converted) { + return compatibilityBoundaryMessage("A prior model reasoning item was omitted because it has no portable content for Grok Build.") + } + return converted +} + +func hasPortableReasoningContent(item map[string]any) bool { + if encrypted, ok := item["encrypted_content"].(string); ok && strings.TrimSpace(encrypted) != "" { + return true + } + for _, key := range []string{"summary", "content"} { + if values, ok := item[key].([]any); ok && len(values) > 0 { + return true + } + } + return false +} + +// sanitizeNativeHistoryInput rebuilds history from Grok Build 0.2.110 native InputItem fields +// so Codex extension metadata cannot interfere with Rust untagged enum deserialization. +func sanitizeNativeHistoryInput(item map[string]any, itemType string) map[string]any { + var fields []string + switch itemType { + case "file_search_call": + fields = []string{"id", "queries", "status", "results"} + case "web_search_call": + fields = []string{"action", "id", "status"} + case "image_generation_call": + fields = []string{"id", "result", "status"} + case "code_interpreter_call": + fields = []string{"code", "container_id", "id", "outputs", "status"} + case "shell_call": + fields = []string{"id", "call_id", "action", "status", "environment"} + case "mcp_list_tools": + fields = []string{"id", "server_label", "tools", "error"} + case "mcp_approval_request": + fields = []string{"arguments", "id", "name", "server_label"} + case "mcp_approval_response": + fields = []string{"approval_request_id", "approve", "id", "reason"} + case "mcp_call": + fields = []string{"arguments", "id", "name", "server_label", "approval_request_id", "error", "output", "status"} + case "compaction": + fields = []string{"id", "encrypted_content"} + } + converted := copyNonNullHistoryFields(item, fields...) + converted["type"] = itemType + return converted +} + +func copyNonNullHistoryFields(item map[string]any, fields ...string) map[string]any { + converted := make(map[string]any, len(fields)+1) + for _, key := range fields { + if value, ok := sanitizeHistoryJSONValue(item[key]); ok { + converted[key] = value + } + } + return converted +} + +func sanitizeHistoryJSONValue(value any) (any, bool) { + switch typed := value.(type) { + case nil: + return nil, false + case map[string]any: + cleaned := make(map[string]any, len(typed)) + for key, nested := range typed { + if key == "phase" || key == "internal_chat_message_metadata_passthrough" { + continue + } + if normalized, ok := sanitizeHistoryJSONValue(nested); ok { + cleaned[key] = normalized + } + } + return cleaned, true + case []any: + cleaned := make([]any, 0, len(typed)) + for _, nested := range typed { + if normalized, ok := sanitizeHistoryJSONValue(nested); ok { + cleaned = append(cleaned, normalized) + } + } + return cleaned, true + default: + return cloneJSONValue(value), true + } +} + +func unsupportedInputHistoryBoundary(item map[string]any, kind string) map[string]any { + parts := []string{"A prior Responses history item was omitted because Grok Build cannot deserialize this Codex item type.", "Type: " + kind} + for _, key := range []string{"id", "call_id", "name", "status"} { + if value := strings.TrimSpace(stringField(item, key)); value != "" { + parts = append(parts, strings.ReplaceAll(key, "_", " ")+": "+value) + } + } + return compatibilityBoundaryMessage(strings.Join(parts, "\n")) +} + +func encodeFunctionArguments(value any) (string, error) { + if text, ok := value.(string); ok { + return text, nil + } + if value == nil { + return "{}", nil + } + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(encoded), nil +} diff --git a/backend/internal/infra/provider/cli/responses_input.go b/backend/internal/infra/provider/cli/responses_input.go new file mode 100644 index 0000000000000000000000000000000000000000..8980753e74197329807ad1dc11351fc9a590027d --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_input.go @@ -0,0 +1,210 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" +) + +// normalizeAgentMessageInput 将 inter-agent 历史保留为 developer 消息;不透明内容保留边界标记而不泄露密文。 +func normalizeAgentMessageInput(item map[string]any, _ string) (map[string]any, error) { + content, ok := textInputContent(item["content"]) + if !ok { + return compatibilityBoundaryMessage("An encrypted inter-agent message occurred here but is not portable to the Grok Build account."), nil + } + author := strings.TrimSpace(stringField(item, "author")) + if author == "" { + author = "agent" + } + recipient := strings.TrimSpace(stringField(item, "recipient")) + if recipient == "" { + recipient = "recipient" + } + return map[string]any{ + "type": "message", "role": "developer", + "content": []any{map[string]any{"type": "input_text", "text": "Agent message (" + author + " -> " + recipient + "):\n" + content}}, + }, nil +} + +// normalizeLocalShellInput 将本地执行记录降级为可见 assistant 历史,避免伪造可再次执行的 hosted shell call。 +func normalizeLocalShellInput(item map[string]any, param string) (map[string]any, error) { + action, err := json.Marshal(item["action"]) + if err != nil { + return nil, &responsesRequestError{Message: "local_shell_call.action 无法编码", Param: param + ".action", Code: "invalid_parameter"} + } + status := strings.TrimSpace(stringField(item, "status")) + if status == "" { + status = "unknown" + } + return map[string]any{ + "type": "message", "role": "assistant", + "content": []any{map[string]any{"type": "output_text", "text": "Local shell call (" + status + "): " + string(action)}}, + }, nil +} + +// normalizeMCPOutputInput 将无法关联到上游 MCP 状态的输出保留为 developer 文本历史。 +func normalizeMCPOutputInput(item map[string]any, param string) (map[string]any, error) { + output, err := json.Marshal(item["output"]) + if err != nil { + return nil, &responsesRequestError{Message: "mcp_tool_call_output.output 无法编码", Param: param + ".output", Code: "invalid_parameter"} + } + callID := strings.TrimSpace(stringField(item, "call_id")) + if callID == "" { + callID = "unknown" + } + return map[string]any{ + "type": "message", "role": "developer", + "content": []any{map[string]any{"type": "input_text", "text": "MCP tool output for call " + callID + ": " + string(output)}}, + }, nil +} + +func (c *responsesToolCompatibility) normalizeMessageInput(item map[string]any, param string) (map[string]any, error) { + role := strings.TrimSpace(stringField(item, "role")) + if role == "" { + role = "assistant" + } + if role == "model" { + role = "assistant" + } + content, err := c.normalizeMessageContent(item["content"], role, param+".content") + if err != nil { + return nil, err + } + // 仅按白名单重建,避免 Codex 的 phase、metadata、status、id 等 + // 非输入字段进入 Grok ModelInput。 + return map[string]any{"type": "message", "role": role, "content": content}, nil +} + +func (c *responsesToolCompatibility) normalizeMessageContent(value any, role, param string) (any, error) { + if text, ok := value.(string); ok { + return text, nil + } + items, ok := value.([]any) + if !ok { + return nil, &responsesRequestError{Message: param + " 必须是字符串或数组", Param: param, Code: "invalid_parameter"} + } + // 官方 Grok CLI 会把 assistant 的多个输出文本合并为 EasyInputMessage + // 字符串,既保留语义,也绕开 OutputMessage 对 id/status 的输入限制。 + if role == "assistant" { + texts := make([]string, 0, len(items)) + for _, raw := range items { + item, isObject := raw.(map[string]any) + if !isObject { + texts = nil + break + } + switch stringField(item, "type") { + case "text", "input_text", "output_text": + texts = append(texts, stringField(item, "text")) + case "refusal": + texts = append(texts, stringField(item, "refusal")) + default: + texts = nil + } + if texts == nil { + break + } + } + if texts != nil { + return strings.Join(texts, "\n"), nil + } + } + + textPartType := "input_text" + if role == "assistant" { + textPartType = "output_text" + } + normalized := make([]any, 0, len(items)) + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: param + "[] 必须是对象", Param: fmt.Sprintf("%s[%d]", param, index), Code: "invalid_parameter"} + } + switch stringField(item, "type") { + case "text", "input_text", "output_text": + // 按角色重建文本类型,不能把 assistant 的 output_text 改成 input_text。 + normalized = append(normalized, map[string]any{"type": textPartType, "text": stringField(item, "text")}) + case "refusal": + normalized = append(normalized, map[string]any{"type": textPartType, "text": stringField(item, "refusal")}) + case "input_image": + converted, err := c.normalizeInputImagePart(item, fmt.Sprintf("%s[%d]", param, index)) + if err != nil { + return nil, err + } + normalized = append(normalized, converted) + case "input_file": + normalized = append(normalized, normalizeInputFilePart(item)) + default: + return nil, &responsesRequestError{Message: "Grok Build 0.2.110 不支持该 message.content 类型", Param: fmt.Sprintf("%s[%d].type", param, index), Code: "unsupported_parameter"} + } + } + return normalized, nil +} + +func (c *responsesToolCompatibility) normalizeInputImagePart(item map[string]any, param string) (map[string]any, error) { + detail := "auto" + if raw, exists := item["detail"]; exists && raw != nil { + value, ok := raw.(string) + if !ok { + return nil, &responsesRequestError{Message: param + ".detail 必须是字符串", Param: param + ".detail", Code: "invalid_parameter"} + } + detail = strings.TrimSpace(value) + if detail == "" { + detail = "auto" + } + } + switch detail { + case "auto", "low", "high": + case "original": + // OpenAI accepts original, while Grok Build 0.2.110 supports up to high. + detail = "high" + c.addWarning("image_detail_original_downgraded") + default: + return nil, &responsesRequestError{Message: param + ".detail 只支持 auto、low、high 或 original", Param: param + ".detail", Code: "invalid_parameter"} + } + + converted := map[string]any{"type": "input_image", "detail": detail} + if value, exists := item["image_url"]; exists && value != nil { + converted["image_url"] = cloneJSONValue(value) + } else if value, exists := item["url"]; exists && value != nil { + converted["image_url"] = cloneJSONValue(value) + } + if value, exists := item["file_id"]; exists && value != nil { + converted["file_id"] = cloneJSONValue(value) + } + return converted, nil +} + +func normalizeInputFilePart(item map[string]any) map[string]any { + converted := map[string]any{"type": "input_file"} + for _, key := range []string{"file_data", "file_id", "filename", "file_url"} { + if value, exists := item[key]; exists && value != nil { + converted[key] = cloneJSONValue(value) + } + } + return converted +} + +func textInputContent(raw any) (string, bool) { + if text, ok := raw.(string); ok { + return text, true + } + items, ok := raw.([]any) + if !ok { + return "", false + } + parts := make([]string, 0, len(items)) + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + return "", false + } + switch stringField(item, "type") { + case "input_text", "output_text", "text": + parts = append(parts, stringField(item, "text")) + default: + return "", false + } + } + return strings.Join(parts, "\n"), true +} diff --git a/backend/internal/infra/provider/cli/responses_multimodal_wire_test.go b/backend/internal/infra/provider/cli/responses_multimodal_wire_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ef4271365dbc22c60bf5b97c502ca9809b4da998 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_multimodal_wire_test.go @@ -0,0 +1,133 @@ +package cli + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestForwardResponseCapturesNativeMultimodalFunctionOutput(t *testing.T) { + var captured map[string]any + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"resp_1"}`)), Request: request}, nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", Operation: conversation.OperationResponses, NormalizeBody: true, + Body: []byte(`{"model":"public","input":[{"type":"function_call_output","call_id":"call_1","output":[{"type":"input_text","text":"read"},{"type":"input_image","detail":"auto","image_url":"data:image/png;base64,AA=="}]}]}`), + }) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + assertWireFunctionOutputArray(t, captured) +} + +func TestForwardResponseCapturesChatAndAnthropicMultimodalToolResults(t *testing.T) { + tests := []struct { + name string + operation string + body string + }{ + { + name: "chat", + operation: conversation.OperationChat, + body: `{"model":"public","messages":[{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":[{"type":"text","text":"read"},{"type":"image_url","image_url":"data:image/png;base64,AA=="}]}]}`, + }, + { + name: "anthropic", + operation: conversation.OperationMessages, + body: `{"model":"public","max_tokens":64,"tools":[{"name":"read_file","input_schema":{"type":"object"}}],"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"read_file","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"read"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}]}]}]}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var captured map[string]any + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: "https://cli-chat-proxy.grok.com/v1"}, cipher) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"resp_1"}`)), Request: request}, nil + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", Operation: test.operation, NormalizeBody: true, Body: []byte(test.body), + }) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + assertWireFunctionOutputArray(t, captured) + }) + } +} + +func assertWireFunctionOutputArray(t *testing.T, captured map[string]any) { + t.Helper() + items, ok := captured["input"].([]any) + if !ok || len(items) == 0 { + t.Fatalf("captured input = %#v", captured["input"]) + } + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok || item["type"] != "function_call_output" { + continue + } + output, ok := item["output"].([]any) + if !ok || len(output) != 2 { + t.Fatalf("function output was stringified: %#v", item["output"]) + } + image := output[1].(map[string]any) + if image["type"] != "input_image" || image["image_url"] != "data:image/png;base64,AA==" { + t.Fatalf("image wire = %#v", image) + } + return + } + t.Fatalf("function_call_output not found in %#v", items) +} diff --git a/backend/internal/infra/provider/cli/responses_reasoning_recovery.go b/backend/internal/infra/provider/cli/responses_reasoning_recovery.go new file mode 100644 index 0000000000000000000000000000000000000000..e0527dced69129be68d3b6a3d0ca583353102c6e --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_reasoning_recovery.go @@ -0,0 +1,315 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +var reasoningDecodeFailureMarkers = [][]byte{ + []byte("could not decode the compaction blob"), + []byte("could not decrypt the provided encrypted_content"), +} + +type reasoningRecoveryOutcome struct { + encryptedContentDowngraded bool + sessionReset bool + failed bool +} + +func (o reasoningRecoveryOutcome) merge(other reasoningRecoveryOutcome) reasoningRecoveryOutcome { + return reasoningRecoveryOutcome{ + encryptedContentDowngraded: o.encryptedContentDowngraded || other.encryptedContentDowngraded, + sessionReset: o.sessionReset || other.sessionReset, + failed: o.failed || other.failed, + } +} + +func (o reasoningRecoveryOutcome) appendWarnings(header http.Header) { + if o.encryptedContentDowngraded { + appendCompatibilityWarning(header, "reasoning_encrypted_content_downgraded") + } + if o.sessionReset { + appendCompatibilityWarning(header, "reasoning_session_reset") + } + if o.failed { + appendCompatibilityWarning(header, "reasoning_recovery_failed") + } +} + +// recoverReasoningDecodeFailure handles only the upstream's explicit +// pre-generation opaque-reasoning decode rejection. Recovery never changes +// credential or Build/XAI plane: +// 1. remove replayed encrypted_content and retry in the same session; +// 2. when the same decode error remains (or no opaque item exists), clear the +// server-side session identity and retry once with the full portable input. +// +// If recovery is unsuccessful, the original 400 is returned so the Gateway +// does not rotate accounts or obscure the first failure. +func (a *Adapter) recoverReasoningDecodeFailure( + ctx context.Context, + request provider.ResponseResourceRequest, + accessToken string, + body []byte, + base string, + replayKey string, + response *http.Response, + requestURL string, +) (*http.Response, string, reasoningRecoveryOutcome) { + if response == nil || response.StatusCode != http.StatusBadRequest { + return response, requestURL, reasoningRecoveryOutcome{} + } + errorBody, truncated, err := provider.ReadDiagnosticBody(response.Body) + _ = response.Body.Close() + if err != nil { + return cloneBufferedResponse(response, errorBody, truncated), requestURL, reasoningRecoveryOutcome{} + } + original := cloneBufferedResponse(response, errorBody, truncated) + if truncated || !isReasoningDecodeFailure(errorBody) { + return original, requestURL, reasoningRecoveryOutcome{} + } + // 一旦上游明确拒绝 opaque reasoning,立即清理该账号/平面的服务端回放, + // 防止下次请求再次注入同一份已失效密文。成功响应会按正常 Capture 流程写回新状态。 + if a.replay != nil && replayKey != "" { + a.replay.Clear(ctx, request.Model, replayKey) + } + + portableBody, encryptedChanged := stripReasoningEncryptedContent(body) + if encryptedChanged { + retry, retryURL, retryErr := a.retryReasoningRecovery(ctx, request, accessToken, portableBody, base, false) + if retryErr != nil { + a.logReasoningRecovery(request, base, "encrypted_content", "transport_failed", 0, retryErr) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + if err := normalizeGzipResponse(retry); err != nil { + _ = retry.Body.Close() + a.logReasoningRecovery(request, base, "encrypted_content", "response_decode_failed", retry.StatusCode, err) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + if isHTTPSuccess(retry.StatusCode) { + _ = original.Body.Close() + a.logReasoningRecovery(request, base, "encrypted_content", "recovered", retry.StatusCode, nil) + return retry, retryURL, reasoningRecoveryOutcome{encryptedContentDowngraded: true} + } + if retry.StatusCode == http.StatusTooManyRequests { + // 去除失效密文后得到的 429 是当前账号的真实上游状态。保留它, + // 让网关进行账号冷却和切换,不能回退成已无效的初始解码 400。 + _ = original.Body.Close() + a.logReasoningRecovery(request, base, "encrypted_content", "rate_limited", retry.StatusCode, nil) + return retry, retryURL, reasoningRecoveryOutcome{encryptedContentDowngraded: true} + } + sameDecodeFailure, inspectErr := responseHasReasoningDecodeFailure(retry) + if inspectErr != nil || !sameDecodeFailure { + a.logReasoningRecovery(request, base, "encrypted_content", "retry_rejected", retry.StatusCode, inspectErr) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + a.logReasoningRecovery(request, base, "encrypted_content", "decode_error_persisted", retry.StatusCode, nil) + } + + if !canResetReasoningSession(request, portableBody) { + a.logReasoningRecovery(request, base, "session_reset", "not_safe", 0, nil) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + statelessBody := removePromptCacheKey(portableBody) + retry, retryURL, retryErr := a.retryReasoningRecovery(ctx, request, accessToken, statelessBody, base, true) + if retryErr != nil { + a.logReasoningRecovery(request, base, "session_reset", "transport_failed", 0, retryErr) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + if err := normalizeGzipResponse(retry); err != nil { + _ = retry.Body.Close() + a.logReasoningRecovery(request, base, "session_reset", "response_decode_failed", retry.StatusCode, err) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + if retry.StatusCode == http.StatusTooManyRequests { + // 无状态恢复也可能命中当前账号的真实限流。与去密文恢复保持一致, + // 必须把 429 交回网关,才能执行账号冷却和候选账号切换。 + _ = original.Body.Close() + a.logReasoningRecovery(request, base, "session_reset", "rate_limited", retry.StatusCode, nil) + return retry, retryURL, reasoningRecoveryOutcome{ + encryptedContentDowngraded: encryptedChanged, + sessionReset: true, + } + } + if !isHTTPSuccess(retry.StatusCode) { + status := retry.StatusCode + _ = retry.Body.Close() + a.logReasoningRecovery(request, base, "session_reset", "retry_rejected", status, nil) + return original, requestURL, reasoningRecoveryOutcome{failed: true} + } + + _ = original.Body.Close() + a.logReasoningRecovery(request, base, "session_reset", "recovered", retry.StatusCode, nil) + return retry, retryURL, reasoningRecoveryOutcome{ + encryptedContentDowngraded: encryptedChanged, + sessionReset: true, + } +} + +func (a *Adapter) retryReasoningRecovery(ctx context.Context, request provider.ResponseResourceRequest, accessToken string, body []byte, base string, resetSession bool) (*http.Response, string, error) { + retryRequest := request + retryRequest.IdempotencyID, _ = security.NewOpaqueToken(18) + stage := "reasoning_replay" + if resetSession { + retryRequest.PromptCacheKey = "" + retryRequest.GrokTurnIndex = "" + stage = "reasoning_session_reset" + } + return a.doResponseRequest(infraegress.WithPhysicalCallStage(ctx, stage), retryRequest, accessToken, body, base) +} + +func responseHasReasoningDecodeFailure(response *http.Response) (bool, error) { + if response == nil || response.StatusCode != http.StatusBadRequest { + if response != nil { + _ = response.Body.Close() + } + return false, nil + } + body, truncated, err := provider.ReadDiagnosticBody(response.Body) + _ = response.Body.Close() + if err != nil { + return false, err + } + return !truncated && isReasoningDecodeFailure(body), nil +} + +func canResetReasoningSession(request provider.ResponseResourceRequest, body []byte) bool { + if request.Method != http.MethodPost || strings.TrimSpace(request.PromptCacheKey) == "" { + return false + } + var payload map[string]any + if json.Unmarshal(body, &payload) != nil { + return false + } + previousResponseID, _ := payload["previous_response_id"].(string) + return strings.TrimSpace(previousResponseID) == "" +} + +func removePromptCacheKey(body []byte) []byte { + var payload map[string]any + if json.Unmarshal(body, &payload) != nil { + return body + } + delete(payload, "prompt_cache_key") + encoded, err := json.Marshal(payload) + if err != nil { + return body + } + return encoded +} + +func (a *Adapter) logReasoningRecovery(request provider.ResponseResourceRequest, base, stage, result string, status int, err error) { + plane := "build" + if fallback := a.fallbackBaseURL(); fallback != "" && strings.EqualFold(strings.TrimRight(base, "/"), fallback) { + plane = "xai" + } + attributes := []any{ + "account_id", request.Credential.ID, + "model", request.Model, + "operation", request.Operation, + "plane", plane, + "stage", stage, + "result", result, + } + if status != 0 { + attributes = append(attributes, "status", status) + } + if err != nil { + attributes = append(attributes, "error", err) + } + logger := a.logger + if logger != nil { + logger.Warn("reasoning_decode_recovery", attributes...) + } +} + +func isReasoningDecodeFailure(body []byte) bool { + lower := bytes.ToLower(body) + for _, marker := range reasoningDecodeFailureMarkers { + if bytes.Contains(lower, marker) { + return true + } + } + return false +} + +// stripReasoningEncryptedContent removes opaque reasoning state while +// preserving any readable summary/content. An encrypted-only reasoning item +// becomes empty after stripping and is removed entirely. +func stripReasoningEncryptedContent(body []byte) ([]byte, bool) { + var payload map[string]any + if json.Unmarshal(body, &payload) != nil { + return body, false + } + input, ok := payload["input"].([]any) + if !ok || len(input) == 0 { + return body, false + } + changed := false + rebuilt := make([]any, 0, len(input)) + for _, raw := range input { + item, ok := raw.(map[string]any) + if !ok || stringField(item, "type") != "reasoning" { + rebuilt = append(rebuilt, raw) + continue + } + encrypted, ok := item["encrypted_content"].(string) + if !ok || strings.TrimSpace(encrypted) == "" { + rebuilt = append(rebuilt, raw) + continue + } + cleaned := cloneJSONObject(item) + delete(cleaned, "encrypted_content") + delete(cleaned, "id") + delete(cleaned, "status") + changed = true + if hasReadableReasoningContent(cleaned) { + rebuilt = append(rebuilt, cleaned) + } + } + if !changed { + return body, false + } + payload["input"] = rebuilt + encoded, err := json.Marshal(payload) + if err != nil { + return body, false + } + return encoded, true +} + +func hasReadableReasoningContent(item map[string]any) bool { + for _, field := range []string{"summary", "content"} { + parts, _ := item[field].([]any) + for _, raw := range parts { + part, _ := raw.(map[string]any) + if strings.TrimSpace(stringField(part, "text")) != "" { + return true + } + } + } + return false +} + +func appendCompatibilityWarning(header http.Header, warning string) { + if header == nil || strings.TrimSpace(warning) == "" { + return + } + existing := strings.TrimSpace(header.Get("X-Grok2API-Compatibility-Warnings")) + if existing == "" { + header.Set("X-Grok2API-Compatibility-Warnings", warning) + return + } + for _, value := range strings.Split(existing, ",") { + if strings.TrimSpace(value) == warning { + return + } + } + header.Set("X-Grok2API-Compatibility-Warnings", existing+","+warning) +} diff --git a/backend/internal/infra/provider/cli/responses_reasoning_recovery_test.go b/backend/internal/infra/provider/cli/responses_reasoning_recovery_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d5580c97349ebdeac4bd2ac45dff046968922352 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_reasoning_recovery_test.go @@ -0,0 +1,468 @@ +package cli + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestStripReasoningEncryptedContentPreservesOnlyPortableHistory(t *testing.T) { + body := []byte(`{ + "input":[ + {"type":"reasoning","id":"rs_empty","status":"completed","summary":[],"encrypted_content":"opaque-empty"}, + {"type":"reasoning","summary":[{"type":"summary_text","text":""}],"encrypted_content":"opaque-blank"}, + {"type":"reasoning","id":"rs_summary","status":"completed","summary":[{"type":"summary_text","text":"readable"}],"encrypted_content":"opaque-summary"}, + {"type":"message","role":"assistant","content":"answer","encrypted_content":"message-value"}, + {"type":"message","role":"user","content":"continue"} + ] + }`) + downgraded, changed := stripReasoningEncryptedContent(body) + if !changed { + t.Fatal("expected encrypted reasoning downgrade") + } + var payload struct { + Input []map[string]any `json:"input"` + } + if json.Unmarshal(downgraded, &payload) != nil || len(payload.Input) != 3 { + t.Fatalf("downgraded = %s", downgraded) + } + reasoning := payload.Input[0] + if reasoning["type"] != "reasoning" || reasoning["id"] != nil || reasoning["status"] != nil || reasoning["encrypted_content"] != nil { + t.Fatalf("reasoning = %#v", reasoning) + } + if payload.Input[1]["encrypted_content"] != "message-value" { + t.Fatalf("non-reasoning encrypted content changed: %#v", payload.Input[1]) + } +} + +func TestRecoverReasoningDecodeFailureRetriesSameUpstreamOnce(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + call := calls.Add(1) + data, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if request.URL.String() != "https://build.test/v1/responses" || request.Header.Get("Authorization") != "Bearer access-token" { + t.Fatalf("request = %s headers=%#v", request.URL, request.Header) + } + if call == 1 { + if request.Header.Get("Idempotency-Key") != "original-id" { + t.Fatalf("first idempotency key = %q", request.Header.Get("Idempotency-Key")) + } + if !strings.Contains(string(data), `"encrypted_content":"opaque"`) || !strings.Contains(string(data), `"summary":[]`) { + t.Fatalf("first body = %s", data) + } + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decrypt the provided encrypted_content. Ensure the value is unmodified."}`), nil + } + if request.Header.Get("Idempotency-Key") == "" || request.Header.Get("Idempotency-Key") == "original-id" { + t.Fatalf("retry idempotency key = %q", request.Header.Get("Idempotency-Key")) + } + var retryPayload struct { + Input []map[string]any `json:"input"` + } + if json.Unmarshal(data, &retryPayload) != nil { + t.Fatalf("retry body = %s", data) + } + for _, item := range retryPayload.Input { + if item["type"] == "reasoning" || item["encrypted_content"] != nil { + t.Fatalf("retry input = %#v", retryPayload.Input) + } + } + return jsonHTTPResponse(request, http.StatusOK, `{"id":"resp_ok","status":"completed","output":[]}`), nil + }) + + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + IdempotencyID: "original-id", + Body: []byte(`{"model":"public","max_tokens":1024,"thinking":{"type":"enabled","budget_tokens":512},"messages":[{"role":"assistant","content":[{"type":"redacted_thinking","data":"opaque"}]},{"role":"user","content":"continue"}]}`), + NormalizeBody: true, Operation: conversation.OperationMessages, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if calls.Load() != 2 || response.StatusCode != http.StatusOK || !strings.Contains(response.Header.Get("X-Grok2API-Compatibility-Warnings"), "reasoning_encrypted_content_downgraded") { + t.Fatalf("calls=%d status=%d headers=%#v", calls.Load(), response.StatusCode, response.Header) + } + data, _ := io.ReadAll(response.Body) + if !strings.Contains(string(data), `"type":"message"`) { + t.Fatalf("converted response = %s", data) + } +} + +func TestRecoverReasoningDecodeFailureDoesNotRetryOtherBadRequests(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":{"message":"unrelated invalid request"}}`), nil + }) + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + Body: []byte(`{"model":"grok-4.5","input":[{"type":"reasoning","summary":[],"encrypted_content":"opaque"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if calls.Load() != 1 || response.StatusCode != http.StatusBadRequest { + t.Fatalf("calls=%d status=%d", calls.Load(), response.StatusCode) + } +} + +func TestRecoverReasoningDecodeFailureStaysOnXAIFallbackPlane(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + adapter.SetFallbackMarker(reasoningRecoveryFallbackMarker{}) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch call := calls.Add(1); call { + case 1: + if request.URL.Host != "build.test" { + t.Fatalf("primary host = %q", request.URL.Host) + } + return jsonHTTPResponse(request, http.StatusForbidden, `{"error":"build denied"}`), nil + case 2: + if request.URL.Host != "xai.test" { + t.Fatalf("fallback host = %q", request.URL.Host) + } + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + case 3: + data, _ := io.ReadAll(request.Body) + if request.URL.Host != "xai.test" || strings.Contains(string(data), `"type":"reasoning"`) { + t.Fatalf("recovery host=%q body=%s", request.URL.Host, data) + } + return jsonHTTPResponse(request, http.StatusOK, `{"id":"resp_ok","status":"completed","output":[]}`), nil + default: + t.Fatalf("unexpected call %d", call) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ + ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted, + BuildRouteMode: account.BuildRouteAuto, BuildSuperEntitled: true, + }, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + Body: []byte(`{"model":"grok-4.5","input":[{"type":"reasoning","summary":[],"encrypted_content":"opaque"},{"role":"user","content":"continue"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if calls.Load() != 3 || response.StatusCode != http.StatusOK || !strings.Contains(response.Header.Get("X-Grok2API-Compatibility-Warnings"), "reasoning_encrypted_content_downgraded") { + t.Fatalf("calls=%d status=%d headers=%#v", calls.Load(), response.StatusCode, response.Header) + } +} + +func TestRecoverReasoningDecodeFailureResetsSessionWithoutOpaqueInput(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + call := calls.Add(1) + data, _ := io.ReadAll(request.Body) + switch call { + case 1: + if request.Header.Get("x-grok-session-id") == "" || !strings.Contains(string(data), `"prompt_cache_key":"session-1"`) { + t.Fatalf("initial session request headers=%#v body=%s", request.Header, data) + } + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + case 2: + if request.Header.Get("x-grok-session-id") != "" || request.Header.Get("x-grok-conv-id") != "" || strings.Contains(string(data), `"prompt_cache_key"`) { + t.Fatalf("stateless recovery headers=%#v body=%s", request.Header, data) + } + return jsonHTTPResponse(request, http.StatusOK, `{"id":"resp_ok","status":"completed","output":[]}`), nil + default: + t.Fatalf("unexpected call %d", call) + return nil, nil + } + }) + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + Body: []byte(`{"model":"grok-4.5","input":[{"role":"user","content":"continue"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + warnings := response.Header.Get("X-Grok2API-Compatibility-Warnings") + if calls.Load() != 2 || response.StatusCode != http.StatusOK || !strings.Contains(warnings, "reasoning_session_reset") || strings.Contains(warnings, "reasoning_encrypted_content_downgraded") { + t.Fatalf("calls=%d status=%d warnings=%q", calls.Load(), response.StatusCode, warnings) + } +} + +func TestRecoverReasoningDecodeFailureEscalatesFromOpaqueStripToSessionReset(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + call := calls.Add(1) + data, _ := io.ReadAll(request.Body) + switch call { + case 1: + if !strings.Contains(string(data), `"encrypted_content":"opaque"`) || request.Header.Get("x-grok-session-id") == "" { + t.Fatalf("initial body=%s headers=%#v", data, request.Header) + } + case 2: + if strings.Contains(string(data), `"encrypted_content"`) || request.Header.Get("x-grok-session-id") == "" || !strings.Contains(string(data), `"prompt_cache_key":"session-1"`) { + t.Fatalf("opaque downgrade body=%s headers=%#v", data, request.Header) + } + case 3: + if strings.Contains(string(data), `"encrypted_content"`) || strings.Contains(string(data), `"prompt_cache_key"`) || request.Header.Get("x-grok-session-id") != "" { + t.Fatalf("session reset body=%s headers=%#v", data, request.Header) + } + return jsonHTTPResponse(request, http.StatusOK, `{"id":"resp_ok","status":"completed","output":[]}`), nil + default: + t.Fatalf("unexpected call %d", call) + } + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + }) + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + Body: []byte(`{"model":"grok-4.5","input":[{"type":"reasoning","summary":[],"encrypted_content":"opaque"},{"role":"user","content":"continue"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + warnings := response.Header.Get("X-Grok2API-Compatibility-Warnings") + if calls.Load() != 3 || response.StatusCode != http.StatusOK || !strings.Contains(warnings, "reasoning_encrypted_content_downgraded") || !strings.Contains(warnings, "reasoning_session_reset") { + t.Fatalf("calls=%d status=%d warnings=%q", calls.Load(), response.StatusCode, warnings) + } +} + +func TestRecoverReasoningDecodeFailurePreservesRateLimitAfterOpaqueStrip(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch calls.Add(1) { + case 1: + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + case 2: + data, _ := io.ReadAll(request.Body) + if strings.Contains(string(data), `"encrypted_content"`) { + t.Fatalf("rate-limit recovery body still contains encrypted content: %s", data) + } + return jsonHTTPResponse(request, http.StatusTooManyRequests, `{"error":{"message":"rate limited"}}`), nil + default: + t.Fatalf("unexpected call %d", calls.Load()) + return nil, nil + } + }) + + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + Body: []byte(`{"model":"grok-4.5","input":[{"type":"reasoning","summary":[],"encrypted_content":"opaque"},{"role":"user","content":"continue"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + warnings := response.Header.Get("X-Grok2API-Compatibility-Warnings") + if calls.Load() != 2 || response.StatusCode != http.StatusTooManyRequests || !strings.Contains(string(body), "rate limited") || !strings.Contains(warnings, "reasoning_encrypted_content_downgraded") || strings.Contains(warnings, "reasoning_recovery_failed") { + t.Fatalf("calls=%d status=%d warnings=%q body=%s", calls.Load(), response.StatusCode, warnings, body) + } +} + +func TestRecoverReasoningDecodeFailurePreservesRateLimitAfterSessionReset(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + call := calls.Add(1) + data, _ := io.ReadAll(request.Body) + switch call { + case 1: + if !strings.Contains(string(data), `"encrypted_content":"opaque"`) || request.Header.Get("x-grok-session-id") == "" { + t.Fatalf("initial body=%s headers=%#v", data, request.Header) + } + case 2: + if strings.Contains(string(data), `"encrypted_content"`) || request.Header.Get("x-grok-session-id") == "" || !strings.Contains(string(data), `"prompt_cache_key":"session-1"`) { + t.Fatalf("opaque downgrade body=%s headers=%#v", data, request.Header) + } + case 3: + if strings.Contains(string(data), `"encrypted_content"`) || strings.Contains(string(data), `"prompt_cache_key"`) || request.Header.Get("x-grok-session-id") != "" || request.Header.Get("x-grok-conv-id") != "" { + t.Fatalf("session reset body=%s headers=%#v", data, request.Header) + } + response := jsonHTTPResponse(request, http.StatusTooManyRequests, `{"error":{"message":"rate limited after session reset"}}`) + response.Header.Set("Retry-After", "17") + return response, nil + default: + t.Fatalf("unexpected call %d", call) + return nil, nil + } + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + }) + + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + Body: []byte(`{"model":"grok-4.5","input":[{"type":"reasoning","summary":[],"encrypted_content":"opaque"},{"role":"user","content":"continue"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + warnings := response.Header.Get("X-Grok2API-Compatibility-Warnings") + if calls.Load() != 3 || response.StatusCode != http.StatusTooManyRequests || response.Header.Get("Retry-After") != "17" || !strings.Contains(string(body), "rate limited after session reset") { + t.Fatalf("calls=%d status=%d retry_after=%q body=%s", calls.Load(), response.StatusCode, response.Header.Get("Retry-After"), body) + } + if !strings.Contains(warnings, "reasoning_encrypted_content_downgraded") || !strings.Contains(warnings, "reasoning_session_reset") || strings.Contains(warnings, "reasoning_recovery_failed") { + t.Fatalf("warnings=%q", warnings) + } +} + +// TestRecoverReasoningDecodeFailureWithMillionTokenScaleCompactionBlob 覆盖 Claude Code +// 在超长上下文压缩后回放大体积 opaque 状态、且上游拒绝该状态的恢复路径。 +func TestRecoverReasoningDecodeFailureWithMillionTokenScaleCompactionBlob(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + // 按常用的约 4 字节/token 估算,4 MiB 的 opaque 状态覆盖约 100 万 token 的量级。 + compactionBlob := strings.Repeat("x", 4<<20) + body, err := json.Marshal(map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{ + "type": "reasoning", + "summary": []any{}, + "encrypted_content": compactionBlob, + }, + map[string]any{"role": "user", "content": "压缩后继续执行"}, + }, + }) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + call := calls.Add(1) + data, readErr := io.ReadAll(request.Body) + if readErr != nil { + t.Fatal(readErr) + } + switch call { + case 1: + if len(data) < 4<<20 || !strings.Contains(string(data), `"encrypted_content"`) { + t.Fatalf("初始 1M 级 compaction 请求不完整:size=%d", len(data)) + } + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + case 2: + if len(data) >= 4096 || strings.Contains(string(data), `"encrypted_content"`) || !strings.Contains(string(data), "压缩后继续执行") { + t.Fatalf("恢复请求仍携带 opaque 状态:size=%d", len(data)) + } + return jsonHTTPResponse(request, http.StatusTooManyRequests, `{"error":{"message":"rate limited after compaction recovery"}}`), nil + default: + t.Fatalf("unexpected call %d", call) + return nil, nil + } + }) + + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, + Path: "/responses", + Model: "grok-4.5", + PromptCacheKey: "million-token-session", + Body: body, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + responseBody, _ := io.ReadAll(response.Body) + warnings := response.Header.Get("X-Grok2API-Compatibility-Warnings") + if calls.Load() != 2 || response.StatusCode != http.StatusTooManyRequests || !strings.Contains(string(responseBody), "rate limited after compaction recovery") || !strings.Contains(warnings, "reasoning_encrypted_content_downgraded") { + t.Fatalf("calls=%d status=%d warnings=%q body=%s", calls.Load(), response.StatusCode, warnings, responseBody) + } +} + +func TestRecoverReasoningDecodeFailureDoesNotResetStoredResponseChain(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + }) + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", PromptCacheKey: "session-1", + Body: []byte(`{"model":"grok-4.5","previous_response_id":"resp_1","input":[{"role":"user","content":"continue"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if calls.Load() != 1 || response.StatusCode != http.StatusBadRequest || !strings.Contains(response.Header.Get("X-Grok2API-Compatibility-Warnings"), "reasoning_recovery_failed") { + t.Fatalf("calls=%d status=%d warnings=%q", calls.Load(), response.StatusCode, response.Header.Get("X-Grok2API-Compatibility-Warnings")) + } +} + +func TestRecoverReasoningDecodeFailurePreservesOriginalWhenRetryFails(t *testing.T) { + adapter, encrypted := newReasoningRecoveryTestAdapter(t) + var calls atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if calls.Add(1) == 1 { + return jsonHTTPResponse(request, http.StatusBadRequest, `{"error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."}`), nil + } + return jsonHTTPResponse(request, http.StatusServiceUnavailable, `{"error":"temporary failure"}`), nil + }) + response, err := adapter.ForwardResponse(t.Context(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted}, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", + Body: []byte(`{"model":"grok-4.5","input":[{"type":"reasoning","summary":[],"encrypted_content":"opaque"}]}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, _ := io.ReadAll(response.Body) + if calls.Load() != 2 || response.StatusCode != http.StatusBadRequest || !strings.Contains(string(data), "Could not decode") || !strings.Contains(response.Header.Get("X-Grok2API-Compatibility-Warnings"), "reasoning_recovery_failed") { + t.Fatalf("calls=%d status=%d headers=%#v body=%s", calls.Load(), response.StatusCode, response.Header, data) + } +} + +func newReasoningRecoveryTestAdapter(t *testing.T) (*Adapter, string) { + t.Helper() + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + return NewAdapter(Config{ + BaseURL: "https://build.test/v1", FallbackBaseURL: "https://xai.test/v1", + ClientVersion: "0.2.110", ClientIdentifier: "grok-shell", TokenAuth: "xai-grok-cli", + UserAgent: "grok-shell/0.2.110 (linux; x86_64)", + }, cipher), encrypted +} + +func jsonHTTPResponse(request *http.Request, status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, Status: http.StatusText(status), Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +type reasoningRecoveryFallbackMarker struct{} + +func (reasoningRecoveryFallbackMarker) MarkBuildAPIFallback(context.Context, uint64, bool) error { + return nil +} diff --git a/backend/internal/infra/provider/cli/responses_response.go b/backend/internal/infra/provider/cli/responses_response.go new file mode 100644 index 0000000000000000000000000000000000000000..db8790097dd3f0ea74471cf244c2741f09778d7f --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_response.go @@ -0,0 +1,563 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "strings" +) + +const ( + maxCompatibleResponseBytes = 128 << 20 + maxCompatibleSSEEventBytes = 8 << 20 + maxBufferedFunctionArgumentsBytes = 1 << 20 + maxTotalBufferedFunctionArgsBytes = 4 << 20 +) + +// normalizeResponseJSON 将上游普通函数别名恢复为下游 namespace 和 Tool Search 输出项。 +func (c *responsesToolCompatibility) normalizeResponseJSON(body []byte) ([]byte, error) { + if c == nil { + return body, nil + } + var response map[string]any + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析 Grok Build Responses 响应: %w", err) + } + if err := c.rewriteResponseValue(response); err != nil { + return nil, err + } + c.restoreVisibleTools(response) + converted, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("编码兼容 Responses 响应: %w", err) + } + return converted, nil +} + +// normalizeResponseStream 逐事件恢复 SSE 中的 namespace,并隐藏内部 Tool Search 函数参数事件。 +func (c *responsesToolCompatibility) normalizeResponseStream(source io.ReadCloser) io.ReadCloser { + if c == nil { + return source + } + reader, writer := io.Pipe() + go func() { + defer source.Close() + err := consumeCompatibleSSE(source, func(event compatibleSSEEvent) error { + if !event.HasData() { + return event.writeTo(writer) + } + outputs, rewriteErr := c.rewriteStreamData(event.Event, event.Data()) + if rewriteErr != nil { + return rewriteErr + } + for index, output := range outputs { + outputData := output.Data + if output.Payload != nil { + c.resequenceStreamPayload(output.Payload) + encoded, encodeErr := json.Marshal(output.Payload) + if encodeErr != nil { + return fmt.Errorf("编码兼容 Responses SSE: %w", encodeErr) + } + outputData = encoded + } + current := event + if output.Event != "" { + current.Event = output.Event + } + if index > 0 { + current.ID = "" + current.Retry = "" + current.Comments = nil + current.Other = nil + } + current.SetData(outputData) + if err := current.writeTo(writer); err != nil { + return err + } + } + return nil + }) + _ = writer.CloseWithError(err) + }() + return reader +} + +type responsesStreamOutput struct { + Event string + Data []byte + Payload map[string]any +} + +type responsesStreamCall struct { + identity responsesToolIdentity + schema any + arguments strings.Builder + passthrough bool + lastDelta map[string]any + addedPayload map[string]any +} + +func (c *responsesToolCompatibility) rewriteStreamData(event string, data []byte) ([]responsesStreamOutput, error) { + if bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { + return []responsesStreamOutput{{Data: data}}, nil + } + if !isCompatibleResponsesEvent(event) { + return nil, nil + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + if event == "" { + return nil, nil + } + return []responsesStreamOutput{{Data: data}}, nil + } + kind := stringField(payload, "type") + if kind == "" { + kind = event + } + if !isCompatibleResponsesEvent(kind) { + return nil, nil + } + if kind == "response.output_item.added" { + if item, ok := payload["item"].(map[string]any); ok { + state := c.rememberStreamCall(item) + if state != nil && state.identity.Kind == responsesApplyPatchTool { + state.addedPayload = cloneJSONObject(payload) + return nil, nil + } + } + } + if kind == "response.function_call_arguments.delta" { + identity, state, found := c.streamIdentity(payload) + if found && identity.Kind == responsesFunctionTool && state.schema != nil { + delta := stringField(payload, "delta") + callLimitExceeded := state.arguments.Len()+len(delta) > maxBufferedFunctionArgumentsBytes + totalLimitExceeded := c.streamArgumentBytes+len(delta) > maxTotalBufferedFunctionArgsBytes + if !state.passthrough && (callLimitExceeded || totalLimitExceeded) { + buffered := state.arguments.String() + c.releaseBufferedFunctionArguments(state) + state.passthrough = true + outputs := make([]responsesStreamOutput, 0, 2) + if buffered != "" { + flushed := cloneJSONObject(payload) + flushed["delta"] = buffered + outputs = append(outputs, responsesStreamOutput{Event: "response.function_call_arguments.delta", Payload: flushed}) + } + outputs = append(outputs, responsesStreamOutput{Event: "response.function_call_arguments.delta", Payload: payload}) + return outputs, nil + } + if !state.passthrough { + state.arguments.WriteString(delta) + c.streamArgumentBytes += len(delta) + state.lastDelta = cloneJSONObject(payload) + return nil, nil + } + } + if found && (identity.Kind == responsesToolSearch || identity.Kind == responsesCustomTool || identity.Kind == responsesApplyPatchTool) { + state.arguments.WriteString(stringField(payload, "delta")) + if identity.Kind == responsesCustomTool { + state.lastDelta = cloneJSONObject(payload) + } + return nil, nil + } + } + if kind == "response.function_call_arguments.done" { + identity, state, found := c.streamIdentity(payload) + if found && identity.Kind == responsesFunctionTool && state.schema != nil { + arguments := stringField(payload, "arguments") + if arguments == "" { + arguments = state.arguments.String() + } + normalized, _ := normalizeFunctionArguments(arguments, state.schema) + if state.passthrough { + done := cloneJSONObject(payload) + done["arguments"] = normalized + return []responsesStreamOutput{{Event: "response.function_call_arguments.done", Payload: done}}, nil + } + outputs := make([]responsesStreamOutput, 0, 2) + if state.lastDelta != nil { + delta := cloneJSONObject(state.lastDelta) + delta["delta"] = normalized + outputs = append(outputs, responsesStreamOutput{Event: "response.function_call_arguments.delta", Payload: delta}) + } + done := cloneJSONObject(payload) + done["arguments"] = normalized + outputs = append(outputs, responsesStreamOutput{Event: "response.function_call_arguments.done", Payload: done}) + c.releaseBufferedFunctionArguments(state) + return outputs, nil + } + if found && (identity.Kind == responsesToolSearch || identity.Kind == responsesApplyPatchTool) { + // Tool Search 的 arguments 是结构化对象;等 output_item.done 带齐参数后再对下游可见。 + return nil, nil + } + if found && identity.Kind == responsesCustomTool { + arguments := stringField(payload, "arguments") + if arguments == "" { + arguments = state.arguments.String() + } + input := decodeCustomToolInput(arguments) + outputs := make([]responsesStreamOutput, 0, 2) + if state.lastDelta != nil { + delta := customToolStreamPayload(state.lastDelta, "response.custom_tool_call_input.delta", "delta", input) + outputs = append(outputs, responsesStreamOutput{Event: "response.custom_tool_call_input.delta", Payload: delta}) + } + done := customToolStreamPayload(payload, "response.custom_tool_call_input.done", "input", input) + outputs = append(outputs, responsesStreamOutput{Event: "response.custom_tool_call_input.done", Payload: done}) + return outputs, nil + } + } + if kind == "response.output_item.done" { + if item, ok := payload["item"].(map[string]any); ok { + identity, exists := c.aliases[stringField(item, "name")] + if exists && identity.Kind == responsesApplyPatchTool { + return c.rewriteApplyPatchDoneEvent(payload, item) + } + } + } + if err := c.rewriteResponseValue(payload); err != nil { + return nil, err + } + if response, ok := payload["response"].(map[string]any); ok { + c.restoreVisibleTools(response) + } + return []responsesStreamOutput{{Payload: payload}}, nil +} + +func (c *responsesToolCompatibility) releaseBufferedFunctionArguments(state *responsesStreamCall) { + if c == nil || state == nil { + return + } + c.streamArgumentBytes -= state.arguments.Len() + if c.streamArgumentBytes < 0 { + c.streamArgumentBytes = 0 + } + state.arguments.Reset() + state.lastDelta = nil +} + +func (c *responsesToolCompatibility) resequenceStreamPayload(payload map[string]any) { + if c == nil || payload == nil { + return + } + rawSequence, exists := payload["sequence_number"] + if !exists { + return + } + if !c.streamSequenceSet { + sequence, ok := exactJSONInt64(rawSequence) + if !ok { + return + } + c.streamSequenceNext = sequence + c.streamSequenceSet = true + } + payload["sequence_number"] = c.streamSequenceNext + c.streamSequenceNext++ +} + +func exactJSONInt64(value any) (int64, bool) { + number, ok := value.(float64) + if !ok || number < 0 || number > float64(maxExactJSONInteger) || number != float64(int64(number)) { + return 0, false + } + return int64(number), true +} + +func (c *responsesToolCompatibility) rememberStreamCall(item map[string]any) *responsesStreamCall { + if stringField(item, "type") != "function_call" { + return nil + } + identity, exists := c.aliases[stringField(item, "name")] + if !exists { + return nil + } + state := &responsesStreamCall{identity: identity, schema: c.functionSchemas[stringField(item, "name")]} + for _, key := range []string{stringField(item, "id"), stringField(item, "call_id")} { + if key != "" { + c.streamCalls[key] = state + } + } + return state +} + +func (c *responsesToolCompatibility) streamIdentity(payload map[string]any) (responsesToolIdentity, *responsesStreamCall, bool) { + for _, key := range []string{stringField(payload, "item_id"), stringField(payload, "call_id")} { + if state, exists := c.streamCalls[key]; exists { + return state.identity, state, true + } + } + identity, exists := c.aliases[stringField(payload, "name")] + if !exists { + return responsesToolIdentity{}, nil, false + } + state := &responsesStreamCall{identity: identity, schema: c.functionSchemas[stringField(payload, "name")]} + for _, key := range []string{stringField(payload, "item_id"), stringField(payload, "call_id")} { + if key != "" { + c.streamCalls[key] = state + } + } + return identity, state, true +} + +func (c *responsesToolCompatibility) rewriteResponseValue(value any) error { + switch typed := value.(type) { + case []any: + for _, item := range typed { + if err := c.rewriteResponseValue(item); err != nil { + return err + } + } + case map[string]any: + for _, item := range typed { + if err := c.rewriteResponseValue(item); err != nil { + return err + } + } + switch stringField(typed, "type") { + case "function_call": + if err := c.rewriteFunctionCall(typed); err != nil { + return err + } + case "shell_call": + if c.legacyLocalShell { + rewriteLegacyLocalShellCall(typed) + } + } + } + return nil +} + +func (c *responsesToolCompatibility) rewriteFunctionCall(call map[string]any) error { + alias := stringField(call, "name") + identity, exists := c.aliases[alias] + if !exists { + return nil + } + switch identity.Kind { + case responsesFunctionTool: + if schema := c.functionSchemas[alias]; schema != nil { + if arguments, ok := call["arguments"].(string); ok { + normalized, changed := normalizeFunctionArguments(arguments, schema) + if changed { + call["arguments"] = normalized + } + } + } + call["name"] = identity.Name + if identity.Namespace != "" { + call["namespace"] = identity.Namespace + } else { + delete(call, "namespace") + } + case responsesCustomTool: + call["type"] = "custom_tool_call" + call["name"] = identity.Name + if identity.Namespace != "" { + call["namespace"] = identity.Namespace + } else { + delete(call, "namespace") + } + call["input"] = decodeCustomToolInput(call["arguments"]) + delete(call, "arguments") + case responsesToolSearch: + call["type"] = "tool_search_call" + call["execution"] = "client" + call["arguments"] = decodeToolSearchArguments(call["arguments"]) + delete(call, "name") + delete(call, "namespace") + case responsesApplyPatchTool: + operation, err := decodeApplyPatchArguments(call["arguments"], "response.output[].arguments") + if err != nil { + return fmt.Errorf("恢复 apply_patch_call: %w", err) + } + call["type"] = "apply_patch_call" + call["operation"] = operation + delete(call, "name") + delete(call, "namespace") + delete(call, "arguments") + } + return nil +} + +func rewriteLegacyLocalShellCall(call map[string]any) { + call["type"] = "local_shell_call" + call["action"] = rewriteLegacyShellAction(call["action"]) + delete(call, "max_output_length") +} + +func (c *responsesToolCompatibility) rewriteApplyPatchDoneEvent(payload, item map[string]any) ([]responsesStreamOutput, error) { + done := cloneJSONObject(payload) + if err := c.rewriteResponseValue(done); err != nil { + return nil, err + } + doneItem, _ := done["item"].(map[string]any) + var state *responsesStreamCall + for _, key := range []string{stringField(item, "id"), stringField(item, "call_id")} { + if candidate, exists := c.streamCalls[key]; exists { + state = candidate + break + } + } + added := map[string]any{"type": "response.output_item.added"} + if state != nil && state.addedPayload != nil { + added = cloneJSONObject(state.addedPayload) + added["type"] = "response.output_item.added" + } + for _, key := range []string{"output_index", "sequence_number"} { + if value, exists := done[key]; exists && added[key] == nil { + added[key] = cloneJSONValue(value) + } + } + addedItem := cloneJSONObject(doneItem) + addedItem["status"] = "in_progress" + added["item"] = addedItem + return []responsesStreamOutput{ + {Event: "response.output_item.added", Payload: added}, + {Event: "response.output_item.done", Payload: done}, + }, nil +} + +func customToolStreamPayload(source map[string]any, kind, valueKey, value string) map[string]any { + result := map[string]any{"type": kind, valueKey: value} + for _, key := range []string{"item_id", "output_index", "sequence_number"} { + if item, exists := source[key]; exists { + result[key] = item + } + } + return result +} + +func isCompatibleResponsesEvent(kind string) bool { + return kind == "" || kind == "error" || strings.HasPrefix(kind, "response.") +} + +func decodeToolSearchArguments(value any) any { + text, ok := value.(string) + if !ok { + return value + } + if strings.TrimSpace(text) == "" { + return map[string]any{} + } + var decoded any + if json.Unmarshal([]byte(text), &decoded) == nil { + return decoded + } + return map[string]any{"input": text} +} + +func (c *responsesToolCompatibility) restoreVisibleTools(response map[string]any) { + if _, exists := response["tools"]; !exists { + return + } + response["tools"] = cloneJSONArray(c.visibleTools) +} + +type compatibleSSEEvent struct { + Event string + ID string + Retry string + Comments []string + Other []string + data []string +} + +func (e compatibleSSEEvent) Data() []byte { + return []byte(strings.Join(e.data, "\n")) +} + +func (e compatibleSSEEvent) HasData() bool { return len(e.data) > 0 } + +func (e *compatibleSSEEvent) SetData(data []byte) { + e.data = strings.Split(string(data), "\n") +} + +func (e compatibleSSEEvent) writeTo(writer io.Writer) error { + for _, comment := range e.Comments { + if _, err := fmt.Fprintln(writer, comment); err != nil { + return err + } + } + if e.Event != "" { + if _, err := fmt.Fprintf(writer, "event: %s\n", e.Event); err != nil { + return err + } + } + if e.ID != "" { + if _, err := fmt.Fprintf(writer, "id: %s\n", e.ID); err != nil { + return err + } + } + if e.Retry != "" { + if _, err := fmt.Fprintf(writer, "retry: %s\n", e.Retry); err != nil { + return err + } + } + for _, field := range e.Other { + if _, err := fmt.Fprintln(writer, field); err != nil { + return err + } + } + for _, line := range e.data { + if _, err := fmt.Fprintf(writer, "data: %s\n", line); err != nil { + return err + } + } + _, err := fmt.Fprintln(writer) + return err +} + +func consumeCompatibleSSE(source io.Reader, handle func(compatibleSSEEvent) error) error { + scanner := bufio.NewScanner(source) + scanner.Buffer(make([]byte, 64<<10), maxCompatibleSSEEventBytes) + event := compatibleSSEEvent{} + eventBytes := 0 + flush := func() error { + if len(event.data) == 0 && len(event.Comments) == 0 && len(event.Other) == 0 && event.Event == "" && event.ID == "" && event.Retry == "" { + return nil + } + current := event + event = compatibleSSEEvent{} + eventBytes = 0 + return handle(current) + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + if err := flush(); err != nil { + return err + } + continue + } + eventBytes += len(line) + if eventBytes > maxCompatibleSSEEventBytes { + return fmt.Errorf("Grok Build Responses SSE 单事件超过 %d MiB", maxCompatibleSSEEventBytes>>20) + } + field, value, found := strings.Cut(line, ":") + if found && strings.HasPrefix(value, " ") { + value = value[1:] + } + switch { + case strings.HasPrefix(line, ":"): + event.Comments = append(event.Comments, line) + case !found: + event.Other = append(event.Other, line) + case field == "event": + event.Event = value + case field == "data": + event.data = append(event.data, value) + case field == "id": + event.ID = value + case field == "retry": + event.Retry = value + default: + event.Other = append(event.Other, line) + } + } + if err := scanner.Err(); err != nil { + return err + } + return flush() +} diff --git a/backend/internal/infra/provider/cli/responses_tool_choice.go b/backend/internal/infra/provider/cli/responses_tool_choice.go new file mode 100644 index 0000000000000000000000000000000000000000..fbbb249271245f921fff0ad0853a9d242f98f442 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_tool_choice.go @@ -0,0 +1,148 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" +) + +func (c *responsesToolCompatibility) normalizeToolChoice(payload map[string]json.RawMessage, normalizedTools []any) error { + raw := payload["tool_choice"] + if isEmptyJSON(raw) { + return nil + } + if len(normalizedTools) == 0 { + delete(payload, "tool_choice") + c.changed = true + c.addWarning("tool_choice_without_tools_ignored") + if c.serverSearchEager { + c.addWarning("server_tool_search_choice_downgraded") + } + return nil + } + var choice any + if err := json.Unmarshal(raw, &choice); err != nil { + return &responsesRequestError{Message: "tool_choice 格式无效", Param: "tool_choice", Code: "invalid_parameter"} + } + object, ok := choice.(map[string]any) + if !ok { + if value, isString := choice.(string); isString && (value == "auto" || value == "required") && c.webSearchDisabled && len(normalizedTools) == 0 { + payload["tool_choice"] = mustJSON("none") + c.changed = true + c.addWarning("web_search_tool_choice_disabled") + } + return nil + } + kind := stringField(object, "type") + if c.webSearchDisabled && normalizeHostedToolChoiceKind(kind) == "web_search" && !hasToolType(normalizedTools, "web_search") { + payload["tool_choice"] = mustJSON("none") + c.changed = true + c.addWarning("web_search_tool_choice_disabled") + return nil + } + if kind == "tool_search" { + if c.clientSearchTool == nil { + if c.serverSearchEager { + payload["tool_choice"] = mustJSON("auto") + c.changed = true + c.addWarning("server_tool_search_choice_downgraded") + return nil + } + return &responsesRequestError{Message: "tool_choice 引用了未声明的 tool_search", Param: "tool_choice", Code: "invalid_parameter"} + } + object = map[string]any{ + "type": "function", + "name": c.alias(responsesToolIdentity{Kind: responsesToolSearch, Name: "tool_search"}), + } + c.changed = true + payload["tool_choice"] = mustJSON(object) + return nil + } + if kind == "custom" { + name := strings.TrimSpace(stringField(object, "name")) + namespace := strings.TrimSpace(stringField(object, "namespace")) + if name == "" { + return &responsesRequestError{Message: "tool_choice.name 不能为空", Param: "tool_choice.name", Code: "invalid_parameter"} + } + identity := responsesToolIdentity{Kind: responsesCustomTool, Namespace: namespace, Name: name} + alias, exists := c.identityAliases[identity.key()] + if !exists { + return &responsesRequestError{Message: "tool_choice 引用了未声明的 custom 工具", Param: "tool_choice.name", Code: "invalid_parameter"} + } + object["type"] = "function" + object["name"] = alias + delete(object, "namespace") + c.changed = true + payload["tool_choice"] = mustJSON(object) + return nil + } + if kind == "apply_patch" { + identity := responsesToolIdentity{Kind: responsesApplyPatchTool, Name: "apply_patch"} + alias, exists := c.identityAliases[identity.key()] + if !exists { + return &responsesRequestError{Message: "tool_choice 引用了未声明的 apply_patch 工具", Param: "tool_choice", Code: "invalid_parameter"} + } + payload["tool_choice"] = mustJSON(map[string]any{"type": "function", "name": alias}) + c.changed = true + return nil + } + if normalizedKind := normalizeHostedToolChoiceKind(kind); normalizedKind != "" { + matching := toolsOfType(normalizedTools, normalizedKind) + if len(matching) == 0 { + return &responsesRequestError{Message: "tool_choice 引用了未声明的 hosted tool", Param: "tool_choice", Code: "invalid_parameter"} + } + if len(matching) != len(normalizedTools) { + // 上游只支持 required,收窄本轮可见工具即可保持“指定该类工具”的语义。 + payload["tools"] = mustJSON(matching) + c.addWarning("hosted_tool_choice_tools_narrowed") + } + payload["tool_choice"] = mustJSON("required") + c.changed = true + return nil + } + if kind != "function" { + return &responsesRequestError{Message: fmt.Sprintf("Grok Build 不支持 tool_choice.type=%q", kind), Param: "tool_choice.type", Code: "unsupported_parameter"} + } + name := strings.TrimSpace(stringField(object, "name")) + namespace := strings.TrimSpace(stringField(object, "namespace")) + if function, nested := object["function"].(map[string]any); nested { + name = strings.TrimSpace(stringField(function, "name")) + namespace = strings.TrimSpace(stringField(function, "namespace")) + if name != "" && namespace != "" { + identity := responsesToolIdentity{Kind: responsesFunctionTool, Namespace: namespace, Name: name} + alias, exists := c.identityAliases[identity.key()] + if !exists { + return &responsesRequestError{Message: "tool_choice 引用了未声明的 namespace 函数", Param: "tool_choice.function.name", Code: "invalid_parameter"} + } + function["name"] = alias + delete(function, "namespace") + c.changed = true + payload["tool_choice"] = mustJSON(object) + } + return nil + } + if name == "" || namespace == "" { + return nil + } + identity := responsesToolIdentity{Kind: responsesFunctionTool, Namespace: namespace, Name: name} + alias, exists := c.identityAliases[identity.key()] + if !exists { + return &responsesRequestError{Message: "tool_choice 引用了未声明的 namespace 函数", Param: "tool_choice.name", Code: "invalid_parameter"} + } + object["name"] = alias + delete(object, "namespace") + c.changed = true + payload["tool_choice"] = mustJSON(object) + return nil +} + +func toolsOfType(tools []any, kind string) []any { + matching := make([]any, 0) + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if ok && stringField(tool, "type") == kind { + matching = append(matching, rawTool) + } + } + return matching +} diff --git a/backend/internal/infra/provider/cli/responses_tool_declarations.go b/backend/internal/infra/provider/cli/responses_tool_declarations.go new file mode 100644 index 0000000000000000000000000000000000000000..d6b9ef75ef3e38c0e6a0b1f96d1b577ff5578b32 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_tool_declarations.go @@ -0,0 +1,504 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" +) + +func normalizeResponsesTools(payload map[string]json.RawMessage) (*responsesToolCompatibility, error) { + compatibility := newResponsesToolCompatibility() + tools, hasTools, err := decodeOptionalArray(payload["tools"], "tools") + if err != nil { + return nil, err + } + if hasTools { + compatibility.visibleTools = cloneJSONArray(tools) + } + clientSearch, err := inspectToolSearch(tools) + if err != nil { + return nil, err + } + if err := compatibility.normalizeClientToolSearchParallel(payload, clientSearch); err != nil { + return nil, err + } + + normalizedTools := make([]any, 0, len(tools)) + for index, rawTool := range tools { + converted, convertErr := compatibility.normalizeTool(rawTool, "", clientSearch, false, fmt.Sprintf("tools[%d]", index)) + if convertErr != nil { + return nil, convertErr + } + normalizedTools = append(normalizedTools, converted...) + } + + if rawInput := payload["input"]; !isEmptyJSON(rawInput) { + var input any + if err := json.Unmarshal(rawInput, &input); err != nil { + return nil, &responsesRequestError{Message: "input 必须是字符串或数组", Param: "input", Code: "invalid_parameter"} + } + if items, ok := input.([]any); ok { + rewritten, loadedTools, visibleTools, rewriteErr := compatibility.normalizeInputItems(items) + if rewriteErr != nil { + return nil, rewriteErr + } + normalizedTools = append(normalizedTools, loadedTools...) + compatibility.visibleTools = append(compatibility.visibleTools, visibleTools...) + payload["input"] = mustJSON(rewritten) + } + } + + if compatibility.clientSearchTool != nil { + searchTool, searchErr := compatibility.buildClientSearchFunction() + if searchErr != nil { + return nil, searchErr + } + normalizedTools = append(normalizedTools, searchTool) + } + normalizedTools = dedupeNormalizedTools(normalizedTools) + if len(normalizedTools) > 0 { + payload["tools"] = mustJSON(normalizedTools) + } else if hasTools { + delete(payload, "tools") + if _, exists := payload["parallel_tool_calls"]; exists { + delete(payload, "parallel_tool_calls") + compatibility.addWarning("parallel_tool_calls_without_tools_ignored") + } + compatibility.changed = true + } + if err := compatibility.normalizeToolChoice(payload, normalizedTools); err != nil { + return nil, err + } + if !compatibility.changed && len(compatibility.functionSchemas) == 0 { + return nil, nil + } + return compatibility, nil +} + +// normalizeClientToolSearchParallel 保证搜索函数先独立完成,再由客户端选择并回传工具定义。 +func (c *responsesToolCompatibility) normalizeClientToolSearchParallel(payload map[string]json.RawMessage, clientSearch bool) error { + if !clientSearch { + return nil + } + raw, exists := payload["parallel_tool_calls"] + if !exists || isEmptyJSON(raw) { + payload["parallel_tool_calls"] = mustJSON(false) + c.changed = true + return nil + } + var parallel bool + if err := json.Unmarshal(raw, ¶llel); err != nil { + return &responsesRequestError{ + Message: "parallel_tool_calls 必须是布尔值", + Param: "parallel_tool_calls", Code: "invalid_parameter", + } + } + if parallel { + payload["parallel_tool_calls"] = mustJSON(false) + c.changed = true + c.addWarning("client_tool_search_forced_serial") + } + return nil +} + +func decodeOptionalArray(raw json.RawMessage, param string) ([]any, bool, error) { + if isEmptyJSON(raw) { + return nil, false, nil + } + var values []any + if err := json.Unmarshal(raw, &values); err != nil { + return nil, false, &responsesRequestError{Message: param + " 必须是数组", Param: param, Code: "invalid_parameter"} + } + return values, true, nil +} + +func inspectToolSearch(tools []any) (bool, error) { + clientSearch := false + serverSearch := false + for index, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + return false, &responsesRequestError{Message: fmt.Sprintf("tools[%d] 必须是对象", index), Param: fmt.Sprintf("tools[%d]", index), Code: "invalid_parameter"} + } + if stringField(tool, "type") != "tool_search" { + continue + } + param := fmt.Sprintf("tools[%d]", index) + execution := strings.ToLower(strings.TrimSpace(stringField(tool, "execution"))) + if execution == "" || execution == "server" { + if clientSearch { + return false, &responsesRequestError{Message: "单次请求不能混用 client 与 server tool_search", Param: param + ".execution", Code: "invalid_parameter"} + } + serverSearch = true + continue + } + if execution != "client" { + return false, &responsesRequestError{Message: "tool_search.execution 必须是 client 或 server", Param: param + ".execution", Code: "invalid_parameter"} + } + if clientSearch { + return false, &responsesRequestError{Message: "单次请求只能声明一个客户端 tool_search", Param: param, Code: "invalid_parameter"} + } + if serverSearch { + return false, &responsesRequestError{Message: "单次请求不能混用 client 与 server tool_search", Param: param + ".execution", Code: "invalid_parameter"} + } + clientSearch = true + } + return clientSearch, nil +} + +func (c *responsesToolCompatibility) normalizeTool(raw any, namespace string, clientSearch, force bool, param string) ([]any, error) { + tool, ok := raw.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: param + " 必须是对象", Param: param, Code: "invalid_parameter"} + } + kind := strings.TrimSpace(stringField(tool, "type")) + switch kind { + case "function": + name := strings.TrimSpace(stringField(tool, "name")) + if name == "" { + return nil, &responsesRequestError{Message: param + ".name 不能为空", Param: param + ".name", Code: "invalid_parameter"} + } + deferred, _ := tool["defer_loading"].(bool) + if deferred && !clientSearch && !force { + c.changed = true + c.addWarning("orphan_deferred_tool_loaded") + } + if deferred && clientSearch && !force { + c.changed = true + if namespace == "" { + c.deferredSurfaces = append(c.deferredSurfaces, describeDeferredTool(name, stringField(tool, "description"))) + } + return nil, nil + } + converted := cloneJSONObject(tool) + if parameters, exists := converted["parameters"]; exists { + normalized, changed, normalizeErr := normalizeBuildFunctionParametersRoot(parameters, param+".parameters") + if normalizeErr != nil { + return nil, normalizeErr + } + if changed { + converted["parameters"] = normalized + c.changed = true + c.addWarning("function_parameters_nullable_root_normalized") + } + } + identity := responsesToolIdentity{Kind: responsesFunctionTool, Namespace: namespace, Name: name} + alias := c.alias(identity) + if parameters, exists := tool["parameters"]; exists && schemaContainsInteger(parameters) { + c.functionSchemas[alias] = cloneJSONValue(parameters) + } + converted["name"] = alias + if namespace != "" || alias != name { + c.changed = true + } + if _, exists := converted["defer_loading"]; exists { + delete(converted, "defer_loading") + c.changed = true + } + return []any{converted}, nil + case "namespace": + name := strings.TrimSpace(stringField(tool, "name")) + if name == "" { + return nil, &responsesRequestError{Message: param + ".name 不能为空", Param: param + ".name", Code: "invalid_parameter"} + } + children, ok := tool["tools"].([]any) + if !ok { + return nil, &responsesRequestError{Message: param + ".tools 必须是数组", Param: param + ".tools", Code: "invalid_parameter"} + } + c.changed = true + c.addWarning("namespace_flattened") + if clientSearch && !force && namespaceHasDeferredFunctions(children) { + c.deferredSurfaces = append(c.deferredSurfaces, describeDeferredTool(name, stringField(tool, "description"))) + } + converted := make([]any, 0, len(children)) + for index, rawChild := range children { + child, childOK := rawChild.(map[string]any) + childParam := fmt.Sprintf("%s.tools[%d]", param, index) + if !childOK { + return nil, &responsesRequestError{Message: childParam + " 必须是对象", Param: childParam, Code: "invalid_parameter"} + } + if stringField(child, "type") != "function" { + return nil, &responsesRequestError{Message: "namespace.tools 只能包含 function 工具", Param: childParam + ".type", Code: "invalid_parameter"} + } + items, err := c.normalizeTool(child, name, clientSearch, force, childParam) + if err != nil { + return nil, err + } + converted = append(converted, items...) + } + return converted, nil + case "tool_search": + if force { + c.changed = true + c.addWarning("nested_tool_search_ignored") + return nil, nil + } + execution := strings.ToLower(strings.TrimSpace(stringField(tool, "execution"))) + if execution == "" || execution == "server" { + // Build 上游没有服务端 Tool Search。将已声明的延迟工具提前展开, + // 比让 Codex 因一个可选优化能力整次失败更符合兼容层语义。 + c.serverSearchEager = true + c.changed = true + c.addWarning("server_tool_search_eager_loaded") + return nil, nil + } + c.changed = true + c.addWarning("client_tool_search_emulated") + c.clientSearchTool = cloneJSONObject(tool) + c.clientSearchParam = param + return nil, nil + case "custom": + return c.normalizeCustomTool(tool, namespace, param) + case "web_search", "web_search_preview", "web_search_preview_2025_03_11", "web_search_2025_08_26": + return c.normalizeWebSearchTool(tool, kind, param) + case "mcp": + return c.normalizeMCPTool(tool, clientSearch, force, param) + case "shell": + return c.normalizeShellTool(tool, param) + case "local_shell": + return c.normalizeLegacyLocalShellTool(tool, param) + case "apply_patch": + return c.normalizeApplyPatchTool(tool, param) + case "x_search": + return c.normalizeXSearchTool(tool, param) + case "image_generation", "collections_search", "file_search", "code_execution", "code_interpreter": + return c.normalizeNativeTool(tool, param) + case "computer_use_preview": + return nil, unsupportedBuildToolError(kind, param) + default: + if kind == "" { + return nil, &responsesRequestError{Message: param + ".type 不能为空", Param: param + ".type", Code: "invalid_parameter"} + } + return nil, unsupportedBuildToolError(kind, param) + } +} + +// normalizeBuildFunctionParametersRoot removes root-level nullability from function schemas. +// Build requires the parameter root to be an object, while Codex can emit `object | null` +// for tools with an optional argument object. Nested nullable fields remain untouched. +func normalizeBuildFunctionParametersRoot(value any, param string) (any, bool, error) { + schema, ok := value.(map[string]any) + if !ok { + return value, false, nil + } + normalized := cloneJSONObject(schema) + changed := false + + if rawTypes, ok := normalized["type"].([]any); ok { + filtered, removedNull := withoutNullSchemaTypes(rawTypes) + if removedNull { + changed = true + switch len(filtered) { + case 0: + return nil, false, invalidBuildFunctionParametersRoot(param) + case 1: + if filtered[0] != "object" { + return nil, false, invalidBuildFunctionParametersRoot(param) + } + normalized["type"] = "object" + default: + return nil, false, invalidBuildFunctionParametersRoot(param) + } + } + } + + for _, keyword := range []string{"anyOf", "oneOf"} { + rawBranches, exists := normalized[keyword] + if !exists { + continue + } + branches, ok := rawBranches.([]any) + if !ok { + continue + } + filtered := make([]any, 0, len(branches)) + removedNull := false + for _, rawBranch := range branches { + if isNullOnlySchema(rawBranch) { + removedNull = true + continue + } + filtered = append(filtered, rawBranch) + } + if !removedNull { + continue + } + changed = true + if len(filtered) == 0 { + return nil, false, invalidBuildFunctionParametersRoot(param) + } + if len(filtered) == 1 { + branch, ok := filtered[0].(map[string]any) + if !ok || !isObjectRootSchema(branch, normalized, nil) { + return nil, false, invalidBuildFunctionParametersRoot(param) + } + if len(normalized) == 1 { + normalized = cloneJSONObject(branch) + normalized["type"] = "object" + continue + } + normalized[keyword] = cloneJSONArray(filtered) + normalized["type"] = "object" + continue + } + for _, rawBranch := range filtered { + branch, ok := rawBranch.(map[string]any) + if !ok || !isObjectRootSchema(branch, normalized, nil) { + return nil, false, invalidBuildFunctionParametersRoot(param) + } + } + normalized[keyword] = cloneJSONArray(filtered) + normalized["type"] = "object" + } + + return normalized, changed, nil +} + +func withoutNullSchemaTypes(types []any) ([]any, bool) { + filtered := make([]any, 0, len(types)) + removed := false + for _, value := range types { + if value == "null" { + removed = true + continue + } + filtered = append(filtered, value) + } + return filtered, removed +} + +func isNullOnlySchema(value any) bool { + schema, ok := value.(map[string]any) + if !ok { + return false + } + if schema["type"] == "null" { + return true + } + types, ok := schema["type"].([]any) + if !ok || len(types) == 0 { + return false + } + for _, value := range types { + if value != "null" { + return false + } + } + return true +} + +func isObjectRootSchema(schema, root map[string]any, visited map[string]struct{}) bool { + rawType, hasType := schema["type"] + if rawType == "object" { + return true + } + if types, ok := rawType.([]any); ok && len(types) > 0 { + for _, value := range types { + if value != "object" { + return false + } + } + return true + } + if hasType { + return false + } + if _, hasProperties := schema["properties"]; hasProperties { + return true + } + ref, ok := schema["$ref"].(string) + if !ok { + return false + } + if visited == nil { + visited = make(map[string]struct{}) + } + if _, seen := visited[ref]; seen { + return false + } + resolved, ok := resolveLocalSchemaRef(root, ref) + if !ok { + return false + } + visited[ref] = struct{}{} + return isObjectRootSchema(resolved, root, visited) +} + +func resolveLocalSchemaRef(root map[string]any, ref string) (map[string]any, bool) { + if ref == "#" { + return root, true + } + if !strings.HasPrefix(ref, "#/") { + return nil, false + } + var current any = root + for _, encoded := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + segment := strings.ReplaceAll(strings.ReplaceAll(encoded, "~1", "/"), "~0", "~") + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + resolved, ok := current.(map[string]any) + return resolved, ok +} + +func invalidBuildFunctionParametersRoot(param string) error { + return &responsesRequestError{ + Message: param + " 顶层必须是非 nullable 的 object schema", + Param: param, + Code: "invalid_parameter", + } +} + +func namespaceHasDeferredFunctions(children []any) bool { + for _, rawChild := range children { + child, ok := rawChild.(map[string]any) + if !ok || stringField(child, "type") != "function" { + continue + } + if deferred, _ := child["defer_loading"].(bool); deferred { + return true + } + } + return false +} + +func describeDeferredTool(name, description string) string { + description = strings.TrimSpace(description) + if description == "" { + return name + } + if len(description) > 240 { + description = description[:240] + } + return name + ": " + description +} + +func (c *responsesToolCompatibility) buildClientSearchFunction() (map[string]any, error) { + identity := responsesToolIdentity{Kind: responsesToolSearch, Name: "tool_search"} + description := strings.TrimSpace(stringField(c.clientSearchTool, "description")) + if description == "" { + description = "Search for tools needed to continue the task." + } + if len(c.deferredSurfaces) > 0 { + description += "\nDeferred tool surfaces available to search:\n- " + strings.Join(c.deferredSurfaces, "\n- ") + } + if len(description) > maxToolSearchDescriptionBytes { + description = description[:maxToolSearchDescriptionBytes] + } + parameters, exists := c.clientSearchTool["parameters"] + if !exists { + parameters = map[string]any{"type": "object", "properties": map[string]any{}, "additionalProperties": true} + } else if _, ok := parameters.(map[string]any); !ok { + return nil, &responsesRequestError{Message: "tool_search.parameters 必须是对象", Param: c.clientSearchParam + ".parameters", Code: "invalid_parameter"} + } + return map[string]any{ + "type": "function", "name": c.alias(identity), "description": description, + "parameters": cloneJSONValue(parameters), + }, nil +} diff --git a/backend/internal/infra/provider/cli/responses_tool_state.go b/backend/internal/infra/provider/cli/responses_tool_state.go new file mode 100644 index 0000000000000000000000000000000000000000..ad0a94dc7e5294acfe89ed6f5d200df3ecf25eb0 --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_tool_state.go @@ -0,0 +1,155 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +const ( + maxBuildToolAliasLength = 128 + maxToolSearchDescriptionBytes = 16 << 10 +) + +type responsesToolKind uint8 + +const ( + responsesFunctionTool responsesToolKind = iota + responsesCustomTool + responsesToolSearch + responsesApplyPatchTool +) + +type responsesToolIdentity struct { + Kind responsesToolKind + Namespace string + Name string +} + +func (i responsesToolIdentity) key() string { + return fmt.Sprintf("%d\x00%s\x00%s", i.Kind, i.Namespace, i.Name) +} + +// responsesToolCompatibility 保存一次请求内的工具别名和响应恢复状态;实例不得跨请求复用。 +type responsesToolCompatibility struct { + aliases map[string]responsesToolIdentity + identityAliases map[string]string + functionSchemas map[string]any + visibleTools []any + deferredSurfaces []string + clientSearchTool map[string]any + clientSearchParam string + serverSearchEager bool + streamCalls map[string]*responsesStreamCall + streamArgumentBytes int + streamSequenceNext int64 + streamSequenceSet bool + legacyLocalShell bool + nativeShell bool + webSearchDisabled bool + compactionRequested bool + warnings []string + warningSet map[string]struct{} + changed bool +} + +// responsesRequestError 表示可直接映射为 OpenAI 错误结构的 Provider 请求错误。 +type responsesRequestError struct { + Message string + Param string + Code string +} + +func (e *responsesRequestError) Error() string { return e.Message } + +func newResponsesToolCompatibility() *responsesToolCompatibility { + return &responsesToolCompatibility{ + aliases: make(map[string]responsesToolIdentity), + identityAliases: make(map[string]string), + functionSchemas: make(map[string]any), + streamCalls: make(map[string]*responsesStreamCall), + warningSet: make(map[string]struct{}), + } +} + +func (c *responsesToolCompatibility) alias(identity responsesToolIdentity) string { + key := identity.key() + if alias, exists := c.identityAliases[key]; exists { + return alias + } + base := identity.Name + if identity.Kind == responsesToolSearch { + base = "grok2api_tool_search" + } else if identity.Kind == responsesApplyPatchTool { + base = "grok2api_apply_patch" + } else if identity.Namespace != "" { + separator := "__" + if strings.HasSuffix(identity.Namespace, separator) { + separator = "" + } + base = identity.Namespace + separator + identity.Name + } + alias := truncateToolAlias(base, key) + if existing, collision := c.aliases[alias]; collision && existing.key() != key { + alias = hashedToolAlias(base, key) + } + c.aliases[alias] = identity + c.identityAliases[key] = alias + return alias +} + +func truncateToolAlias(base, key string) string { + if len(base) <= maxBuildToolAliasLength { + return base + } + return hashedToolAlias(base, key) +} + +func hashedToolAlias(base, key string) string { + suffix := "__" + shortToolHash(key) + limit := maxBuildToolAliasLength - len(suffix) + if len(base) > limit { + base = base[:limit] + } + return base + suffix +} + +func shortToolHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:9] +} + +func stringField(value map[string]any, key string) string { + text, _ := value[key].(string) + return text +} + +func cloneJSONArray(values []any) []any { + cloned := make([]any, len(values)) + for index, value := range values { + cloned[index] = cloneJSONValue(value) + } + return cloned +} + +func cloneJSONObject(value map[string]any) map[string]any { + cloned := make(map[string]any, len(value)) + for key, item := range value { + cloned[key] = cloneJSONValue(item) + } + return cloned +} + +func cloneJSONValue(value any) any { + data, err := json.Marshal(value) + if err != nil { + return value + } + var cloned any + if json.Unmarshal(data, &cloned) != nil { + return value + } + return cloned +} diff --git a/backend/internal/infra/provider/cli/responses_tool_types.go b/backend/internal/infra/provider/cli/responses_tool_types.go new file mode 100644 index 0000000000000000000000000000000000000000..51ffc38b20ebae8378e704285062c11c0c1ccf8b --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_tool_types.go @@ -0,0 +1,268 @@ +package cli + +import ( + "fmt" + "strings" + "time" +) + +var nativeHostedToolChoiceTypes = map[string]string{ + "web_search": "web_search", + "web_search_preview": "web_search", + "web_search_preview_2025_03_11": "web_search", + "web_search_2025_08_26": "web_search", + "x_search": "x_search", + "image_generation": "image_generation", + "collections_search": "collections_search", + "file_search": "file_search", + "code_execution": "code_execution", + "code_interpreter": "code_interpreter", + "mcp": "mcp", + "shell": "shell", + "local_shell": "shell", +} + +// webSearchCompatibilityFields lists newer Codex/OpenAI controls that Build 0.2.110 rejects. +// The compatibility layer can only reduce them to Build's native minimal search tool. +var webSearchCompatibilityFields = map[string]struct{}{ + "external_web_access": {}, + "indexed_web_access": {}, + "search_content_types": {}, + "search_context_size": {}, + "user_location": {}, + "max_search_results": {}, + "safe_search": {}, +} + +// normalizeNativeTool preserves tools supported by Build 0.2.110 and removes Tool Search-only fields. +func (c *responsesToolCompatibility) normalizeNativeTool(tool map[string]any, _ string) ([]any, error) { + converted := cloneJSONObject(tool) + if _, exists := converted["defer_loading"]; exists { + delete(converted, "defer_loading") + c.changed = true + c.addWarning("orphan_deferred_tool_loaded") + } + return []any{converted}, nil +} + +func (c *responsesToolCompatibility) normalizeXSearchTool(tool map[string]any, param string) ([]any, error) { + converted := cloneJSONObject(tool) + var fromDate, toDate time.Time + var hasFromDate, hasToDate bool + for _, field := range []string{"from_date", "to_date"} { + value, exists := converted[field] + if !exists { + continue + } + if value == nil { + delete(converted, field) + c.changed = true + continue + } + text, ok := value.(string) + date, err := time.Parse("2006-01-02", text) + if !ok || err != nil || len(text) != len("2006-01-02") || date.Year() < 1 || date.Format("2006-01-02") != text { + return nil, &responsesRequestError{ + Message: field + " 必须使用 YYYY-MM-DD 格式", + Param: param + "." + field, + Code: "invalid_parameter", + } + } + if field == "from_date" { + fromDate, hasFromDate = date, true + } else { + toDate, hasToDate = date, true + } + } + if hasFromDate && hasToDate && fromDate.After(toDate) { + return nil, &responsesRequestError{ + Message: "from_date 不得晚于 to_date", + Param: param + ".from_date", + Code: "invalid_parameter", + } + } + return c.normalizeNativeTool(converted, param) +} + +// normalizeWebSearchTool preserves Build 0.2.110's allowed_domains constraint and safely +// reduces newer controls that cannot be represented with equivalent semantics. +func (c *responsesToolCompatibility) normalizeWebSearchTool(tool map[string]any, kind, param string) ([]any, error) { + if external, exists := tool["external_web_access"]; exists { + enabled, ok := external.(bool) + if !ok { + return nil, &responsesRequestError{Message: "external_web_access 必须是布尔值", Param: param + ".external_web_access", Code: "invalid_parameter"} + } + if !enabled { + // Build cannot express indexed-only search without external access. Sending a minimal + // web_search would broaden the client's authorization, so remove the tool instead. + c.webSearchDisabled = true + c.changed = true + c.addWarning("web_search_disabled_no_external_access") + return nil, nil + } + } + filters, err := c.normalizeWebSearchFilters(tool, param) + if err != nil { + return nil, err + } + if contentTypes, exists := tool["search_content_types"]; exists { + values, ok := contentTypes.([]any) + if !ok { + return nil, &responsesRequestError{Message: "search_content_types 必须是数组", Param: param + ".search_content_types", Code: "invalid_parameter"} + } + for _, value := range values { + if value != "text" { + c.changed = true + c.addWarning("web_search_non_text_content_ignored") + } + } + } + for key := range tool { + if key == "type" { + continue + } + if key == "filters" { + continue + } + if key == "allowed_domains" { + c.changed = true + c.addWarning("web_search_allowed_domains_normalized") + continue + } + if _, compatible := webSearchCompatibilityFields[key]; !compatible { + c.changed = true + c.addWarning("web_search_unknown_controls_ignored") + continue + } + c.changed = true + c.addWarning("web_search_controls_downgraded") + } + if kind == "web_search" && len(tool) == 1 { + return []any{cloneJSONValue(tool)}, nil + } + converted := map[string]any{"type": "web_search"} + if filters != nil { + converted["filters"] = filters + } + if kind != "web_search" || len(tool) != len(converted) { + c.changed = true + } + return []any{converted}, nil +} + +func (c *responsesToolCompatibility) normalizeWebSearchFilters(tool map[string]any, param string) (map[string]any, error) { + var nestedDomains []any + if rawFilters, exists := tool["filters"]; exists && rawFilters != nil { + filters, ok := rawFilters.(map[string]any) + if !ok { + return nil, &responsesRequestError{Message: "web_search filters 必须是对象", Param: param + ".filters", Code: "invalid_parameter"} + } + for key := range filters { + if key != "allowed_domains" { + c.changed = true + c.addWarning("web_search_filters_downgraded") + } + } + if rawDomains, exists := filters["allowed_domains"]; exists { + values, err := normalizeAllowedDomains(rawDomains, param+".filters.allowed_domains") + if err != nil { + return nil, err + } + nestedDomains = values + } + } + + var topLevelDomains []any + if rawDomains, exists := tool["allowed_domains"]; exists { + values, err := normalizeAllowedDomains(rawDomains, param+".allowed_domains") + if err != nil { + return nil, err + } + topLevelDomains = values + } + if len(nestedDomains) > 0 && len(topLevelDomains) > 0 && !sameStringValues(nestedDomains, topLevelDomains) { + return nil, &responsesRequestError{Message: "web_search allowed_domains 声明冲突", Param: param + ".allowed_domains", Code: "invalid_parameter"} + } + domains := nestedDomains + if len(domains) == 0 { + domains = topLevelDomains + } + if len(domains) == 0 { + return nil, nil + } + return map[string]any{"allowed_domains": cloneJSONValue(domains)}, nil +} + +func normalizeAllowedDomains(value any, param string) ([]any, error) { + values, ok := value.([]any) + if !ok { + return nil, &responsesRequestError{Message: "allowed_domains 必须是字符串数组", Param: param, Code: "invalid_parameter"} + } + for index, value := range values { + domain, ok := value.(string) + if !ok || strings.TrimSpace(domain) == "" { + return nil, &responsesRequestError{Message: "allowed_domains 必须包含有效域名", Param: fmt.Sprintf("%s[%d]", param, index), Code: "invalid_parameter"} + } + } + return values, nil +} + +func sameStringValues(left, right []any) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +// normalizeMCPTool 支持客户端 Tool Search 延迟加载整个 MCP server 定义。 +func (c *responsesToolCompatibility) normalizeMCPTool(tool map[string]any, clientSearch, force bool, param string) ([]any, error) { + deferred, _ := tool["defer_loading"].(bool) + if deferred && !clientSearch && !force { + c.changed = true + c.addWarning("orphan_deferred_tool_loaded") + } + if deferred && clientSearch && !force { + label := strings.TrimSpace(stringField(tool, "server_label")) + if label == "" { + label = strings.TrimSpace(stringField(tool, "name")) + } + if label == "" { + return nil, &responsesRequestError{Message: "延迟 MCP 工具缺少 server_label", Param: param + ".server_label", Code: "invalid_parameter"} + } + c.deferredSurfaces = append(c.deferredSurfaces, describeDeferredTool(label, stringField(tool, "description"))) + c.changed = true + return nil, nil + } + converted := cloneJSONObject(tool) + if _, exists := converted["defer_loading"]; exists { + delete(converted, "defer_loading") + c.changed = true + } + return []any{converted}, nil +} + +func unsupportedBuildToolError(kind, param string) error { + return &responsesRequestError{ + Message: fmt.Sprintf("Grok Build 0.2.110 不支持 tools.type=%q", kind), + Param: param + ".type", Code: "unsupported_parameter", + } +} + +func normalizeHostedToolChoiceKind(kind string) string { + return nativeHostedToolChoiceTypes[kind] +} + +func hasToolType(tools []any, kind string) bool { + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if ok && stringField(tool, "type") == kind { + return true + } + } + return false +} diff --git a/backend/internal/infra/provider/cli/responses_tools_test.go b/backend/internal/infra/provider/cli/responses_tools_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4bb7a27c2745414403de4a65f9d9412e884699ab --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_tools_test.go @@ -0,0 +1,471 @@ +package cli + +import ( + "encoding/json" + "io" + "strings" + "testing" +) + +func TestNormalizeResponsesRequestFlattensNamespaceAndRestoresResponse(t *testing.T) { + body := []byte(`{ + "model":"public", + "input":"List calendar events", + "tools":[ + {"type":"namespace","name":"mcp__calendar__","description":"Calendar tools","tools":[ + {"type":"function","name":"create","defer_loading":true,"parameters":{"type":"object"}}, + {"type":"function","name":"list","parameters":{"type":"object"}} + ]}, + {"type":"tool_search","execution":"client","description":"Find a calendar tool","parameters":{"type":"object"}} + ], + "tool_choice":{"type":"function","name":"list","namespace":"mcp__calendar__"} + }`) + + normalized, compatibility, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("namespace 请求未建立响应恢复映射") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + if parallel, ok := request["parallel_tool_calls"].(bool); !ok || parallel { + t.Fatalf("客户端 tool_search 必须串行执行: %#v", request["parallel_tool_calls"]) + } + tools := request["tools"].([]any) + if len(tools) != 2 { + t.Fatalf("上游 tools = %#v", tools) + } + listTool := tools[0].(map[string]any) + searchTool := tools[1].(map[string]any) + if listTool["type"] != "function" || listTool["name"] != "mcp__calendar__list" { + t.Fatalf("namespace 函数未展平: %#v", listTool) + } + if _, exists := listTool["defer_loading"]; exists { + t.Fatalf("defer_loading 泄露到上游: %#v", listTool) + } + if searchTool["type"] != "function" || searchTool["name"] != "grok2api_tool_search" || !strings.Contains(searchTool["description"].(string), "Calendar tools") { + t.Fatalf("客户端 tool_search 未转换: %#v", searchTool) + } + choice := request["tool_choice"].(map[string]any) + if choice["name"] != "mcp__calendar__list" || choice["namespace"] != nil { + t.Fatalf("tool_choice 未转换: %#v", choice) + } + + upstream := []byte(`{ + "id":"resp_1","object":"response", + "tools":[{"type":"function","name":"mcp__calendar__list"},{"type":"function","name":"grok2api_tool_search"}], + "output":[ + {"type":"function_call","call_id":"call_1","name":"mcp__calendar__list","arguments":"{}"}, + {"type":"function_call","call_id":"call_2","name":"grok2api_tool_search","arguments":"{\"goal\":\"calendar\"}"} + ] + }`) + restored, err := compatibility.normalizeResponseJSON(upstream) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(restored, &response); err != nil { + t.Fatal(err) + } + output := response["output"].([]any) + namespaced := output[0].(map[string]any) + if namespaced["name"] != "list" || namespaced["namespace"] != "mcp__calendar__" { + t.Fatalf("namespace 调用未恢复: %#v", namespaced) + } + searchCall := output[1].(map[string]any) + arguments, ok := searchCall["arguments"].(map[string]any) + if searchCall["type"] != "tool_search_call" || searchCall["execution"] != "client" || searchCall["name"] != nil || !ok || arguments["goal"] != "calendar" { + t.Fatalf("tool_search_call 未恢复: %#v", searchCall) + } + visibleTools := response["tools"].([]any) + if len(visibleTools) != 2 || visibleTools[0].(map[string]any)["type"] != "namespace" || visibleTools[1].(map[string]any)["type"] != "tool_search" { + t.Fatalf("响应 tools 未恢复为客户端声明: %#v", visibleTools) + } +} + +func TestNormalizeResponsesRequestLoadsClientToolSearchOutput(t *testing.T) { + body := []byte(`{ + "model":"public", + "input":[ + {"type":"tool_search_call","execution":"client","call_id":"search_1","arguments":{"goal":"shipping"}}, + {"type":"tool_search_output","execution":"client","call_id":"search_1","status":"completed","tools":[ + {"type":"namespace","name":"shipping","tools":[ + {"type":"function","name":"get_eta","defer_loading":true,"parameters":{"type":"object"}} + ]} + ]} + ] + }`) + + normalized, compatibility, err := normalizeResponsesRequest(body, "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil { + t.Fatal("tool_search 续轮未建立响应恢复映射") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("已加载 tools = %#v", tools) + } + loaded := tools[0].(map[string]any) + if loaded["name"] != "shipping__get_eta" || loaded["defer_loading"] != nil { + t.Fatalf("已加载工具未转换: %#v", loaded) + } + input := request["input"].([]any) + call := input[0].(map[string]any) + output := input[1].(map[string]any) + if call["type"] != "function_call" || call["name"] != "grok2api_tool_search" || call["arguments"] != `{"goal":"shipping"}` { + t.Fatalf("tool_search_call 历史未转换: %#v", call) + } + if output["type"] != "function_call_output" || output["call_id"] != "search_1" { + t.Fatalf("tool_search_output 历史未转换: %#v", output) + } +} + +func TestNormalizeResponsesRequestLoadsServerToolSearchHistory(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public", + "input":[ + {"type":"tool_search_call","execution":"server","call_id":"search_1","arguments":{"goal":"shipping"}}, + {"type":"tool_search_output","execution":"server","call_id":"search_1","status":"completed","tools":[ + {"type":"function","name":"get_eta","defer_loading":true,"parameters":{"type":"object"}} + ]} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tools := request["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["name"] != "get_eta" || tools[0].(map[string]any)["defer_loading"] != nil { + t.Fatalf("tools = %#v", tools) + } + items := request["input"].([]any) + if len(items) != 2 || items[0].(map[string]any)["role"] != "developer" || items[1].(map[string]any)["role"] != "developer" { + t.Fatalf("history = %#v", items) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "server_tool_search_history_approximated") { + t.Fatalf("compatibility = %#v", compatibility) + } +} + +func TestNormalizeResponsesRequestEagerLoadsServerToolSearch(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[ + {"type":"function","name":"lookup","defer_loading":true,"parameters":{"type":"object"}}, + {"type":"tool_search"} + ], + "tool_choice":{"type":"tool_search"} + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + tools := payload["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["name"] != "lookup" || tools[0].(map[string]any)["defer_loading"] != nil || payload["tool_choice"] != "auto" { + t.Fatalf("payload = %#v", payload) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "server_tool_search_eager_loaded") || !strings.Contains(compatibility.warningHeader(), "server_tool_search_choice_downgraded") { + t.Fatalf("compatibility = %#v", compatibility) + } +} + +func TestNormalizeResponsesRequestDropsEmptyServerToolSearchChoice(t *testing.T) { + normalized, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"tool_search"}], + "tool_choice":{"type":"tool_search"}, + "parallel_tool_calls":true + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload["tools"]; exists { + t.Fatalf("tools = %#v", payload["tools"]) + } + if _, exists := payload["tool_choice"]; exists { + t.Fatalf("tool_choice = %#v", payload["tool_choice"]) + } + if _, exists := payload["parallel_tool_calls"]; exists { + t.Fatalf("parallel_tool_calls = %#v", payload["parallel_tool_calls"]) + } +} + +func TestNormalizeResponsesRequestSerializesParallelClientToolSearch(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello","parallel_tool_calls":true, + "tools":[{"type":"tool_search","execution":"client"}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + if payload["parallel_tool_calls"] != false || compatibility == nil || !strings.Contains(compatibility.warningHeader(), "client_tool_search_forced_serial") { + t.Fatalf("payload=%#v compatibility=%#v", payload, compatibility) + } +} + +func TestNormalizeResponsesRequestLoadsDeferredToolWithoutSearch(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"function","name":"lookup","defer_loading":true,"parameters":{"type":"object"}}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + tool := payload["tools"].([]any)[0].(map[string]any) + if tool["name"] != "lookup" || tool["defer_loading"] != nil || compatibility == nil || !strings.Contains(compatibility.warningHeader(), "orphan_deferred_tool_loaded") { + t.Fatalf("tool=%#v compatibility=%#v", tool, compatibility) + } +} + +func TestNormalizeResponsesRequestKeepsOrdinaryFunctionsOnNativePath(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"function","name":"lookup","description":"Lookup","parameters":{"type":"object"}}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility != nil { + t.Fatal("普通函数请求不应启用响应兼容层") + } + var request map[string]any + if err := json.Unmarshal(normalized, &request); err != nil { + t.Fatal(err) + } + tool := request["tools"].([]any)[0].(map[string]any) + if tool["name"] != "lookup" || tool["description"] != "Lookup" { + t.Fatalf("普通函数被意外改写: %#v", tool) + } +} + +func TestNormalizeResponsesRequestRemovesNullableFunctionParameterRoot(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"function","name":"automation_update","parameters":{ + "anyOf":[ + {"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}, + {"type":"null"} + ] + }}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "function_parameters_nullable_root_normalized") { + t.Fatalf("compatibility = %#v", compatibility) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + parameters := payload["tools"].([]any)[0].(map[string]any)["parameters"].(map[string]any) + if parameters["type"] != "object" || parameters["anyOf"] != nil || parameters["additionalProperties"] != false { + t.Fatalf("upstream parameters = %#v", parameters) + } + properties := parameters["properties"].(map[string]any) + if properties["title"].(map[string]any)["type"] != "string" { + t.Fatalf("upstream properties = %#v", properties) + } + visibleParameters := compatibility.visibleTools[0].(map[string]any)["parameters"].(map[string]any) + if _, exists := visibleParameters["anyOf"]; !exists || visibleParameters["type"] != nil { + t.Fatalf("visible parameters were mutated = %#v", visibleParameters) + } +} + +func TestNormalizeResponsesRequestRejectsNullableNonObjectFunctionRoot(t *testing.T) { + _, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"function","name":"invalid","parameters":{ + "anyOf":[{"type":"object"},{"type":"string"},{"type":"null"}] + }}] + }`), "grok-4.5") + requestErr, ok := err.(*responsesRequestError) + if !ok || requestErr.Param != "tools[0].parameters" || requestErr.Code != "invalid_parameter" { + t.Fatalf("error = %#v", err) + } +} + +func TestNormalizeResponsesRequestRemovesNullableLocalRefFunctionRoot(t *testing.T) { + normalized, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"function","name":"lookup","parameters":{ + "$defs":{"Args":{ + "type":"object", + "properties":{"query":{"type":"string"}}, + "required":["query"] + }}, + "anyOf":[{"$ref":"#/$defs/Args"},{"type":"null"}] + }}] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + if compatibility == nil || !strings.Contains(compatibility.warningHeader(), "function_parameters_nullable_root_normalized") { + t.Fatalf("compatibility = %#v", compatibility) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + parameters := payload["tools"].([]any)[0].(map[string]any)["parameters"].(map[string]any) + if parameters["type"] != "object" { + t.Fatalf("upstream parameters = %#v", parameters) + } + branches := parameters["anyOf"].([]any) + if len(branches) != 1 || branches[0].(map[string]any)["$ref"] != "#/$defs/Args" { + t.Fatalf("upstream branches = %#v", branches) + } + visibleParameters := compatibility.visibleTools[0].(map[string]any)["parameters"].(map[string]any) + visibleBranches := visibleParameters["anyOf"].([]any) + if len(visibleBranches) != 2 || visibleParameters["type"] != nil { + t.Fatalf("visible parameters were mutated = %#v", visibleParameters) + } +} + +func TestNormalizeResponsesRequestRejectsNullableExternalRefFunctionRoot(t *testing.T) { + _, _, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[{"type":"function","name":"lookup","parameters":{ + "anyOf":[ + {"$ref":"https://example.com/schema.json#/$defs/Args"}, + {"type":"null"} + ] + }}] + }`), "grok-4.5") + requestErr, ok := err.(*responsesRequestError) + if !ok || requestErr.Param != "tools[0].parameters" || requestErr.Code != "invalid_parameter" { + t.Fatalf("error = %#v", err) + } +} + +func TestNormalizeBuildFunctionParametersRootVariants(t *testing.T) { + tests := []struct { + name string + schema map[string]any + check func(*testing.T, map[string]any, bool) + }{ + { + name: "type array", + schema: map[string]any{"type": []any{"object", "null"}, "properties": map[string]any{}}, + check: func(t *testing.T, normalized map[string]any, changed bool) { + if !changed || normalized["type"] != "object" { + t.Fatalf("normalized=%#v changed=%v", normalized, changed) + } + }, + }, + { + name: "oneOf", + schema: map[string]any{"oneOf": []any{ + map[string]any{"type": "object", "additionalProperties": false}, + map[string]any{"type": "null"}, + }}, + check: func(t *testing.T, normalized map[string]any, changed bool) { + if !changed || normalized["type"] != "object" || normalized["oneOf"] != nil { + t.Fatalf("normalized=%#v changed=%v", normalized, changed) + } + }, + }, + { + name: "nested nullable unchanged", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{ + "anyOf": []any{map[string]any{"type": "string"}, map[string]any{"type": "null"}}, + }}, + }, + check: func(t *testing.T, normalized map[string]any, changed bool) { + properties := normalized["properties"].(map[string]any) + value := properties["value"].(map[string]any) + if changed || value["anyOf"] == nil { + t.Fatalf("normalized=%#v changed=%v", normalized, changed) + } + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, changed, err := normalizeBuildFunctionParametersRoot(test.schema, "tools[0].parameters") + if err != nil { + t.Fatal(err) + } + test.check(t, value.(map[string]any), changed) + }) + } +} + +func TestResponsesCompatibilityRestoresNamespaceAndToolSearchStream(t *testing.T) { + _, compatibility, err := normalizeResponsesRequest([]byte(`{ + "model":"public","input":"hello", + "tools":[ + {"type":"namespace","name":"crm","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}, + {"type":"tool_search","execution":"client","parameters":{"type":"object"}} + ] + }`), "grok-4.5") + if err != nil { + t.Fatal(err) + } + source := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"crm__lookup","arguments":""}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"item_2","type":"function_call","call_id":"call_2","name":"grok2api_tool_search","arguments":""}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"item_2","delta":"{\"goal\":"}`, + ``, + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","item_id":"item_2","arguments":"{\"goal\":\"crm\"}"}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"item_2","type":"function_call","call_id":"call_2","name":"grok2api_tool_search","arguments":"{\"goal\":\"crm\"}"}}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"object":"response","tools":[{"type":"function","name":"crm__lookup"}],"output":[{"type":"function_call","call_id":"call_1","name":"crm__lookup","arguments":"{}"}]}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + stream := compatibility.normalizeResponseStream(io.NopCloser(strings.NewReader(source))) + converted, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(converted) + if strings.Contains(text, "response.function_call_arguments") { + t.Fatalf("内部 Tool Search 参数事件未隐藏:\n%s", text) + } + for _, expected := range []string{`"namespace":"crm"`, `"type":"tool_search_call"`, `"goal":"crm"`, `"type":"namespace"`, `data: [DONE]`} { + if !strings.Contains(text, expected) { + t.Fatalf("流式响应缺少 %s:\n%s", expected, text) + } + } +} diff --git a/backend/internal/infra/provider/cli/responses_x_search_filter.go b/backend/internal/infra/provider/cli/responses_x_search_filter.go new file mode 100644 index 0000000000000000000000000000000000000000..5827180e199f797292956b5afec1cfc8197beb8f --- /dev/null +++ b/backend/internal/infra/provider/cli/responses_x_search_filter.go @@ -0,0 +1,299 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// buildXSearchResponseFilter hides internal custom_tool_call items emitted while xAI executes native x_search. +// The upstream has already completed them, so Codex and Claude Code must not execute them again. +type buildXSearchResponseFilter struct { + clientTools map[string]struct{} + injectedToolTypes map[string]struct{} + droppedOutputIndexes map[int]struct{} + droppedItemIDs map[string]struct{} +} + +func newBuildXSearchResponseFilter(route buildPromptCacheRoute) *buildXSearchResponseFilter { + return &buildXSearchResponseFilter{ + clientTools: route.clientDeclaredTools, + injectedToolTypes: route.injectedToolTypes, + droppedOutputIndexes: make(map[int]struct{}), + droppedItemIDs: make(map[string]struct{}), + } +} + +func filterBuildPromptCacheResponse(response *http.Response, streaming bool, route buildPromptCacheRoute) error { + if response == nil || response.Body == nil || (!route.filterXSearch && len(route.injectedToolTypes) == 0) { + return nil + } + filter := newBuildXSearchResponseFilter(route) + if streaming { + response.Body = filter.stream(response.Body) + response.Header.Del("Content-Length") + response.ContentLength = -1 + return nil + } + source := response.Body + data, err := io.ReadAll(io.LimitReader(source, maxCompatibleResponseBytes+1)) + _ = source.Close() + if err != nil { + return err + } + if len(data) > maxCompatibleResponseBytes { + return fmt.Errorf("Grok Build Responses 响应超过 %d MiB", maxCompatibleResponseBytes>>20) + } + filtered, err := filter.filterJSON(data) + if err != nil { + return err + } + response.Body = io.NopCloser(bytes.NewReader(filtered)) + response.Header.Set("Content-Length", strconv.Itoa(len(filtered))) + response.ContentLength = int64(len(filtered)) + return nil +} + +func (f *buildXSearchResponseFilter) stream(source io.ReadCloser) io.ReadCloser { + reader, writer := io.Pipe() + go func() { + defer source.Close() + err := consumeCompatibleSSE(source, func(event compatibleSSEEvent) error { + if !event.HasData() { + return event.writeTo(writer) + } + data := event.Data() + if bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { + return event.writeTo(writer) + } + filtered, keep, filterErr := f.filterEvent(data) + if filterErr != nil { + return filterErr + } + if !keep { + return nil + } + event.SetData(filtered) + return event.writeTo(writer) + }) + _ = writer.CloseWithError(err) + }() + return reader +} + +func (f *buildXSearchResponseFilter) filterJSON(body []byte) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("解析 Grok Build Responses 响应: %w", err) + } + if err := f.filterEnvelope(payload); err != nil { + return nil, err + } + filtered, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("编码 Grok Build Responses 响应: %w", err) + } + return filtered, nil +} + +func (f *buildXSearchResponseFilter) filterEvent(data []byte) ([]byte, bool, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(data, &payload); err != nil { + return data, true, nil + } + if item := payload["item"]; !isEmptyJSON(item) && f.isInternalCall(item) { + f.recordDroppedItem(payload, item) + return nil, false, nil + } + if err := f.filterEnvelope(payload); err != nil { + return nil, false, err + } + if f.referencesDroppedItem(payload) { + return nil, false, nil + } + f.compactOutputIndex(payload) + filtered, err := json.Marshal(payload) + if err != nil { + return nil, false, err + } + return filtered, true, nil +} + +func (f *buildXSearchResponseFilter) filterEnvelope(payload map[string]json.RawMessage) error { + if err := f.filterOutput(payload); err != nil { + return err + } + if err := f.filterTools(payload); err != nil { + return err + } + if raw := payload["response"]; !isEmptyJSON(raw) { + var response map[string]json.RawMessage + if json.Unmarshal(raw, &response) == nil && response != nil { + if err := f.filterOutput(response); err != nil { + return err + } + if err := f.filterTools(response); err != nil { + return err + } + payload["response"] = mustJSON(response) + } + } + return nil +} + +func (f *buildXSearchResponseFilter) filterOutput(envelope map[string]json.RawMessage) error { + raw := envelope["output"] + if isEmptyJSON(raw) { + return nil + } + var output []json.RawMessage + if json.Unmarshal(raw, &output) != nil { + return fmt.Errorf("解析 Grok Build Responses output 失败") + } + filtered := make([]json.RawMessage, 0, len(output)) + for _, rawItem := range output { + if f.isInternalCall(rawItem) { + continue + } + filtered = append(filtered, rawItem) + } + envelope["output"] = mustJSON(filtered) + return nil +} + +func (f *buildXSearchResponseFilter) filterTools(envelope map[string]json.RawMessage) error { + if len(f.injectedToolTypes) == 0 { + return nil + } + raw := envelope["tools"] + if isEmptyJSON(raw) { + return nil + } + var tools []json.RawMessage + if json.Unmarshal(raw, &tools) != nil { + return fmt.Errorf("解析 Grok Build Responses tools 失败") + } + filtered := make([]json.RawMessage, 0, len(tools)) + for _, rawTool := range tools { + kind, _ := buildCacheToolIdentity(rawTool) + if _, injected := f.injectedToolTypes[kind]; injected { + continue + } + filtered = append(filtered, rawTool) + } + if len(filtered) == 0 { + delete(envelope, "tools") + return nil + } + envelope["tools"] = mustJSON(filtered) + return nil +} + +type buildXSearchResponseItem struct { + Type string `json:"type"` + ID string `json:"id"` + CallID string `json:"call_id"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +func (f *buildXSearchResponseFilter) isInternalCall(raw json.RawMessage) bool { + var item buildXSearchResponseItem + if json.Unmarshal(raw, &item) != nil { + return false + } + kind := strings.TrimSpace(item.Type) + if kind == "web_search_call" { + _, injected := f.injectedToolTypes["web_search"] + return injected + } + if kind != "custom_tool_call" && kind != "function_call" { + return false + } + callID := strings.TrimSpace(item.CallID) + if strings.HasPrefix(callID, "xs_call") { + return true + } + name := strings.TrimSpace(item.Name) + if !isBuildInternalXSearchToolName(name) || strings.TrimSpace(item.Namespace) != "" { + return false + } + if _, declared := f.clientTools[name]; declared { + return false + } + return true +} + +func isBuildInternalXSearchToolName(name string) bool { + switch strings.TrimSpace(name) { + case "x_user_search", "x_semantic_search", "x_keyword_search", "x_thread_fetch": + return true + default: + return false + } +} + +func (f *buildXSearchResponseFilter) recordDroppedItem(payload map[string]json.RawMessage, rawItem json.RawMessage) { + if index, ok := rawJSONInt(payload["output_index"]); ok { + f.droppedOutputIndexes[index] = struct{}{} + } + var item buildXSearchResponseItem + if json.Unmarshal(rawItem, &item) != nil { + return + } + for _, value := range []string{item.ID, item.CallID} { + if value = strings.TrimSpace(value); value != "" { + f.droppedItemIDs[value] = struct{}{} + } + } +} + +func (f *buildXSearchResponseFilter) referencesDroppedItem(payload map[string]json.RawMessage) bool { + if index, ok := rawJSONInt(payload["output_index"]); ok { + if _, dropped := f.droppedOutputIndexes[index]; dropped { + return true + } + } + for _, name := range []string{"item_id", "call_id"} { + if value := strings.TrimSpace(rawJSONString(payload[name])); value != "" { + if _, dropped := f.droppedItemIDs[value]; dropped { + return true + } + } + } + return false +} + +func (f *buildXSearchResponseFilter) compactOutputIndex(payload map[string]json.RawMessage) { + index, ok := rawJSONInt(payload["output_index"]) + if !ok { + return + } + removedBefore := 0 + for dropped := range f.droppedOutputIndexes { + if dropped < index { + removedBefore++ + } + } + if removedBefore > 0 { + payload["output_index"] = mustJSON(index - removedBefore) + } +} + +func rawJSONInt(raw json.RawMessage) (int, bool) { + if isEmptyJSON(raw) { + return 0, false + } + parsed, err := strconv.Atoi(string(bytes.TrimSpace(raw))) + return parsed, err == nil +} + +func rawJSONString(raw json.RawMessage) string { + var value string + _ = json.Unmarshal(raw, &value) + return value +} diff --git a/backend/internal/infra/provider/cli/session_id_test.go b/backend/internal/infra/provider/cli/session_id_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bd7edad773b4751af28885cb38c667fbd93da88d --- /dev/null +++ b/backend/internal/infra/provider/cli/session_id_test.go @@ -0,0 +1,29 @@ +package cli + +import "testing" + +func TestGrokSessionIDEmptyWhenNoKey(t *testing.T) { + got, err := grokSessionID("") + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("empty key must not invent session id, got %q", got) + } + // 两次调用仍为空,证明无无随机漂移 + again, err := grokSessionID(" ") + if err != nil || again != "" { + t.Fatalf("blank key = %q err=%v", again, err) + } +} + +func TestGrokSessionIDStableForSameKey(t *testing.T) { + first, err := grokSessionID("client-session") + if err != nil || first == "" { + t.Fatalf("first = %q err=%v", first, err) + } + second, err := grokSessionID("client-session") + if err != nil || second != first { + t.Fatalf("unstable: first=%q second=%q err=%v", first, second, err) + } +} diff --git a/backend/internal/infra/provider/cli/video.go b/backend/internal/infra/provider/cli/video.go new file mode 100644 index 0000000000000000000000000000000000000000..ce961c5b85f14521460c2a1bfe46d659e4e72d02 --- /dev/null +++ b/backend/internal/infra/provider/cli/video.go @@ -0,0 +1,557 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const ( + buildVideoModel = "grok-imagine-video-1.5" + xaiVideoModel = "grok-imagine-video-1.5-preview" + buildVideoMaxImages = 1 + buildVideoPollEvery = 2 * time.Second + buildVideoMaxBodySize = 2 << 20 + buildVideoErrorSummaryLimit = 256 + buildVideoErrorMessageLimit = 160 +) + +type videoRequestProfile struct { + model string + imageURLField string + userAgentProduct string +} + +var ( + buildVideoRequestProfile = videoRequestProfile{model: buildVideoModel, imageURLField: "image_url"} + // Direct XAI image and video tools use xai-grok-build/, unlike the + // grok-shell/ user agent used for cli-chat-proxy sampling requests. + xaiVideoRequestProfile = videoRequestProfile{model: xaiVideoModel, imageURLField: "url", userAgentProduct: "xai-grok-build"} +) + +// mediaUploadSecretPattern 匹配上传路径及其中的 64-hex 一次性 token(含完整 URL 前缀)。 +var mediaUploadSecretPattern = regexp.MustCompile(`(?i)(?:https?://[^\s"'\\<>]+)?/v1/media/uploads/[0-9a-f]{64}`) + +// bareUploadTokenPattern 匹配无路径前缀的 64-hex 上传 token(上游可能仅回显 token 本身)。 +var bareUploadTokenPattern = regexp.MustCompile(`(?i)\b[0-9a-f]{64}\b`) + +// videoUpstreamError 是上游非 2xx 的可分类错误:保留 HTTP 状态,摘要经密钥脱敏且长度有界。 +// 不得将原始响应体写入 Error(),避免 upload_url / token 泄漏到 media_jobs.error_message、日志或审计。 +type videoUpstreamError struct { + status int + summary string +} + +func (e *videoUpstreamError) Error() string { + if e == nil { + return "" + } + return e.summary +} + +func (e *videoUpstreamError) HTTPStatusCode() int { + if e == nil { + return 0 + } + return e.status +} + +// newVideoUpstreamError 从上游响应构造密钥安全、有界的错误摘要。 +func newVideoUpstreamError(status int, body []byte) *videoUpstreamError { + return &videoUpstreamError{status: status, summary: summarizeVideoUpstreamError(status, body)} +} + +func summarizeVideoUpstreamError(status int, body []byte) string { + code, message := extractVideoUpstreamErrorFields(body) + parts := []string{fmt.Sprintf("Build 视频上游返回 %d", status)} + if code != "" { + parts = append(parts, code) + } + if message != "" { + parts = append(parts, message) + } else if code == "" { + // 非结构化正文:仅作有界、脱敏片段,避免整包原文进入错误链。 + if snippet := redactVideoUploadSecrets(strings.TrimSpace(string(body))); snippet != "" { + parts = append(parts, boundDiagnosticText(snippet, buildVideoErrorMessageLimit)) + } + } + return boundDiagnosticText(strings.Join(parts, ": "), buildVideoErrorSummaryLimit) +} + +func extractVideoUpstreamErrorFields(body []byte) (code, message string) { + body = bytes.TrimSpace(body) + if len(body) == 0 || body[0] != '{' { + return "", "" + } + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return "", "" + } + if errObj, ok := root["error"].(map[string]any); ok { + code = firstString(errObj, "code", "type", "error") + message = firstString(errObj, "message", "error", "detail") + } + if code == "" { + code = firstString(root, "code", "error_code", "type") + } + if message == "" { + message = firstString(root, "message", "error_message", "detail") + } + if code != "" { + code = boundDiagnosticText(redactVideoUploadSecrets(strings.TrimSpace(code)), 64) + } + if message != "" { + message = safeVideoDiagnosticMessage(message) + } + return code, message +} + +func redactVideoUploadSecrets(value string) string { + if value == "" { + return value + } + // 先替换完整 upload 路径,再替换残留的裸 64-hex token。 + value = mediaUploadSecretPattern.ReplaceAllString(value, "/v1/media/uploads/[REDACTED]") + value = bareUploadTokenPattern.ReplaceAllString(value, "[REDACTED]") + return value +} + +// safeVideoDiagnosticMessage 对上游派生的 code/message 做密钥脱敏与长度有界处理。 +// 用于非 2xx 摘要以及 2xx JSON 中的 error.message 等字段,避免 upload token 进入错误链。 +func safeVideoDiagnosticMessage(message string) string { + return boundDiagnosticText(redactVideoUploadSecrets(strings.TrimSpace(message)), buildVideoErrorMessageLimit) +} + +func boundDiagnosticText(value string, limit int) string { + if limit <= 0 || value == "" { + return "" + } + if len(value) <= limit { + return value + } + for limit > 0 && !utf8.ValidString(value[:limit]) { + limit-- + } + return value[:limit] +} + +// GenerateVideo 通过 Build OAuth 创建并轮询视频任务;对外仍使用 grok-imagine-video-1.5, +// 发往 XAI 时仅在出站协议层转换为官方 preview 模型与 image.url 结构。 +// 最多 1 张首图;多于 1 张在调用上游前失败,不静默截断。 +// 显式模式优先;auto 下仅已确认 Super 且 bot_flag_source=1 默认使用 XAI。 +// 其他 auto Super 账号仅在当次 Build 创建返回 403 后探测 XAI。 +func (a *Adapter) GenerateVideo(ctx context.Context, request provider.VideoRequest) (provider.VideoResult, error) { + if len(request.ReferenceURLs) > buildVideoMaxImages { + return provider.VideoResult{}, fmt.Errorf("Build grok-imagine-video-1.5 最多支持 1 张首图,当前为 %d 张", len(request.ReferenceURLs)) + } + accessToken, err := a.cipher.Decrypt(request.Credential.EncryptedAccessToken) + if err != nil { + return provider.VideoResult{}, err + } + credential := request.Credential + routeMode := normalizedBuildRouteMode(credential) + xaiEligible := account.IsBuildSuper(credential, request.Billing) + if routeMode == account.BuildRouteXAI || (routeMode == account.BuildRouteAuto && xaiEligible && a.CredentialMetadata(credential).BuildBotFlagged) { + return a.generateVideoOnXAI(ctx, request, credential, accessToken, false) + } + // 非 bot-flagged 账号先走 Build 主地址,不读取历史回退标记。 + primaryBase := a.primaryBaseURL() + payload, err := videoCreatePayload(request, "", buildVideoRequestProfile) + if err != nil { + return provider.VideoResult{}, err + } + body, err := json.Marshal(payload) + if err != nil { + return provider.VideoResult{}, err + } + createResp, createErr := a.doVideoJSON(ctx, credential, accessToken, http.MethodPost, primaryBase, "/videos/generations", body, buildVideoRequestProfile, true) + if createErr == nil { + jobID, parseErr := parseVideoCreateResponse(createResp) + if parseErr != nil { + return provider.VideoResult{}, parseErr + } + if request.Progress != nil { + request.Progress(1) + } + return a.pollVideoJob(ctx, credential, accessToken, primaryBase, jobID, "", buildVideoRequestProfile, request.Progress) + } + var upstream *videoUpstreamError + if !asVideoUpstreamError(createErr, &upstream) || !isHTTPForbidden(upstream.status) { + return provider.VideoResult{}, createErr + } + if routeMode == account.BuildRouteBuild || !xaiEligible { + return provider.VideoResult{}, createErr + } + // 已确认 Super 的主地址 403:签发 upload_url 后探测 XAI;创建成功才标记降级。 + return a.generateVideoOnXAI(ctx, request, credential, accessToken, true) +} + +func (a *Adapter) generateVideoOnXAI(ctx context.Context, request provider.VideoRequest, credential account.Credential, accessToken string, recordFallback bool) (provider.VideoResult, error) { + issuer := a.uploadIssuerRef() + if issuer == nil { + return provider.VideoResult{}, fmt.Errorf("XAI 视频需要媒体上传接收服务") + } + jobKey := strings.TrimSpace(request.JobID) + if jobKey == "" { + jobKey = "video-pending" + } + uploadURL, assetID, err := issuer.IssueVideoUpload(ctx, jobKey) + if err != nil { + // 配置错误(例如 PublicAPIBaseURL 不可用)不得误标降级。 + return provider.VideoResult{}, err + } + payload, err := videoCreatePayload(request, uploadURL, xaiVideoRequestProfile) + if err != nil { + return provider.VideoResult{}, err + } + body, err := json.Marshal(payload) + if err != nil { + return provider.VideoResult{}, err + } + base := a.fallbackBaseURL() + createResp, err := a.doVideoJSON(ctx, credential, accessToken, http.MethodPost, base, "/videos/generations", body, xaiVideoRequestProfile, true) + if err != nil { + return provider.VideoResult{}, err + } + // 必须先解析到可用 job ID,再标记降级;畸形 2xx 不得激活或本地置位。 + jobID, err := parseVideoCreateResponse(createResp) + if err != nil { + return provider.VideoResult{}, err + } + if recordFallback { + a.activateBuildAPIFallback(ctx, &credential) + } + if request.Progress != nil { + request.Progress(1) + } + return a.pollVideoJob(ctx, credential, accessToken, base, jobID, assetID, xaiVideoRequestProfile, request.Progress) +} + +// DownloadVideo 通过 Build egress 拉取已完成任务的公开 CDN URL。 +// 资源域不需要 OAuth;不得解密或转发 token 与客户端身份头。 +func (a *Adapter) DownloadVideo(ctx context.Context, credential account.Credential, rawURL string) (io.ReadCloser, string, int64, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || !trustedBuildVideoAssetHost(parsed.Hostname()) { + return nil, "", 0, fmt.Errorf("视频内容 URL 不受信任") + } + requestCtx := infraegress.WithCredential(ctx, credential) + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, "", 0, err + } + // 仅保留 egress 路径上的匿名 GET;禁止 Authorization / Token-Auth / 会话身份头。 + req.Header.Set("Accept", "*/*") + response, err := a.http.Do(req) + if err != nil { + return nil, "", 0, err + } + if err := normalizeGzipResponse(response); err != nil { + return nil, "", 0, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + _ = response.Body.Close() + return nil, "", 0, fmt.Errorf("下载视频返回 %d", response.StatusCode) + } + contentType := strings.ToLower(strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])) + if contentType == "" || contentType == "application/octet-stream" { + contentType = "video/mp4" + } + if !strings.HasPrefix(contentType, "video/") { + _ = response.Body.Close() + return nil, "", 0, fmt.Errorf("上游视频 Content-Type 无效") + } + return response.Body, contentType, response.ContentLength, nil +} + +func videoCreatePayload(request provider.VideoRequest, uploadURL string, profile videoRequestProfile) (map[string]any, error) { + payload := map[string]any{ + "model": profile.model, + } + if prompt := strings.TrimSpace(request.Prompt); prompt != "" { + payload["prompt"] = prompt + } + if request.Duration > 0 { + payload["duration"] = request.Duration + } + if ratio := strings.TrimSpace(request.AspectRatio); ratio != "" { + payload["aspect_ratio"] = ratio + } + if resolution := strings.TrimSpace(request.Resolution); resolution != "" { + payload["resolution"] = resolution + } + if len(request.ReferenceURLs) == 1 { + imageURL := strings.TrimSpace(request.ReferenceURLs[0]) + if imageURL == "" { + return nil, fmt.Errorf("视频首图 URL 不能为空") + } + payload["image"] = map[string]any{profile.imageURLField: imageURL} + } + if _, hasPrompt := payload["prompt"]; !hasPrompt { + if _, hasImage := payload["image"]; !hasImage { + return nil, fmt.Errorf("文本生视频必须提供 prompt;图片生视频可以省略 prompt") + } + } + if uploadURL = strings.TrimSpace(uploadURL); uploadURL != "" { + payload["output"] = map[string]any{"upload_url": uploadURL} + } + return payload, nil +} + +func (a *Adapter) pollVideoJob(ctx context.Context, credential account.Credential, accessToken, base, jobID, assetID string, profile videoRequestProfile, progress func(int)) (provider.VideoResult, error) { + ticker := time.NewTicker(buildVideoPollEvery) + defer ticker.Stop() + for { + statusBody, err := a.doVideoJSON(ctx, credential, accessToken, http.MethodGet, base, "/videos/"+url.PathEscape(jobID), nil, profile, false) + if err != nil { + return provider.VideoResult{}, err + } + result, done, pollErr := parseVideoStatusResponse(statusBody, progress, assetID != "") + if pollErr != nil { + return provider.VideoResult{}, pollErr + } + if done { + if assetID != "" { + issuer := a.uploadIssuerRef() + if issuer == nil { + return provider.VideoResult{}, fmt.Errorf("XAI 视频需要媒体上传接收服务") + } + contentType, waitErr := issuer.WaitVideoUpload(ctx, assetID) + if waitErr != nil { + // 上游 done 但本地未收到上传:若 status 带 CDN URL 仍可回退远程读取。 + if result.URL != "" { + return result, nil + } + return provider.VideoResult{}, waitErr + } + if contentType == "" { + contentType = "video/mp4" + } + return provider.VideoResult{ContentType: contentType, AssetID: assetID}, nil + } + return result, nil + } + select { + case <-ctx.Done(): + return provider.VideoResult{}, ctx.Err() + case <-ticker.C: + } + } +} + +func (a *Adapter) doVideoJSON(ctx context.Context, credential account.Credential, accessToken, method, base, path string, body []byte, profile videoRequestProfile, withTrace bool) ([]byte, error) { + var bodyReader io.Reader + if len(body) > 0 { + bodyReader = bytes.NewReader(body) + } + requestCtx := infraegress.WithCredential(ctx, credential) + req, err := http.NewRequestWithContext(requestCtx, method, a.urlWithBase(base, path), bodyReader) + if err != nil { + return nil, err + } + if err := a.applyHeaders(req, credential, accessToken, profile.model, "", withTrace); err != nil { + return nil, err + } + if profile.userAgentProduct != "" { + version := strings.TrimSpace(a.config().ClientVersion) + if version != "" { + req.Header.Set("User-Agent", profile.userAgentProduct+"/"+version) + } + } + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + resp, err := a.http.Do(req) + if err != nil { + return nil, err + } + if err := normalizeGzipResponse(resp); err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, buildVideoMaxBodySize+1)) + if err != nil { + return nil, err + } + if len(data) > buildVideoMaxBodySize { + return nil, fmt.Errorf("Build 视频上游响应超过 %d 字节", buildVideoMaxBodySize) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, provider.ErrUnauthorized + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, newVideoUpstreamError(resp.StatusCode, data) + } + return data, nil +} + +func parseVideoCreateResponse(body []byte) (string, error) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return "", fmt.Errorf("解析 Build 视频创建响应: %w", err) + } + if message := firstNestedString(root, "error", "message"); message != "" { + return "", fmt.Errorf("Build 视频创建失败: %s", safeVideoDiagnosticMessage(message)) + } + if id := firstString(root, "request_id", "id"); id != "" { + return id, nil + } + if data, ok := root["data"].(map[string]any); ok { + if id := firstString(data, "request_id", "id"); id != "" { + return id, nil + } + } + return "", fmt.Errorf("Build 视频创建响应缺少 request_id") +} + +func parseVideoStatusResponse(body []byte, progress func(int), allowMissingURL bool) (provider.VideoResult, bool, error) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return provider.VideoResult{}, false, fmt.Errorf("解析 Build 视频状态响应: %w", err) + } + if message := firstNestedString(root, "error", "message"); message != "" { + return provider.VideoResult{}, false, fmt.Errorf("Build 视频生成失败: %s", safeVideoDiagnosticMessage(message)) + } + statusSource := root + if data, ok := root["data"].(map[string]any); ok { + statusSource = data + } + if value, ok := numberAsInt(firstValue(statusSource, "progress")); ok && progress != nil { + progress(value) + } + status := strings.ToLower(strings.TrimSpace(firstString(statusSource, "status", "state"))) + switch status { + case "failed", "error", "cancelled", "canceled": + message := firstNestedString(statusSource, "error", "message") + if message == "" { + message = firstString(statusSource, "error_message", "message") + } + if message == "" { + message = status + } + return provider.VideoResult{}, false, fmt.Errorf("Build 视频生成失败: %s", safeVideoDiagnosticMessage(message)) + case "completed", "succeeded", "success", "ready", "done": + videoURL := extractBuildVideoURL(root) + if videoURL == "" && !allowMissingURL { + return provider.VideoResult{}, false, fmt.Errorf("视频生成完成但没有返回内容 URL") + } + return provider.VideoResult{URL: videoURL, ContentType: "video/mp4"}, true, nil + default: + if videoURL := extractBuildVideoURL(root); videoURL != "" { + return provider.VideoResult{URL: videoURL, ContentType: "video/mp4"}, true, nil + } + return provider.VideoResult{}, false, nil + } +} + +func asVideoUpstreamError(err error, target **videoUpstreamError) bool { + if err == nil { + return false + } + if v, ok := err.(*videoUpstreamError); ok { + *target = v + return true + } + return false +} + +func extractBuildVideoURL(root map[string]any) string { + candidates := []string{ + firstString(root, "video_url", "url"), + firstNestedString(root, "video", "url"), + firstNestedString(root, "output", "url"), + firstNestedString(root, "result", "url"), + } + if data, ok := root["data"].(map[string]any); ok { + candidates = append(candidates, + firstString(data, "video_url", "url"), + firstNestedString(data, "video", "url"), + firstNestedString(data, "output", "url"), + ) + if items, ok := data["data"].([]any); ok { + for _, item := range items { + if m, ok := item.(map[string]any); ok { + if u := firstString(m, "url", "video_url"); u != "" { + candidates = append(candidates, u) + } + } + } + } + } + if items, ok := root["data"].([]any); ok { + for _, item := range items { + if m, ok := item.(map[string]any); ok { + if u := firstString(m, "url", "video_url"); u != "" { + candidates = append(candidates, u) + } + } + } + } + for _, value := range candidates { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func trustedBuildVideoAssetHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return false + } + trusted := []string{ + "x.ai", "grok.com", "assets.grok.com", "cdn.x.ai", "videos.x.ai", + } + for _, suffix := range trusted { + if host == suffix || strings.HasSuffix(host, "."+suffix) { + return true + } + } + return false +} + +func firstString(values map[string]any, keys ...string) string { + for _, key := range keys { + if text := strings.TrimSpace(stringValue(values[key])); text != "" { + return text + } + } + return "" +} + +func firstNestedString(values map[string]any, objectKey, field string) string { + nested, _ := values[objectKey].(map[string]any) + if nested == nil { + return "" + } + return strings.TrimSpace(stringValue(nested[field])) +} + +func numberAsInt(value any) (int, bool) { + switch typed := value.(type) { + case float64: + return int(typed), true + case int: + return typed, true + case int64: + return int(typed), true + case json.Number: + n, err := typed.Int64() + return int(n), err == nil + default: + return 0, false + } +} diff --git a/backend/internal/infra/provider/cli/video_test.go b/backend/internal/infra/provider/cli/video_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ebd2f8227588273e9c94ffb62f178f05ac2c4b45 --- /dev/null +++ b/backend/internal/infra/provider/cli/video_test.go @@ -0,0 +1,503 @@ +package cli + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestBuildVideoCreatePayloadNoImageAndSingleR2URL(t *testing.T) { + noImage, err := videoCreatePayload(provider.VideoRequest{ + Prompt: "animate waves", Duration: 6, AspectRatio: "16:9", Resolution: "720p", + }, "", buildVideoRequestProfile) + if err != nil { + t.Fatal(err) + } + if noImage["model"] != buildVideoModel || noImage["prompt"] != "animate waves" || noImage["duration"] != 6 { + t.Fatalf("no-image payload = %#v", noImage) + } + if _, exists := noImage["image"]; exists { + t.Fatalf("unexpected image field: %#v", noImage) + } + if _, exists := noImage["output"]; exists { + t.Fatalf("primary payload must not include output: %#v", noImage) + } + + withImage, err := videoCreatePayload(provider.VideoRequest{ + Prompt: "animate", Duration: 6, AspectRatio: "16:9", Resolution: "720p", + ReferenceURLs: []string{"https://cdn.example.com/r2/first.png"}, + }, "", buildVideoRequestProfile) + if err != nil { + t.Fatal(err) + } + image, ok := withImage["image"].(map[string]any) + if !ok || image["image_url"] != "https://cdn.example.com/r2/first.png" { + t.Fatalf("image payload = %#v", withImage) + } + if withImage["model"] != buildVideoModel || withImage["resolution"] != "720p" || withImage["aspect_ratio"] != "16:9" { + t.Fatalf("single-image payload = %#v", withImage) + } + + withUpload, err := videoCreatePayload(provider.VideoRequest{Prompt: "x", Duration: 6}, "https://api.example/v1/media/uploads/tok", buildVideoRequestProfile) + if err != nil { + t.Fatal(err) + } + output, ok := withUpload["output"].(map[string]any) + if !ok || output["upload_url"] != "https://api.example/v1/media/uploads/tok" { + t.Fatalf("upload payload = %#v", withUpload) + } +} + +func TestBuildVideoCreatePayloadImageOnlyEmptyPrompt(t *testing.T) { + payload, err := videoCreatePayload(provider.VideoRequest{ + Prompt: " ", + Duration: 6, + AspectRatio: "16:9", + Resolution: "720p", + ReferenceURLs: []string{"https://r2.example.com/first.png"}, + }, "", buildVideoRequestProfile) + if err != nil { + t.Fatal(err) + } + if _, exists := payload["prompt"]; exists { + t.Fatalf("empty prompt should be omitted: %#v", payload) + } + image, ok := payload["image"].(map[string]any) + if !ok || image["image_url"] != "https://r2.example.com/first.png" || payload["model"] != buildVideoModel { + t.Fatalf("image-only payload = %#v", payload) + } +} + +func TestXAIVideoCreatePayloadMatchesOfficialSchema(t *testing.T) { + payload, err := videoCreatePayload(provider.VideoRequest{ + Prompt: "animate", + Duration: 6, + AspectRatio: "16:9", + Resolution: "720p", + ReferenceURLs: []string{"https://cdn.example.com/r2/first.png"}, + }, "https://api.example/v1/media/uploads/tok", xaiVideoRequestProfile) + if err != nil { + t.Fatal(err) + } + if payload["model"] != xaiVideoModel { + t.Fatalf("XAI model = %#v", payload["model"]) + } + image, ok := payload["image"].(map[string]any) + if !ok || image["url"] != "https://cdn.example.com/r2/first.png" { + t.Fatalf("XAI image payload = %#v", payload["image"]) + } + if _, exists := image["image_url"]; exists { + t.Fatalf("XAI image payload leaked Build field: %#v", image) + } + output, ok := payload["output"].(map[string]any) + if !ok || output["upload_url"] != "https://api.example/v1/media/uploads/tok" { + t.Fatalf("XAI output payload = %#v", payload["output"]) + } +} + +func TestGenerateVideoRejectsTwoImagesBeforeUpstream(t *testing.T) { + adapter, encrypted := newTestBuildVideoAdapter(t) + var hits atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + hits.Add(1) + t.Fatalf("two-image request must not reach upstream: %s %s", request.Method, request.URL) + return nil, nil + }) + _, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 1, EncryptedAccessToken: encrypted}, + Prompt: "animate", + ReferenceURLs: []string{ + "https://cdn.example.com/one.png", + "https://cdn.example.com/two.png", + }, + }) + if err == nil || !strings.Contains(err.Error(), "最多支持 1 张首图") { + t.Fatalf("error = %v", err) + } + if hits.Load() != 0 { + t.Fatalf("upstream hits = %d", hits.Load()) + } +} + +func TestGenerateVideoPostsSingleImageAndPollsUntilReady(t *testing.T) { + adapter, encrypted := newTestBuildVideoAdapter(t) + var createBody map[string]any + var pollCount atomic.Int32 + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if identity := infraegress.AccountFromContext(request.Context()); identity != "grok_build_9" { + t.Fatalf("egress account identity = %q", identity) + } + switch { + case request.Method == http.MethodPost && request.URL.Path == "/v1/videos/generations": + if request.Header.Get("Authorization") != "Bearer access-token" { + t.Fatalf("missing auth header: %#v", request.Header) + } + if request.Header.Get("User-Agent") != "grok-shell/0.2.99 (linux; x86_64)" { + t.Fatalf("Build video user agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("x-grok-model-override") != buildVideoModel { + t.Fatalf("model override = %q", request.Header.Get("x-grok-model-override")) + } + if err := json.NewDecoder(request.Body).Decode(&createBody); err != nil { + t.Fatal(err) + } + return jsonResponse(http.StatusOK, `{"request_id":"vid_123","status":"queued"}`, request), nil + case request.Method == http.MethodGet && request.URL.Path == "/v1/videos/vid_123": + count := pollCount.Add(1) + if count == 1 { + return jsonResponse(http.StatusOK, `{"status":"processing","progress":40}`, request), nil + } + return jsonResponse(http.StatusOK, `{"status":"completed","progress":100,"video":{"url":"https://assets.grok.com/videos/out.mp4"}}`, request), nil + default: + t.Fatalf("unexpected request %s %s", request.Method, request.URL) + return nil, nil + } + }) + + var progressValues []int + result, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{ID: 9, UserID: "user-1", EncryptedAccessToken: encrypted}, + Prompt: "animate knife", + Duration: 6, + AspectRatio: "16:9", + Resolution: "720p", + ReferenceURLs: []string{"https://r2.example.com/first.png"}, + Progress: func(value int) { + progressValues = append(progressValues, value) + }, + }) + if err != nil { + t.Fatal(err) + } + if result.URL != "https://assets.grok.com/videos/out.mp4" || result.ContentType != "video/mp4" { + t.Fatalf("result = %#v", result) + } + image, _ := createBody["image"].(map[string]any) + if createBody["model"] != buildVideoModel || image["image_url"] != "https://r2.example.com/first.png" || createBody["duration"] != float64(6) { + t.Fatalf("create body = %#v", createBody) + } + if pollCount.Load() < 2 { + t.Fatalf("poll count = %d", pollCount.Load()) + } + if len(progressValues) == 0 || progressValues[0] != 1 { + t.Fatalf("progress = %#v", progressValues) + } +} + +func TestGenerateVideoMapsUnauthorizedAndUpstreamErrors(t *testing.T) { + adapter, encrypted := newTestBuildVideoAdapter(t) + + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusUnauthorized, `{"error":{"message":"bad token"}}`, request), nil + }) + _, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Prompt: "test", + }) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("401 err = %v", err) + } + + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadRequest, `{"error":{"message":"invalid image"}}`, request), nil + }) + _, err = adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Prompt: "test", + }) + status, ok := provider.ErrorHTTPStatus(err) + if !ok || status != http.StatusBadRequest || !strings.Contains(err.Error(), "invalid image") { + t.Fatalf("4xx err = %v status=%d ok=%v", err, status, ok) + } + + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, `upstream unavailable`, request), nil + }) + _, err = adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Prompt: "test", + }) + status, ok = provider.ErrorHTTPStatus(err) + if !ok || status != http.StatusBadGateway { + t.Fatalf("5xx err = %v status=%d ok=%v", err, status, ok) + } +} + +func TestVideoUpstreamErrorRedactsUploadTokenFromBody(t *testing.T) { + const token = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + if len(token) != 64 { + t.Fatalf("fixture token length = %d", len(token)) + } + fullURL := "https://api.example.com/v1/media/uploads/" + token + // 上游校验响应可能回显完整 output.upload_url(含一次性 token)。 + body := []byte(`{"error":{"code":"invalid_request","message":"output.upload_url is not reachable: ` + fullURL + `"}}`) + err := newVideoUpstreamError(http.StatusBadRequest, body) + if err == nil { + t.Fatal("expected videoUpstreamError") + } + status, ok := provider.ErrorHTTPStatus(err) + if !ok || status != http.StatusBadRequest { + t.Fatalf("status = %d ok=%v", status, ok) + } + msg := err.Error() + if strings.Contains(msg, token) { + t.Fatalf("error must not contain upload token: %q", msg) + } + if strings.Contains(msg, fullURL) { + t.Fatalf("error must not contain full upload URL: %q", msg) + } + if strings.Contains(msg, "/v1/media/uploads/"+token) { + t.Fatalf("error must not contain upload path with token: %q", msg) + } + if !strings.Contains(msg, "invalid_request") { + t.Fatalf("safe error code should be preserved: %q", msg) + } + if !strings.Contains(msg, "not reachable") { + t.Fatalf("safe error message fragment should be preserved: %q", msg) + } + if !strings.Contains(msg, "[REDACTED]") { + t.Fatalf("expected redaction marker in summary: %q", msg) + } + + // 非 JSON 正文中的完整路径同样脱敏。 + plain := newVideoUpstreamError(http.StatusUnprocessableEntity, []byte("reject upload_url="+fullURL)) + plainMsg := plain.Error() + if strings.Contains(plainMsg, token) || strings.Contains(plainMsg, fullURL) { + t.Fatalf("plain body error leaked secret: %q", plainMsg) + } + if plain.HTTPStatusCode() != http.StatusUnprocessableEntity { + t.Fatalf("plain status = %d", plain.HTTPStatusCode()) + } +} + +func TestParseVideoCreateAndStatusRedactUploadSecretsIn2xxErrors(t *testing.T) { + const token = "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff" + if len(token) != 64 { + t.Fatalf("fixture token length = %d", len(token)) + } + fullURL := "https://public.example/v1/media/uploads/" + token + + assertNoSecret := func(t *testing.T, msg string) { + t.Helper() + if strings.Contains(msg, token) { + t.Fatalf("error leaked bare token: %q", msg) + } + if strings.Contains(msg, fullURL) { + t.Fatalf("error leaked full upload URL: %q", msg) + } + if strings.Contains(msg, "/v1/media/uploads/"+token) { + t.Fatalf("error leaked upload path+token: %q", msg) + } + } + + // 2xx create:error.message 回显完整 upload URL。 + _, err := parseVideoCreateResponse([]byte(`{"error":{"message":"invalid output.upload_url ` + fullURL + `"}}`)) + if err == nil { + t.Fatal("expected create error") + } + createMsg := err.Error() + assertNoSecret(t, createMsg) + if !strings.Contains(createMsg, "invalid output.upload_url") { + t.Fatalf("safe create diagnostics missing: %q", createMsg) + } + if !strings.Contains(createMsg, "[REDACTED]") { + t.Fatalf("expected redaction in create error: %q", createMsg) + } + + // 2xx create:error.message 仅含裸 64-hex token。 + _, err = parseVideoCreateResponse([]byte(`{"error":{"message":"token rejected: ` + token + `"}}`)) + if err == nil { + t.Fatal("expected create bare-token error") + } + assertNoSecret(t, err.Error()) + if !strings.Contains(err.Error(), "token rejected") { + t.Fatalf("safe bare-token fragment missing: %q", err.Error()) + } + + // 2xx status:顶层 error.message 含完整 URL。 + _, _, err = parseVideoStatusResponse([]byte(`{"error":{"message":"upload failed `+fullURL+`"}}`), nil, false) + if err == nil { + t.Fatal("expected status top-level error") + } + assertNoSecret(t, err.Error()) + + // 2xx status:failed 状态 + error_message 含裸 token。 + _, _, err = parseVideoStatusResponse([]byte(`{"status":"failed","error_message":"moderation token=`+token+`"}`), nil, false) + if err == nil { + t.Fatal("expected failed status error") + } + statusMsg := err.Error() + assertNoSecret(t, statusMsg) + if !strings.Contains(statusMsg, "moderation") { + t.Fatalf("safe status diagnostics missing: %q", statusMsg) + } + + // 2xx status:failed + nested error.message 含完整 URL。 + _, _, err = parseVideoStatusResponse([]byte(`{"status":"failed","error":{"message":"bad upload_url `+fullURL+`"}}`), nil, false) + if err == nil { + t.Fatal("expected nested failed status error") + } + assertNoSecret(t, err.Error()) +} + +func TestGenerateVideoFailedStatusAndDownloadTrustedURL(t *testing.T) { + adapter, encrypted := newTestBuildVideoAdapter(t) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case request.Method == http.MethodPost: + return jsonResponse(http.StatusOK, `{"id":"vid_fail"}`, request), nil + case request.Method == http.MethodGet && request.URL.Path == "/v1/videos/vid_fail": + return jsonResponse(http.StatusOK, `{"status":"failed","error":{"message":"moderation"}}`, request), nil + default: + t.Fatalf("unexpected %s %s", request.Method, request.URL) + return nil, nil + } + }) + _, err := adapter.GenerateVideo(context.Background(), provider.VideoRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Prompt: "bad", + }) + if err == nil || !strings.Contains(err.Error(), "moderation") { + t.Fatalf("failed status err = %v", err) + } + + const assetURL = "https://vidgen.x.ai/videos/done.mp4" + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodGet || request.URL.String() != assetURL { + t.Fatalf("download request = %s %s", request.Method, request.URL) + } + if identity := infraegress.AccountFromContext(request.Context()); identity != "grok_build_99" { + t.Fatalf("download egress account identity = %q", identity) + } + for _, key := range []string{ + "Authorization", "X-XAI-Token-Auth", "x-userid", + "x-grok-model-override", "x-grok-session-id", "x-grok-agent-id", + "x-grok-conv-id", "x-grok-req-id", "x-grok-conversation-id", + "x-grok-session-id-legacy", "x-grok-request-id", + "x-grok-client-version", "x-grok-client-identifier", + } { + if value := request.Header.Get(key); value != "" { + t.Fatalf("resource download must not send %s=%q", key, value) + } + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"video/mp4"}}, + Body: io.NopCloser(strings.NewReader("mp4-bytes")), + ContentLength: 9, + Request: request, + }, nil + }) + // 即使传入凭据,下载也不得解密或转发 OAuth / 身份头。 + body, contentType, size, err := adapter.DownloadVideo(context.Background(), account.Credential{ + ID: 99, UserID: "user-1", EncryptedAccessToken: encrypted, + }, assetURL) + if err != nil { + t.Fatal(err) + } + defer func() { _ = body.Close() }() + data, _ := io.ReadAll(body) + if string(data) != "mp4-bytes" || contentType != "video/mp4" || size != 9 { + t.Fatalf("download = %q %s %d", data, contentType, size) + } + if !trustedBuildVideoAssetHost("vidgen.x.ai") { + t.Fatal("vidgen.x.ai must be trusted") + } + + _, _, _, err = adapter.DownloadVideo(context.Background(), account.Credential{EncryptedAccessToken: encrypted}, "https://evil.example/video.mp4") + if err == nil || !strings.Contains(err.Error(), "不受信任") { + t.Fatalf("untrusted download err = %v", err) + } +} + +func TestGenerateVideoRespectsContextCancelDuringPoll(t *testing.T) { + adapter, encrypted := newTestBuildVideoAdapter(t) + adapter.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method == http.MethodPost { + return jsonResponse(http.StatusOK, `{"request_id":"vid_slow"}`, request), nil + } + return jsonResponse(http.StatusOK, `{"status":"processing","progress":10}`, request), nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := adapter.GenerateVideo(ctx, provider.VideoRequest{ + Credential: account.Credential{EncryptedAccessToken: encrypted}, + Prompt: "slow", + }) + if err == nil || !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + t.Fatalf("cancel err = %v", err) + } +} + +func TestRegistryExposesBuildVideoAdapter(t *testing.T) { + registry := provider.NewRegistry(NewAdapter(Config{}, nil)) + adapter, ok := registry.Videos(account.ProviderBuild) + if !ok { + t.Fatal("Build video adapter not registered") + } + if _, ok := adapter.(provider.VideoContentDownloader); !ok { + t.Fatal("Build adapter must implement VideoContentDownloader") + } + definition, ok := registry.Definition(account.ProviderBuild) + if !ok || !definition.SupportsModelCapability(modeldomain.CapabilityVideo) || !definition.Media.VideoGeneration { + t.Fatalf("build definition = %#v", definition) + } +} + +func TestBuildVideoDefinitionDeclaresVideoCapability(t *testing.T) { + adapter := NewAdapter(Config{}, nil) + definition := adapter.Definition() + if !definition.Media.VideoGeneration { + t.Fatal("Media.VideoGeneration not enabled") + } + if !definition.SupportsModelCapability(modeldomain.CapabilityVideo) { + t.Fatalf("capabilities = %#v", definition.ModelCapabilities) + } + if err := definition.Validate(); err != nil { + t.Fatal(err) + } +} + +func newTestBuildVideoAdapter(t *testing.T) (*Adapter, string) { + t.Helper() + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("access-token") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: "https://cli-chat-proxy.grok.com/v1", + ClientVersion: "0.2.99", + ClientIdentifier: "grok-shell", + TokenAuth: "xai-grok-cli", + UserAgent: "grok-shell/0.2.99 (linux; x86_64)", + }, cipher) + return adapter, encrypted +} + +func jsonResponse(status int, body string, request *http.Request) *http.Response { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + } +} diff --git a/backend/internal/infra/provider/console/account_identity.go b/backend/internal/infra/provider/console/account_identity.go new file mode 100644 index 0000000000000000000000000000000000000000..a508b120f5122c553859c90072e5c4d71ff73766 --- /dev/null +++ b/backend/internal/infra/provider/console/account_identity.go @@ -0,0 +1,21 @@ +package console + +import ( + "context" + "fmt" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/sessionidentity" +) + +// SyncAccountIdentity 通过 Grok Web Session 接口补充 Console SSO 身份。 +// 请求仍使用该 Console 凭据对应的代理、Cookie 与 Resin 身份。 +func (a *Adapter) SyncAccountIdentity(ctx context.Context, credential account.Credential) (provider.AccountIdentity, error) { + if credential.Provider != account.ProviderConsole || credential.AuthType != account.AuthTypeSSO { + return provider.AccountIdentity{}, fmt.Errorf("仅 Grok Console SSO 账号支持身份同步") + } + return sessionidentity.Fetch(ctx, a.config().SessionBaseURL, credential, a.egress, a.cipher) +} + +var _ provider.AccountIdentityAdapter = (*Adapter)(nil) diff --git a/backend/internal/infra/provider/console/adapter.go b/backend/internal/infra/provider/console/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..92d6fdfb5d3769457955abd1f2e29461b7e823f3 --- /dev/null +++ b/backend/internal/infra/provider/console/adapter.go @@ -0,0 +1,372 @@ +package console + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +type Config struct { + BaseURL string + SessionBaseURL string + TimeoutSeconds int +} + +type Adapter struct { + mu sync.RWMutex + cfg Config + egress *infraegress.Manager + cipher *security.Cipher +} + +func NewAdapter(cfg Config, egress *infraegress.Manager, cipher *security.Cipher) *Adapter { + return &Adapter{cfg: cfg, egress: egress, cipher: cipher} +} + +func (a *Adapter) Provider() account.Provider { return account.ProviderConsole } + +func (a *Adapter) UpdateConfig(cfg Config) { + a.mu.Lock() + a.cfg = cfg + a.mu.Unlock() +} + +func (a *Adapter) config() Config { + a.mu.RLock() + defer a.mu.RUnlock() + return a.cfg +} + +func (a *Adapter) ModelAliases() []provider.ModelAlias { return Aliases() } + +func (a *Adapter) QuotaMode(upstreamModel string) string { + if _, ok := Resolve(upstreamModel); ok { + return QuotaMode + } + return "" +} + +func (a *Adapter) TierOrder(string) []account.WebTier { return nil } + +func (a *Adapter) PricingModel(upstreamModel string) string { return upstreamModel } + +func (a *Adapter) ListModels(context.Context, account.Credential) ([]string, error) { + values := make([]string, 0, len(catalog)) + for _, spec := range catalog { + values = append(values, spec.UpstreamModel) + } + return values, nil +} + +func (a *Adapter) ParseImportedCredentials(data []byte) ([]provider.CredentialSeed, error) { + return parseImportedCredentials(data) +} + +func (a *Adapter) MarshalCredentials(values []provider.CredentialSeed) ([]byte, error) { + return marshalCredentials(values) +} + +func (a *Adapter) SyncQuota(_ context.Context, credential account.Credential) (provider.QuotaSnapshot, error) { + now := time.Now().UTC() + resetAt := now.Add(DefaultQuotaWindow * time.Second) + return provider.QuotaSnapshot{SyncedAt: now, Windows: []account.QuotaWindow{{ + AccountID: credential.ID, Mode: QuotaMode, Remaining: DefaultQuotaLimit, Total: DefaultQuotaLimit, + WindowSeconds: DefaultQuotaWindow, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceDefault, UpdatedAt: now, + }}}, nil +} + +func (a *Adapter) SyncQuotaMode(ctx context.Context, credential account.Credential, mode string) (account.QuotaWindow, error) { + if mode != QuotaMode { + return account.QuotaWindow{}, fmt.Errorf("不支持的 Console 额度模式 %q", mode) + } + snapshot, err := a.SyncQuota(ctx, credential) + if err != nil { + return account.QuotaWindow{}, err + } + return snapshot.Windows[0], nil +} + +func (a *Adapter) ForwardResponse(ctx context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + if request.Method != http.MethodPost || request.Path != "/responses" { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "Grok Console 仅支持 POST /responses"}}), nil + } + spec, ok := Resolve(request.Model) + if !ok { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "Console 模型不存在"}}), nil + } + token, err := a.cipher.Decrypt(request.Credential.EncryptedAccessToken) + if err != nil { + return nil, err + } + body := request.Body + var conversationOptions conversation.ResponseOptions + if request.NormalizeBody { + if request.Operation == conversation.OperationMessages { + body, conversationOptions, err = conversation.ConvertRequestWithOptions(body, request.Model, request.Operation) + } else { + body, err = conversation.ConvertRequest(body, request.Model, request.Operation) + } + if err == nil { + body, err = normalizeRequest(body, spec) + } + if err != nil { + return invalidConversationResponse(request.Operation, err), nil + } + } + cfg := a.config() + requestCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second) + lease, err := a.egress.AcquireCredential(requestCtx, egressdomain.ScopeConsole, request.Credential) + if err != nil { + cancel() + return nil, err + } + upstream, err := http.NewRequestWithContext(requestCtx, http.MethodPost, consoleEndpoint(cfg.BaseURL), bytes.NewReader(body)) + if err != nil { + lease.Release() + cancel() + return nil, err + } + applyHeaders(upstream, token, lease) + if request.Streaming { + upstream.Header.Set("Accept", "text/event-stream") + } + response, err := lease.DoDeferredForbidden(upstream) + if err != nil { + a.egress.FeedbackForScope(context.WithoutCancel(ctx), egressdomain.ScopeConsole, lease.NodeID, 0, err) + lease.Release() + cancel() + return nil, err + } + responseBodyTruncated := false + var rateLimit *provider.RateLimitMetadata + if response.StatusCode == http.StatusTooManyRequests { + responseBodyTruncated, rateLimit, err = normalizeRateLimitResponse(response) + if err != nil { + _ = response.Body.Close() + lease.Release() + cancel() + return nil, err + } + } + responseReleased := false + if response.StatusCode == http.StatusForbidden { + data, truncated, readErr := provider.ReadDiagnosticBody(response.Body) + _ = response.Body.Close() + if readErr != nil { + lease.Release() + cancel() + return nil, readErr + } + if !provider.IsDefinitiveAccountBlockBody(data) { + lease.InvalidateClearance() + a.egress.FeedbackForScope(context.WithoutCancel(ctx), egressdomain.ScopeConsole, lease.NodeID, response.StatusCode, nil) + } + lease.Release() + cancel() + responseReleased = true + responseBodyTruncated = responseBodyTruncated || truncated + response.Body = io.NopCloser(bytes.NewReader(data)) + response.ContentLength = int64(len(data)) + response.Header.Set("Content-Length", strconv.Itoa(len(data))) + if truncated { + response.Header.Set("X-Grok2API-Body-Truncated", "1") + } + } + release := func() { + if responseReleased { + return + } + a.egress.FeedbackForScope(context.WithoutCancel(ctx), egressdomain.ScopeConsole, lease.NodeID, response.StatusCode, nil) + lease.Release() + cancel() + } + if request.Operation == conversation.OperationChat || request.Operation == conversation.OperationMessages { + if request.Streaming && response.StatusCode >= 200 && response.StatusCode < 300 { + response.Body = conversation.ConvertResponseStreamWithOptions(response.Body, request.Operation, conversationOptions) + response.Header.Del("Content-Length") + response.Header.Set("Content-Type", "text/event-stream") + result := responseResult(response, &releaseBody{ReadCloser: response.Body, release: release}) + result.RateLimit = rateLimit + return result, nil + } + var data []byte + var readErr error + var diagnosticTruncated bool + if response.StatusCode >= 200 && response.StatusCode < 300 { + data, readErr = io.ReadAll(io.LimitReader(response.Body, (64<<20)+1)) + } else { + data, diagnosticTruncated, readErr = provider.ReadDiagnosticBody(response.Body) + diagnosticTruncated = diagnosticTruncated || responseBodyTruncated + } + _ = response.Body.Close() + release() + if readErr != nil { + return nil, readErr + } + if response.StatusCode >= 200 && response.StatusCode < 300 && len(data) > 64<<20 { + return nil, fmt.Errorf("Console 对话响应超过 64 MiB") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + diagnostic := &provider.DiagnosticResponse{StatusCode: response.StatusCode, Status: response.Status, Header: response.Header.Clone(), Body: data, BodyTruncated: diagnosticTruncated} + converted := normalizeConversationError(data, request.Operation, response.StatusCode) + response.Header.Set("Content-Length", strconv.Itoa(len(converted))) + response.Header.Set("Content-Type", "application/json") + result := responseResult(response, io.NopCloser(bytes.NewReader(converted))) + result.Diagnostic = diagnostic + result.RateLimit = rateLimit + return result, nil + } + converted, convertErr := conversation.ConvertResponseJSONWithOptions(data, request.Operation, conversationOptions) + if convertErr != nil { + return nil, convertErr + } + response.Header.Set("Content-Length", strconv.Itoa(len(converted))) + response.Header.Set("Content-Type", "application/json") + result := responseResult(response, io.NopCloser(bytes.NewReader(converted))) + result.RateLimit = rateLimit + return result, nil + } + result := responseResult(response, &releaseBody{ReadCloser: response.Body, release: release}) + result.RateLimit = rateLimit + return result, nil +} + +func normalizeConversationError(data []byte, operation string, status int) []byte { + var envelope struct { + Error json.RawMessage `json:"error"` + Message string `json:"message"` + } + if json.Unmarshal(data, &envelope) == nil && len(bytes.TrimSpace(envelope.Error)) > 0 && string(bytes.TrimSpace(envelope.Error)) != "null" { + if converted, err := conversation.ConvertResponseJSON(data, operation); err == nil { + return converted + } + } + message := strings.TrimSpace(envelope.Message) + if message == "" { + message = strings.TrimSpace(string(data)) + } + if message == "" { + message = http.StatusText(status) + } + if len(message) > 4096 { + message = message[:4096] + } + errorType := conversationErrorType(status, operation) + if operation == conversation.OperationMessages { + result, _ := json.Marshal(map[string]any{"type": "error", "error": map[string]any{"type": errorType, "message": message}}) + return result + } + result, _ := json.Marshal(map[string]any{"error": map[string]any{"type": errorType, "message": message}}) + return result +} + +func conversationErrorType(status int, operation string) string { + switch status { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return "invalid_request_error" + case http.StatusUnauthorized: + return "authentication_error" + case http.StatusForbidden: + return "permission_error" + case http.StatusNotFound: + return "not_found_error" + case http.StatusTooManyRequests: + return "rate_limit_error" + case http.StatusServiceUnavailable: + if operation == conversation.OperationMessages { + return "overloaded_error" + } + } + if operation == conversation.OperationMessages { + return "api_error" + } + return "server_error" +} + +func consoleEndpoint(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(baseURL, "/v1") { + return baseURL + "/responses" + } + return baseURL + "/v1/responses" +} + +func normalizeRateLimitResponse(response *http.Response) (bool, *provider.RateLimitMetadata, error) { + data, truncated, err := provider.ReadDiagnosticBody(response.Body) + if err != nil { + return truncated, nil, err + } + _ = response.Body.Close() + response.Body = io.NopCloser(bytes.NewReader(data)) + response.ContentLength = int64(len(data)) + response.Header.Set("Content-Length", strconv.Itoa(len(data))) + metadata := parseConsoleRateLimitMetadata(data) + if headerValue := response.Header.Get("Retry-After"); headerValue != "" { + if metadata != nil { + if retryAfter := parseConsoleRetryAfterHeader(headerValue, time.Now().UTC()); retryAfter > 0 { + metadata.RetryAfter = retryAfter + } + } + } else { + retryAfter := consoleRetryAfter(data) + if metadata != nil { + retryAfter = metadata.RetryAfter + } + if retryAfter > 0 { + response.Header.Set("Retry-After", strconv.FormatInt(int64(retryAfter/time.Second), 10)) + } + } + return truncated, metadata, nil +} + +func responseResult(response *http.Response, body io.ReadCloser) *provider.Response { + upstreamURL := "" + if response.Request != nil && response.Request.URL != nil { + upstreamURL = response.Request.URL.String() + } + return &provider.Response{ + StatusCode: response.StatusCode, Status: response.Status, Header: response.Header.Clone(), Body: body, QuotaUnits: 1, UpstreamURL: upstreamURL, + } +} + +func jsonProviderResponse(status int, value any) *provider.Response { + data, _ := json.Marshal(value) + return &provider.Response{ + StatusCode: status, Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), + Header: http.Header{"Content-Type": []string{"application/json"}, "Content-Length": []string{strconv.Itoa(len(data))}}, + Body: io.NopCloser(bytes.NewReader(data)), + } +} + +func invalidConversationResponse(operation string, err error) *provider.Response { + if operation == conversation.OperationMessages { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"type": "error", "error": map[string]any{"type": "invalid_request_error", "message": err.Error()}}) + } + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": err.Error()}}) +} + +type releaseBody struct { + io.ReadCloser + once sync.Once + release func() +} + +func (b *releaseBody) Close() error { + err := b.ReadCloser.Close() + b.once.Do(b.release) + return err +} diff --git a/backend/internal/infra/provider/console/catalog.go b/backend/internal/infra/provider/console/catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..3c71f7b3657d58cec8c25c008d15985bcc4b0383 --- /dev/null +++ b/backend/internal/infra/provider/console/catalog.go @@ -0,0 +1,82 @@ +package console + +import ( + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const ( + QuotaMode = "console" + DefaultQuotaLimit = 20 + DefaultQuotaWindow = 3600 +) + +type ModelSpec struct { + PublicID string + UpstreamModel string + SupportsReasoning bool + DefaultReasoningEffort string + MaxOutputTokens int + SearchTools bool +} + +var catalog = []ModelSpec{ + {PublicID: "grok-4.3", UpstreamModel: "grok-4.3", SupportsReasoning: true, DefaultReasoningEffort: "medium", MaxOutputTokens: 1_000_000, SearchTools: true}, + {PublicID: "grok-4.20-0309", UpstreamModel: "grok-4.20-0309", MaxOutputTokens: 1_000_000, SearchTools: true}, + {PublicID: "grok-4.20-0309-reasoning", UpstreamModel: "grok-4.20-0309-reasoning", SupportsReasoning: true, MaxOutputTokens: 1_000_000, SearchTools: true}, + {PublicID: "grok-4.20-0309-non-reasoning", UpstreamModel: "grok-4.20-0309-non-reasoning", MaxOutputTokens: 1_000_000, SearchTools: true}, + {PublicID: "grok-4.20-multi-agent-0309", UpstreamModel: "grok-4.20-multi-agent-0309", SupportsReasoning: true, DefaultReasoningEffort: "medium", MaxOutputTokens: 2_000_000, SearchTools: true}, + {PublicID: "grok-build-0.1", UpstreamModel: "grok-build-0.1", MaxOutputTokens: 256_000, SearchTools: true}, +} + +// Effort-suffixed aliases only include levels each model actually supports +// (see domain/model.SupportedReasoningEfforts). No blanket none/low/medium/high/xhigh/max template. +var aliases = []provider.ModelAlias{ + consoleAlias("grok-4.3-console", "grok-4.3", "grok-4.3", ""), + consoleAlias("grok-4.20-0309-console", "grok-4.20-0309", "grok-4.20-0309", ""), + consoleAlias("grok-4.20-0309-reasoning-console", "grok-4.20-0309-reasoning", "grok-4.20-0309-reasoning", ""), + consoleAlias("grok-4.20-0309-non-reasoning-console", "grok-4.20-0309-non-reasoning", "grok-4.20-0309-non-reasoning", ""), + consoleAlias("grok-4.20-multi-agent-console", "grok-4.20-multi-agent-0309", "grok-4.20-multi-agent-0309", ""), + consoleAlias("grok-build-console", "grok-build-0.1", "grok-build-0.1", ""), + consoleAlias("grok-4.3-low", "grok-4.3", "grok-4.3", "low"), + consoleAlias("grok-4.3-medium", "grok-4.3", "grok-4.3", "medium"), + consoleAlias("grok-4.3-high", "grok-4.3", "grok-4.3", "high"), + consoleAlias("grok-4.20-multi-agent-low", "grok-4.20-multi-agent-0309", "grok-4.20-multi-agent-0309", "low"), + consoleAlias("grok-4.20-multi-agent-medium", "grok-4.20-multi-agent-0309", "grok-4.20-multi-agent-0309", "medium"), + consoleAlias("grok-4.20-multi-agent-high", "grok-4.20-multi-agent-0309", "grok-4.20-multi-agent-0309", "high"), + consoleAlias("grok-4.20-multi-agent-xhigh", "grok-4.20-multi-agent-0309", "grok-4.20-multi-agent-0309", "xhigh"), +} + +func consoleAlias(alias, publicModel, upstreamModel, effort string) provider.ModelAlias { + canonical, _ := modeldomain.NormalizePublicID(account.ProviderConsole, publicModel) + return provider.ModelAlias{ + Alias: alias, PublicModel: canonical, Provider: account.ProviderConsole, + UpstreamModel: upstreamModel, ReasoningEffort: effort, + } +} + +func Catalog() []ModelSpec { return append([]ModelSpec(nil), catalog...) } + +func Routes() []modeldomain.Route { + values := make([]modeldomain.Route, 0, len(catalog)) + for _, spec := range catalog { + publicID, _ := modeldomain.NormalizePublicID(account.ProviderConsole, spec.PublicID) + values = append(values, modeldomain.Route{ + PublicID: publicID, Provider: account.ProviderConsole, UpstreamModel: spec.UpstreamModel, + Capability: modeldomain.CapabilityResponses, Enabled: true, + }) + } + return values +} + +func Resolve(upstreamModel string) (ModelSpec, bool) { + for _, spec := range catalog { + if spec.UpstreamModel == upstreamModel { + return spec, true + } + } + return ModelSpec{}, false +} + +func Aliases() []provider.ModelAlias { return append([]provider.ModelAlias(nil), aliases...) } diff --git a/backend/internal/infra/provider/console/console_test.go b/backend/internal/infra/provider/console/console_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2eefd92889569021df6f82125cac8f710db2dc75 --- /dev/null +++ b/backend/internal/infra/provider/console/console_test.go @@ -0,0 +1,631 @@ +package console + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestCatalogContainsAllConsoleModelsAndAliases(t *testing.T) { + expected := map[string]string{ + "Console/grok-4.3": "grok-4.3", + "Console/grok-4.20-0309": "grok-4.20-0309", + "Console/grok-4.20-0309-reasoning": "grok-4.20-0309-reasoning", + "Console/grok-4.20-0309-non-reasoning": "grok-4.20-0309-non-reasoning", + "Console/grok-4.20-multi-agent-0309": "grok-4.20-multi-agent-0309", + "Console/grok-build-0.1": "grok-build-0.1", + } + routes := Routes() + if len(routes) != len(expected) { + t.Fatalf("routes = %d, want %d", len(routes), len(expected)) + } + for _, route := range routes { + if route.Provider != account.ProviderConsole || route.Capability != modeldomain.CapabilityResponses || !route.Enabled { + t.Fatalf("invalid route: %#v", route) + } + if expected[route.PublicID] != route.UpstreamModel { + t.Fatalf("route %q = %q", route.PublicID, route.UpstreamModel) + } + } + aliases := Aliases() + if len(aliases) != 13 { + t.Fatalf("aliases = %d, want 13", len(aliases)) + } + registry := provider.NewRegistry(NewAdapter(Config{}, nil, nil)) + if registry.SupportsStoredResponses(account.ProviderConsole) { + t.Fatal("console must not advertise stored Responses support") + } + for _, name := range []string{ + "grok-4.3-console", "grok-4.20-0309-console", "grok-4.20-0309-reasoning-console", + "grok-4.20-0309-non-reasoning-console", "grok-4.20-multi-agent-console", "grok-build-console", + "grok-4.3-low", "grok-4.3-medium", "grok-4.3-high", + "grok-4.20-multi-agent-low", "grok-4.20-multi-agent-medium", "grok-4.20-multi-agent-high", "grok-4.20-multi-agent-xhigh", + } { + alias, ok := registry.ResolveModelAlias(name) + if !ok { + t.Fatalf("alias %q missing", name) + } + if !strings.HasPrefix(alias.PublicModel, "Console/") { + t.Fatalf("alias %q targets non-canonical model %q", name, alias.PublicModel) + } + } +} + +func TestSyncAccountIdentityUsesWebSessionWithConsoleCredential(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/auth/session" || request.Method != http.MethodGet { + http.NotFound(writer, request) + return + } + if request.Header.Get("User-Agent") != infraegress.DefaultUserAgent { + t.Errorf("user agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("Cookie") != "sso=test-sso; sso-rw=test-sso; cf_clearance=clear" { + t.Errorf("cookie = %q", request.Header.Get("Cookie")) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"user":{"sub":"console-user","email":"console@example.com","teamId":"team-1"}}`)) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + token, _ := cipher.Encrypt("test-sso") + cookies, _ := cipher.Encrypt("cf_clearance=clear") + adapter := NewAdapter(Config{SessionBaseURL: server.URL}, infraegress.NewManager(consoleEgressRepositoryStub{}, cipher), cipher) + identity, err := adapter.SyncAccountIdentity(context.Background(), account.Credential{ + ID: 1, Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, + EncryptedAccessToken: token, EncryptedCloudflareCookie: cookies, + }) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "console-user" || identity.Email != "console@example.com" || identity.TeamID != "team-1" { + t.Fatalf("identity = %#v", identity) + } +} + +func TestNormalizeRequestAppliesConsoleContract(t *testing.T) { + spec, ok := Resolve("grok-4.3") + if !ok { + t.Fatal("grok-4.3 missing") + } + body, err := normalizeRequest([]byte(`{ + "model":"grok-4.3", + "metadata":{"private":"value"}, + "reasoning":{"effort":"xhigh"}, + "tools":[{"type":"web_search","custom":true},{"type":"function","name":"lookup","parameters":{"type":"object"}}] + }`), spec) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload["model"] != "grok-4.3" || payload["store"] != false || payload["metadata"] != nil { + t.Fatalf("payload = %#v", payload) + } + if payload["max_output_tokens"] != float64(1_000_000) { + t.Fatalf("max_output_tokens = %#v", payload["max_output_tokens"]) + } + reasoning, _ := payload["reasoning"].(map[string]any) + if reasoning["effort"] != "xhigh" { + t.Fatalf("reasoning = %#v", reasoning) + } + include, _ := payload["include"].([]any) + if len(include) != 1 || include[0] != "reasoning.encrypted_content" { + t.Fatalf("include = %#v", include) + } + tools, _ := payload["tools"].([]any) + if len(tools) != 3 || toolIdentity(tools[0]) != "web_search" || toolIdentity(tools[1]) != "x_search" || toolIdentity(tools[2]) != "function:lookup" { + t.Fatalf("tools = %#v", tools) + } + webSearch, _ := tools[0].(map[string]any) + if webSearch["custom"] != nil || webSearch["enable_image_understanding"] != true { + t.Fatalf("web_search = %#v", webSearch) + } + stateless, err := normalizeRequest([]byte(`{"model":"grok-4.3","store":true,"previous_response_id":"resp_1","service_tier":"priority","input":"hello"}`), spec) + if err != nil { + t.Fatal(err) + } + var statelessPayload map[string]any + if json.Unmarshal(stateless, &statelessPayload) != nil || statelessPayload["store"] != false || statelessPayload["previous_response_id"] != nil || statelessPayload["service_tier"] != nil { + t.Fatalf("stateless payload = %#v", statelessPayload) + } +} + +func TestNormalizeRequestAppliesConsoleCompatibilityBoundary(t *testing.T) { + spec, ok := Resolve("grok-4.20-0309") + if !ok { + t.Fatal("grok-4.20-0309 missing") + } + body, err := normalizeRequest([]byte(`{ + "model":"public", + "response_format":{"type":"json_schema","json_schema":{"name":"answer","strict":true,"schema":{"type":"object"}}}, + "input":[ + {"type":"reasoning","content":[{"text":"prior thought"}]}, + {"type":"message","role":"user","content":[ + {"type":"output_text","text":"hello"}, + {"type":"image_url","image_url":{"url":"https://example.com/image.png"}} + ]} + ], + "tools":[ + {"type":"namespace","name":"crm","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}, + {"type":"web_search","external_web_access":true} + ], + "tool_choice":"required" + }`), spec) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload["response_format"] != nil || payload["reasoning"] != nil || payload["tool_choice"] != "auto" { + t.Fatalf("payload boundary = %#v", payload) + } + include, _ := payload["include"].([]any) + if len(include) != 1 || include[0] != "reasoning.encrypted_content" { + t.Fatalf("include = %#v", include) + } + text, _ := payload["text"].(map[string]any) + format, _ := text["format"].(map[string]any) + if format["type"] != "json_schema" || format["name"] != "answer" || format["json_schema"] != nil { + t.Fatalf("text.format = %#v", format) + } + input, _ := payload["input"].([]any) + reasoning := input[0].(map[string]any)["content"].([]any)[0].(map[string]any) + if reasoning["type"] != "reasoning_text" { + t.Fatalf("reasoning content = %#v", reasoning) + } + parts := input[1].(map[string]any)["content"].([]any) + if parts[0].(map[string]any)["type"] != "input_text" || parts[1].(map[string]any)["type"] != "input_image" || parts[1].(map[string]any)["image_url"] != "https://example.com/image.png" { + t.Fatalf("message parts = %#v", parts) + } + tools, _ := payload["tools"].([]any) + if len(tools) != 2 || toolIdentity(tools[0]) != "web_search" || toolIdentity(tools[1]) != "x_search" { + t.Fatalf("sanitized tools = %#v", tools) + } + if tools[0].(map[string]any)["external_web_access"] != nil { + t.Fatalf("unsupported web search controls leaked: %#v", tools[0]) + } +} + +func TestNormalizeReasoningPreservesReferenceEfforts(t *testing.T) { + for input, want := range map[string]string{ + "none": "none", "minimal": "low", "low": "low", "medium": "medium", + "high": "high", "xhigh": "xhigh", "max": "xhigh", + } { + if got := normalizeEffort(input); got != want { + t.Fatalf("normalizeEffort(%q) = %q, want %q", input, got, want) + } + } +} + +func TestNormalizeRequestPreservesGrok420ReasoningEffort(t *testing.T) { + spec, ok := Resolve("grok-4.20-0309-reasoning") + if !ok { + t.Fatal("grok-4.20-0309-reasoning missing") + } + body, err := normalizeRequest([]byte(`{ + "model":"grok-4.20-0309-reasoning", + "input":"hello", + "reasoning":{"effort":"low"} + }`), spec) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + reasoning, _ := payload["reasoning"].(map[string]any) + if reasoning["effort"] != "low" { + t.Fatalf("reasoning = %#v", reasoning) + } + + withoutEffort, err := normalizeRequest([]byte(`{ + "model":"grok-4.20-0309-reasoning", + "input":"hello" + }`), spec) + if err != nil { + t.Fatal(err) + } + payload = nil + if err := json.Unmarshal(withoutEffort, &payload); err != nil { + t.Fatal(err) + } + if payload["reasoning"] != nil { + t.Fatalf("base model request should retain the upstream default: %#v", payload) + } +} + +func TestConsoleImportAcceptsJSONPlainTextAndCookieFormat(t *testing.T) { + values, err := parseImportedCredentials([]byte("sso=token-one; sso-rw=token-one\ntoken-two\ntoken-two\n")) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].AccessToken != "token-one" || values[1].AccessToken != "token-two" { + t.Fatalf("plain values = %#v", values) + } + values, err = parseImportedCredentials([]byte(`{"provider":"grok_console","accounts":[{"name":"console-a","sso_token":"token-a","cloudflare_cookies":"cf_clearance=abc"}]}`)) + if err != nil { + t.Fatal(err) + } + if len(values) != 1 || values[0].Provider != account.ProviderConsole || values[0].AuthType != account.AuthTypeSSO || values[0].Name != "console-a" || values[0].AccessToken != "token-a" { + t.Fatalf("json values = %#v", values) + } + if values[0].CloudflareCookies != "cf_clearance=abc" { + t.Fatalf("cloudflare cookies = %q", values[0].CloudflareCookies) + } +} + +func TestConsoleImportAcceptsJSONLines(t *testing.T) { + data := []byte("\xef\xbb\xbf{\"name\":\"first\",\"sso_token\":\"token-one\",\"email\":\"one@example.com\"}\r\n\r\n" + + "{\"name\":\"second\",\"token\":\"token-two\",\"user_id\":\"user-two\"}\r\n") + values, err := parseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].AccessToken != "token-one" || values[0].Email != "one@example.com" || values[1].AccessToken != "token-two" || values[1].UserID != "user-two" { + t.Fatalf("credentials = %#v", values) + } +} + +func TestConsoleRetryAfterParsesCompoundDuration(t *testing.T) { + if value := consoleRetryAfter([]byte(`Rate limit reached. Resets in: 1h 2m 3s`)); value != time.Hour+2*time.Minute+3*time.Second { + t.Fatalf("retry after = %s", value) + } + if value := consoleRetryAfter([]byte(`ordinary error`)); value != 0 { + t.Fatalf("ordinary retry after = %s", value) + } +} + +func TestNormalizeRateLimitResponsePrefersRetryAfterHeader(t *testing.T) { + response := &http.Response{ + Header: http.Header{"Retry-After": {"17"}}, + Body: io.NopCloser(strings.NewReader("Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.20-multi-agent-0309. Requests per Second (actual/limit): 2/2")), + } + _, metadata, err := normalizeRateLimitResponse(response) + if err != nil { + t.Fatal(err) + } + if metadata == nil || metadata.RetryAfter != 17*time.Second { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestParseConsoleRateLimitMetadataRPS(t *testing.T) { + metadata := parseConsoleRateLimitMetadata([]byte(`{"code":"resource-exhausted","error":"Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.3. Your team's rate limit is — Requests per Second (actual/limit): 2/2."}`)) + if metadata == nil { + t.Fatal("metadata is nil") + } + if metadata.Scope != provider.RateLimitScopeRPS || metadata.Actual != 2 || metadata.Limit != 2 || metadata.RetryAfter != 2*time.Second { + t.Fatalf("metadata = %#v", metadata) + } + if metadata.TeamID != "00000000-0000-0000-0000-000000000013" || metadata.Model != "grok-4.3" { + t.Fatalf("team/model = %q/%q", metadata.TeamID, metadata.Model) + } +} + +func TestParseConsoleRateLimitMetadataRPM(t *testing.T) { + body := []byte(`{"error":{"message":"Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.20-multi-agent-0309. Requests per Minute (actual/limit): 101/60. Resets in: 3m 4s"}}`) + metadata := parseConsoleRateLimitMetadata(body) + if metadata == nil { + t.Fatal("metadata is nil") + } + if metadata.Scope != provider.RateLimitScopeRPM || metadata.Actual != 101 || metadata.Limit != 60 || metadata.RetryAfter != 3*time.Minute+4*time.Second { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestParseConsoleRateLimitMetadataOrdinary429(t *testing.T) { + if metadata := parseConsoleRateLimitMetadata([]byte(`Rate limit reached. Resets in: 1h`)); metadata != nil { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestParseConsoleRateLimitMetadataExtractsTeamAndModel(t *testing.T) { + metadata := parseConsoleRateLimitMetadata([]byte(`{"message":"Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.20-multi-agent-0309. Requests per Second (actual/limit): 3/2. Resets in: 1s"}`)) + if metadata == nil { + t.Fatal("metadata is nil") + } + if metadata.TeamID != "00000000-0000-0000-0000-000000000013" || metadata.Model != "grok-4.20-multi-agent-0309" { + t.Fatalf("team/model = %q/%q", metadata.TeamID, metadata.Model) + } + if metadata.RetryAfter != 2*time.Second { + t.Fatalf("retry after = %s", metadata.RetryAfter) + } +} + +func TestAdapterAttachesConsoleRateLimitMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain") + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(writer, "Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.20-multi-agent-0309. Requests per Second (actual/limit): 2/2") + })) + defer server.Close() + adapter, credential := newConsoleTestAdapter(t, server.URL) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.20-multi-agent-0309", + Operation: "responses", NormalizeBody: true, Body: []byte(`{"model":"grok-4.20-multi-agent-0309","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.RateLimit == nil { + t.Fatal("rate limit metadata is nil") + } + if response.RateLimit.Scope != provider.RateLimitScopeRPS || response.RateLimit.TeamID != "00000000-0000-0000-0000-000000000013" || response.RateLimit.Model != "grok-4.20-multi-agent-0309" { + t.Fatalf("rate limit metadata = %#v", response.RateLimit) + } + if response.Header.Get("Retry-After") != "2" { + t.Fatalf("retry-after = %q", response.Header.Get("Retry-After")) + } +} + +func TestAdapterDoesNotPenalizeEgressForBlockedAccount(t *testing.T) { + tests := []struct { + name string + body string + wantUpdates int + }{ + {name: "blocked account", body: `{"code":"unauthorized:blocked-user","error":"User is blocked"}`, wantUpdates: 0}, + {name: "anti-bot rejection", body: `{"error":{"code":7,"message":"Request rejected by anti-bot rules."}}`, wantUpdates: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(writer, test.body) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + repository := &recordingConsoleEgressRepository{node: egressdomain.Node{ + ID: 1, Name: "console", Scope: egressdomain.ScopeConsole, Enabled: true, Health: 1, + }} + adapter := NewAdapter(Config{BaseURL: server.URL, TimeoutSeconds: 5}, infraegress.NewManager(repository, cipher), cipher) + credential := account.Credential{ID: 1, Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, EncryptedAccessToken: encrypted} + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.3", + Operation: conversation.OperationResponses, NormalizeBody: true, Body: []byte(`{"model":"grok-4.3","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusForbidden || string(body) != test.body { + t.Fatalf("status=%d body=%s", response.StatusCode, body) + } + if updates := repository.UpdateCount(); updates != test.wantUpdates { + t.Fatalf("egress updates = %d, want %d", updates, test.wantUpdates) + } + }) + } +} + +func TestAdapterForwardsConsoleHeadersAndNormalizedBody(t *testing.T) { + var received map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/responses" || request.Method != http.MethodPost { + t.Errorf("request = %s %s", request.Method, request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer anonymous" || request.Header.Get("x-cluster") != "https://us-east-1.api.x.ai" || request.Header.Get("Accept") != "*/*" || request.Header.Get("Priority") != "u=1, i" { + t.Errorf("headers = %#v", request.Header) + } + if request.Header.Get("User-Agent") != infraegress.DefaultUserAgent { + t.Errorf("user-agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("Sec-Ch-Ua") != `"Google Chrome";v="146", "Chromium";v="146", "Not(A:Brand";v="24"` || + request.Header.Get("Sec-Ch-Ua-Mobile") != "?0" || request.Header.Get("Sec-Ch-Ua-Platform") != `"macOS"` || + request.Header.Get("Sec-Ch-Ua-Arch") != "x86" || request.Header.Get("Sec-Ch-Ua-Bitness") != "64" { + t.Errorf("client hints = %#v", request.Header) + } + cookie := request.Header.Get("Cookie") + if !strings.Contains(cookie, "sso=test-sso") || !strings.Contains(cookie, "sso-rw=test-sso") { + t.Errorf("cookie = %q", cookie) + } + if err := json.NewDecoder(request.Body).Decode(&received); err != nil { + t.Errorf("decode request: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"id":"resp_console","object":"response","status":"completed","output":[]}`) + })) + defer server.Close() + + adapter, credential := newConsoleTestAdapter(t, server.URL) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, + Method: http.MethodPost, Path: "/responses", Model: "grok-4.3", Operation: "responses", NormalizeBody: true, + Body: []byte(`{"model":"grok-4.3","input":"hello","metadata":{"drop":true}}`), + }) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if err := response.Body.Close(); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || !bytes.Contains(data, []byte(`"resp_console"`)) { + t.Fatalf("status=%d body=%s", response.StatusCode, data) + } + if received["model"] != "grok-4.3" || received["store"] != false || received["metadata"] != nil { + t.Fatalf("received = %#v", received) + } +} + +func TestApplyChromiumClientHintsSkipsNonChromiumUserAgent(t *testing.T) { + header := make(http.Header) + applyChromiumClientHints(header, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/18.0 Safari/605.1.15") + for name := range header { + if strings.HasPrefix(http.CanonicalHeaderKey(name), "Sec-Ch-Ua") { + t.Fatalf("unexpected client hint %q", name) + } + } +} + +func TestAdapterPreservesConversationRateLimitStatusAndProtocol(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain") + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(writer, "Rate limit reached. Resets in: 1h 2m 3s") + })) + defer server.Close() + adapter, credential := newConsoleTestAdapter(t, server.URL) + tests := []struct { + operation string + body string + }{ + {operation: conversation.OperationChat, body: `{"model":"grok-4.3","messages":[{"role":"user","content":"hello"}],"stream":true}`}, + {operation: conversation.OperationMessages, body: `{"model":"grok-4.3","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"stream":true}`}, + } + for _, test := range tests { + t.Run(test.operation, func(t *testing.T) { + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.3", + Operation: test.operation, NormalizeBody: true, Streaming: true, Body: []byte(test.body), + }) + if err != nil { + t.Fatal(err) + } + data, readErr := io.ReadAll(response.Body) + if readErr != nil { + t.Fatal(readErr) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusTooManyRequests || response.Header.Get("Retry-After") != "3723" { + t.Fatalf("status=%d retry-after=%q body=%s", response.StatusCode, response.Header.Get("Retry-After"), data) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("invalid compatible error JSON: %v, body=%s", err, data) + } + if test.operation == conversation.OperationMessages && payload["type"] != "error" { + t.Fatalf("messages error = %#v", payload) + } + errorObject, _ := payload["error"].(map[string]any) + if errorObject["type"] != "rate_limit_error" || !strings.Contains(errorObject["message"].(string), "Rate limit reached") { + t.Fatalf("compatible error = %#v", payload) + } + }) + } +} + +func newConsoleTestAdapter(t *testing.T, baseURL string) (*Adapter, account.Credential) { + t.Helper() + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: baseURL, TimeoutSeconds: 5}, infraegress.NewManager(consoleEgressRepositoryStub{}, cipher), cipher) + credential := account.Credential{ID: 1, Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, EncryptedAccessToken: encrypted} + return adapter, credential +} + +type consoleEgressRepositoryStub struct{} + +func (consoleEgressRepositoryStub) ListEgressNodes(context.Context, egressdomain.Scope, repository.SortQuery) ([]egressdomain.Node, error) { + return nil, nil +} + +func (consoleEgressRepositoryStub) GetEgressNode(context.Context, uint64) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("not found") +} + +func (consoleEgressRepositoryStub) CreateEgressNode(context.Context, egressdomain.Node) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("unsupported") +} + +func (consoleEgressRepositoryStub) UpdateEgressNode(context.Context, egressdomain.Node) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("unsupported") +} + +func (consoleEgressRepositoryStub) DeleteEgressNode(context.Context, uint64) error { + return errors.New("unsupported") +} + +type recordingConsoleEgressRepository struct { + mu sync.Mutex + node egressdomain.Node + updates int +} + +func (r *recordingConsoleEgressRepository) ListEgressNodes(_ context.Context, scope egressdomain.Scope, _ repository.SortQuery) ([]egressdomain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.node.Scope != scope { + return nil, nil + } + return []egressdomain.Node{r.node}, nil +} + +func (r *recordingConsoleEgressRepository) GetEgressNode(context.Context, uint64) (egressdomain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.node, nil +} + +func (r *recordingConsoleEgressRepository) CreateEgressNode(context.Context, egressdomain.Node) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("unsupported") +} + +func (r *recordingConsoleEgressRepository) UpdateEgressNode(_ context.Context, value egressdomain.Node) (egressdomain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.node = value + r.updates++ + return value, nil +} + +func (r *recordingConsoleEgressRepository) DeleteEgressNode(context.Context, uint64) error { + return errors.New("unsupported") +} + +func (r *recordingConsoleEgressRepository) UpdateCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.updates +} diff --git a/backend/internal/infra/provider/console/definition.go b/backend/internal/infra/provider/console/definition.go new file mode 100644 index 0000000000000000000000000000000000000000..d77eafa18d47259008ab91fa0714072e13068882 --- /dev/null +++ b/backend/internal/infra/provider/console/definition.go @@ -0,0 +1,29 @@ +package console + +import ( + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +// Definition declares the stable Grok Console capability boundary; Console retains stateless Responses semantics. +func (a *Adapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderConsole, + ModelNamespace: account.ProviderConsole.ModelNamespace(), + ModelCatalog: provider.ModelCatalogStatic, + ModelCapabilities: []modeldomain.Capability{modeldomain.CapabilityResponses}, + Quota: provider.QuotaLocalWindow, + Credential: provider.CredentialSurface{ + AuthType: account.AuthTypeSSO, Import: true, + }, + Conversation: provider.ConversationSurface{ + Responses: true, ChatCompletions: true, Messages: true, + }, + // Console shares the browser/clearance surface with Web. A 403 is + // therefore normally an egress challenge, not proof that the SSO + // credential is invalid; the gateway must retry after rebuilding the + // browser session instead of cooling the account. + Inference: provider.InferencePolicy{Usage: provider.UsageUpstream, RetryForbiddenAsEgress: true}, + } +} diff --git a/backend/internal/infra/provider/console/headers.go b/backend/internal/infra/provider/console/headers.go new file mode 100644 index 0000000000000000000000000000000000000000..ea943d700b61b52ca46169f554bacca815a77f18 --- /dev/null +++ b/backend/internal/infra/provider/console/headers.go @@ -0,0 +1,38 @@ +package console + +import ( + "net/http" + "strings" + + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider/browserheaders" +) + +func applyHeaders(request *http.Request, token string, lease *infraegress.Lease) { + userAgent := strings.TrimSpace(lease.UserAgent) + if userAgent == "" { + userAgent = infraegress.DefaultUserAgent + } + request.Header.Set("Accept", "*/*") + request.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + request.Header.Set("Authorization", "Bearer anonymous") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Cookie", infraegress.BuildSSOCookie(token, lease.CFCookies)) + request.Header.Set("Origin", "https://console.x.ai") + request.Header.Set("Referer", "https://console.x.ai/") + request.Header.Set("Sec-Fetch-Dest", "empty") + request.Header.Set("Sec-Fetch-Mode", "cors") + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.Header.Set("Priority", "u=1, i") + request.Header.Set("User-Agent", userAgent) + request.Header.Set("x-cluster", "https://us-east-1.api.x.ai") + applyChromiumClientHints(request.Header, userAgent) +} + +// applyChromiumClientHints keeps the HTTP headers aligned with the Chromium +// TLS profile used by the Console transport. Non-Chromium User-Agents do not +// receive synthetic hints, avoiding contradictory browser fingerprints. +func applyChromiumClientHints(header http.Header, userAgent string) { + browserheaders.ApplyChromiumClientHints(header, userAgent) +} diff --git a/backend/internal/infra/provider/console/import.go b/backend/internal/infra/provider/console/import.go new file mode 100644 index 0000000000000000000000000000000000000000..c003353f5b772ff8bc6a411f59d6347a0b84872a --- /dev/null +++ b/backend/internal/infra/provider/console/import.go @@ -0,0 +1,140 @@ +package console + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const ( + maxImportAccounts = 10000 + maxSSOTokenBytes = 16 << 10 +) + +type importDocument struct { + Provider string `json:"provider"` + Accounts []importEntry `json:"accounts"` +} + +type importEntry struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserID string `json:"user_id,omitempty"` + SSOToken string `json:"sso_token"` + Token string `json:"token"` + CloudflareCookies string `json:"cloudflare_cookies"` +} + +func parseImportedCredentials(data []byte) ([]provider.CredentialSeed, error) { + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return nil, fmt.Errorf("账号文件中没有 Grok Console 账号") + } + if !strings.HasPrefix(trimmed, "{") { + return parsePlainTextCredentials(trimmed) + } + entries, err := provider.DecodeCredentialJSONEntries[importEntry](data, string(account.ProviderConsole), maxImportAccounts) + if err != nil { + return nil, fmt.Errorf("解析 Grok Console 账号 JSON: %w", err) + } + if len(entries) == 0 { + return nil, fmt.Errorf("账号文件中没有 Grok Console 账号") + } + seen := make(map[string]struct{}, len(entries)) + result := make([]provider.CredentialSeed, 0, len(entries)) + for index, entry := range entries { + token := sanitizeSSOToken(firstNonEmpty(entry.SSOToken, entry.Token)) + if token == "" { + return nil, fmt.Errorf("第 %d 个账号缺少 sso_token", index+1) + } + if len(token) > maxSSOTokenBytes { + return nil, fmt.Errorf("第 %d 个账号的 sso_token 超过 16 KiB", index+1) + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + name := strings.TrimSpace(entry.Name) + if name == "" { + name = "Grok Console " + security.HashToken(token)[:8] + } + seed := credentialSeed(name, token) + seed.Email = strings.TrimSpace(entry.Email) + seed.UserID = strings.TrimSpace(entry.UserID) + seed.CloudflareCookies = entry.CloudflareCookies + result = append(result, seed) + } + return result, nil +} + +func parsePlainTextCredentials(value string) ([]provider.CredentialSeed, error) { + lines := strings.Split(value, "\n") + seen := make(map[string]struct{}, len(lines)) + result := make([]provider.CredentialSeed, 0, len(lines)) + for index, line := range lines { + token := sanitizeSSOToken(line) + if token == "" { + continue + } + if len(token) > maxSSOTokenBytes { + return nil, fmt.Errorf("第 %d 行的 sso token 超过 16 KiB", index+1) + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + result = append(result, credentialSeed("Grok Console "+security.HashToken(token)[:8], token)) + if len(result) > maxImportAccounts { + return nil, provider.ErrCredentialLimit + } + } + if len(result) == 0 { + return nil, fmt.Errorf("文本中没有有效的 sso token") + } + return result, nil +} + +func credentialSeed(name, token string) provider.CredentialSeed { + return provider.CredentialSeed{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: name, + SourceKey: "console-sso:" + security.HashToken(token), AccessToken: token, + } +} + +func marshalCredentials(values []provider.CredentialSeed) ([]byte, error) { + document := importDocument{Provider: string(account.ProviderConsole), Accounts: make([]importEntry, 0, len(values))} + for _, value := range values { + document.Accounts = append(document.Accounts, importEntry{Name: value.Name, Email: value.Email, UserID: value.UserID, SSOToken: value.AccessToken, CloudflareCookies: value.CloudflareCookies}) + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func sanitizeSSOToken(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "sso=") { + value = strings.TrimSpace(value[len("sso="):]) + } + if token, _, found := strings.Cut(value, ";"); found { + value = token + } + return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(value)) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/infra/provider/console/normalize.go b/backend/internal/infra/provider/console/normalize.go new file mode 100644 index 0000000000000000000000000000000000000000..3b1ea11b543eef7741d096dfac13e50b34a1d241 --- /dev/null +++ b/backend/internal/infra/provider/console/normalize.go @@ -0,0 +1,379 @@ +package console + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +var ( + resetDurationPattern = regexp.MustCompile(`(?i)(\d+)\s*([dhms])`) +) + +func normalizeRequest(body []byte, spec ModelSpec) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("解析 Console Responses 请求: %w", err) + } + payload["model"] = spec.UpstreamModel + // Console is stateless. Replay the supplied input and silently discard + // stateful client hints instead of rejecting an otherwise valid request. + payload["store"] = false + for _, field := range []string{ + "metadata", "previous_response_id", "service_tier", "prompt_cache_key", + "background", "conversation", + } { + delete(payload, field) + } + normalizeConsoleResponseFormat(payload) + patchConsoleInput(payload) + if _, exists := payload["max_output_tokens"]; !exists && spec.MaxOutputTokens > 0 { + payload["max_output_tokens"] = spec.MaxOutputTokens + } + normalizeReasoning(payload, spec) + ensureReasoningInclude(payload) + retainedClientTools := normalizeConsoleTools(payload) + if spec.SearchTools { + mergeSearchTools(payload) + } + normalizeConsoleToolChoice(payload, retainedClientTools) + return json.Marshal(payload) +} + +func normalizeConsoleResponseFormat(payload map[string]any) { + raw, exists := payload["response_format"] + if !exists { + return + } + delete(payload, "response_format") + format, ok := raw.(map[string]any) + if !ok { + return + } + if typeName, _ := format["type"].(string); typeName == "json_schema" { + if nested, ok := format["json_schema"].(map[string]any); ok { + flattened := map[string]any{"type": "json_schema"} + for key, value := range nested { + if key != "type" { + flattened[key] = value + } + } + format = flattened + } + } + text, _ := payload["text"].(map[string]any) + if text == nil { + text = make(map[string]any) + } + if _, exists := text["format"]; !exists { + text["format"] = format + } + payload["text"] = text +} + +func patchConsoleInput(payload map[string]any) { + items, ok := payload["input"].([]any) + if !ok { + return + } + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + if item["type"] == "reasoning" { + patchConsoleReasoningContent(item) + continue + } + content, ok := item["content"].([]any) + if !ok { + continue + } + for _, rawPart := range content { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + typeName, _ := part["type"].(string) + switch typeName { + case "text", "output_text": + part["type"] = "input_text" + case "image_url": + if image, ok := part["image_url"].(map[string]any); ok { + if url, _ := image["url"].(string); strings.TrimSpace(url) != "" { + part["type"] = "input_image" + part["image_url"] = url + } + } + } + } + } +} + +func patchConsoleReasoningContent(item map[string]any) { + content, ok := item["content"].([]any) + if !ok { + return + } + for _, rawPart := range content { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + if _, exists := part["type"]; !exists { + if _, hasText := part["text"]; hasText { + part["type"] = "reasoning_text" + } + } + } +} + +func normalizeReasoning(payload map[string]any, spec ModelSpec) { + if !spec.SupportsReasoning { + delete(payload, "reasoning") + return + } + reasoning, _ := payload["reasoning"].(map[string]any) + if reasoning == nil { + if spec.DefaultReasoningEffort == "" { + delete(payload, "reasoning") + return + } + reasoning = make(map[string]any) + } + effort, _ := reasoning["effort"].(string) + effort = normalizeEffort(effort) + if effort == "" { + effort = spec.DefaultReasoningEffort + } + if effort != "" { + reasoning["effort"] = effort + } + payload["reasoning"] = reasoning +} + +func normalizeEffort(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "none": + return "none" + case "minimal", "low": + return "low" + case "medium": + return "medium" + case "high": + return "high" + case "xhigh", "max": + return "xhigh" + default: + return "" + } +} + +func ensureReasoningInclude(payload map[string]any) { + value, _ := payload["include"].([]any) + seen := make(map[string]struct{}) + result := make([]any, 0) + for _, item := range value { + name, ok := item.(string) + if !ok || strings.TrimSpace(name) == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + result = append(result, name) + } + if _, exists := seen["reasoning.encrypted_content"]; !exists { + result = append(result, "reasoning.encrypted_content") + } + payload["include"] = result +} + +func normalizeConsoleTools(payload map[string]any) bool { + value, exists := payload["tools"] + if !exists || value == nil { + return false + } + tools, ok := value.([]any) + if !ok { + delete(payload, "tools") + delete(payload, "tool_choice") + return false + } + result := make([]any, 0, len(tools)) + retainedClientTools := false + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + typeName, _ := tool["type"].(string) + switch strings.ToLower(strings.TrimSpace(typeName)) { + case "web_search", "web_search_preview", "web_search_preview_2025_03_11", "web_search_2025_08_26": + clean := map[string]any{"type": "web_search", "enable_image_understanding": true} + if enabled, ok := tool["enable_image_understanding"].(bool); ok { + clean["enable_image_understanding"] = enabled + } + result = append(result, clean) + case "x_search": + clean := map[string]any{"type": "x_search", "enable_video_understanding": true} + if enabled, ok := tool["enable_video_understanding"].(bool); ok { + clean["enable_video_understanding"] = enabled + } + result = append(result, clean) + case "function": + name, _ := tool["name"].(string) + if strings.TrimSpace(name) == "" { + continue + } + clean := map[string]any{"type": "function", "name": strings.TrimSpace(name)} + for _, field := range []string{"description", "parameters", "strict"} { + if fieldValue, exists := tool[field]; exists { + clean[field] = fieldValue + } + } + result = append(result, clean) + retainedClientTools = true + case "mcp", "shell", "image_generation", "collections_search", "file_search", "code_execution", "code_interpreter": + // These are native xAI Responses tool variants. Keep their payloads, + // while namespace/tool_search remain client-side abstractions and are + // intentionally omitted instead of causing an upstream 400. + result = append(result, tool) + retainedClientTools = true + } + } + if len(result) == 0 { + delete(payload, "tools") + return false + } + payload["tools"] = result + return retainedClientTools +} + +func mergeSearchTools(payload map[string]any) { + defaults := []any{ + map[string]any{"type": "web_search", "enable_image_understanding": true}, + map[string]any{"type": "x_search", "enable_video_understanding": true}, + } + positions := map[string]int{"web_search": 0, "x_search": 1} + result := append([]any(nil), defaults...) + if value, exists := payload["tools"]; exists && value != nil { + tools, _ := value.([]any) + for _, tool := range tools { + identity := toolIdentity(tool) + if index, exists := positions[identity]; identity != "" && exists { + result[index] = tool + continue + } + if identity != "" { + positions[identity] = len(result) + } + result = append(result, tool) + } + } + payload["tools"] = result + if _, exists := payload["tool_choice"]; !exists { + payload["tool_choice"] = "auto" + } +} + +func normalizeConsoleToolChoice(payload map[string]any, retainedClientTools bool) { + choice, exists := payload["tool_choice"] + if !exists { + payload["tool_choice"] = "auto" + return + } + if value, ok := choice.(string); ok { + switch strings.ToLower(strings.TrimSpace(value)) { + case "none", "auto": + payload["tool_choice"] = strings.ToLower(strings.TrimSpace(value)) + case "required": + if !retainedClientTools { + payload["tool_choice"] = "auto" + } + default: + payload["tool_choice"] = "auto" + } + return + } + object, ok := choice.(map[string]any) + if !ok { + payload["tool_choice"] = "auto" + return + } + typeName, _ := object["type"].(string) + if typeName != "function" || !retainedClientTools { + payload["tool_choice"] = "auto" + return + } + name, _ := object["name"].(string) + if strings.TrimSpace(name) == "" { + if function, ok := object["function"].(map[string]any); ok { + name, _ = function["name"].(string) + } + } + if strings.TrimSpace(name) == "" { + payload["tool_choice"] = "auto" + return + } + payload["tool_choice"] = map[string]any{"type": "function", "name": strings.TrimSpace(name)} +} + +func toolIdentity(value any) string { + tool, ok := value.(map[string]any) + if !ok { + return "" + } + typeName, _ := tool["type"].(string) + if typeName != "function" { + return typeName + } + name, _ := tool["name"].(string) + return typeName + ":" + name +} + +func consoleRetryAfter(body []byte) time.Duration { + text := string(body) + index := strings.Index(strings.ToLower(text), "resets in:") + if index < 0 { + return 0 + } + text = text[index+len("resets in:"):] + var total time.Duration + for _, match := range resetDurationPattern.FindAllStringSubmatch(text, -1) { + value, _ := strconv.Atoi(match[1]) + switch strings.ToLower(match[2]) { + case "d": + total += time.Duration(value) * 24 * time.Hour + case "h": + total += time.Duration(value) * time.Hour + case "m": + total += time.Duration(value) * time.Minute + case "s": + total += time.Duration(value) * time.Second + } + } + return total +} + +func parseConsoleRetryAfterHeader(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if at, err := http.ParseTime(value); err == nil && at.After(now) { + return at.Sub(now) + } + return 0 +} + +func parseConsoleRateLimitMetadata(body []byte) *provider.RateLimitMetadata { + return provider.ParseRateLimitMetadata(body) +} diff --git a/backend/internal/infra/provider/conversation/chat_request.go b/backend/internal/infra/provider/conversation/chat_request.go new file mode 100644 index 0000000000000000000000000000000000000000..82c493492d13e47330b0f119f6c9410a44a827c0 --- /dev/null +++ b/backend/internal/infra/provider/conversation/chat_request.go @@ -0,0 +1,329 @@ +package conversation + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// convertChatRequest 将 Chat Completions 请求完整转换为标准 Responses 输入。 +func convertChatRequest(body []byte, model string) ([]byte, ResponseOptions, error) { + var source map[string]json.RawMessage + if err := json.Unmarshal(body, &source); err != nil { + return nil, ResponseOptions{}, fmt.Errorf("解析 Chat Completions 请求: %w", err) + } + var messages []chatMessage + if err := json.Unmarshal(source["messages"], &messages); err != nil || len(messages) == 0 { + return nil, ResponseOptions{}, errors.New("messages 必须是非空数组") + } + input, err := convertChatMessages(messages) + if err != nil { + return nil, ResponseOptions{}, err + } + target := map[string]json.RawMessage{"model": mustJSON(model), "input": mustJSON(input)} + copyFields(target, source, "stream", "temperature", "top_p", "parallel_tool_calls", "metadata", "store", "service_tier") + if raw := source["user"]; !isEmptyJSON(raw) { + var user string + if json.Unmarshal(raw, &user) != nil || strings.TrimSpace(user) == "" { + return nil, ResponseOptions{}, errors.New("user 必须是非空字符串") + } + target["safety_identifier"] = mustJSON(strings.TrimSpace(user)) + } + stopSequences, err := parseChatStopSequences(source["stop"]) + if err != nil { + return nil, ResponseOptions{}, err + } + if raw := firstJSON(source["max_completion_tokens"], source["max_tokens"]); !isEmptyJSON(raw) { + target["max_output_tokens"] = raw + } + if raw := source["response_format"]; !isEmptyJSON(raw) { + format, err := convertResponseFormat(raw) + if err != nil { + return nil, ResponseOptions{}, err + } + target["text"] = mustJSON(map[string]json.RawMessage{"format": format}) + } + if raw := source["reasoning_effort"]; !isEmptyJSON(raw) { + target["reasoning"] = mustJSON(map[string]json.RawMessage{"effort": raw}) + } + var tools []any + if raw := source["tools"]; !isEmptyJSON(raw) { + tools, err = convertChatTools(raw) + if err != nil { + return nil, ResponseOptions{}, err + } + } + if !isEmptyJSON(source["web_search_options"]) && !containsToolType(tools, "web_search") { + tools = append(tools, map[string]any{"type": "web_search"}) + } + if len(tools) > 0 { + target["tools"] = mustJSON(tools) + } + if raw := source["tool_choice"]; !isEmptyJSON(raw) { + choice, err := convertChatToolChoice(raw) + if err != nil { + return nil, ResponseOptions{}, err + } + target["tool_choice"] = choice + } + converted, err := json.Marshal(target) + return converted, ResponseOptions{StopSequences: stopSequences}, err +} + +func parseChatStopSequences(raw json.RawMessage) ([]string, error) { + if isEmptyJSON(raw) { + return nil, nil + } + var single string + if json.Unmarshal(raw, &single) == nil { + if single == "" { + return nil, errors.New("stop 不能为空") + } + return []string{single}, nil + } + var values []string + if json.Unmarshal(raw, &values) != nil || len(values) == 0 { + return nil, errors.New("stop 必须是字符串或非空字符串数组") + } + if len(values) > 4 { + return nil, errors.New("stop 最多包含 4 个序列") + } + for index, value := range values { + if value == "" { + return nil, fmt.Errorf("stop[%d] 不能为空", index) + } + } + return values, nil +} + +type chatMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCalls json.RawMessage `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + Name string `json:"name"` +} + +func convertChatMessages(messages []chatMessage) ([]any, error) { + input := make([]any, 0, len(messages)) + for _, message := range messages { + role := strings.ToLower(strings.TrimSpace(message.Role)) + switch role { + case "system", "developer", "user", "assistant": + if !isEmptyJSON(message.Content) && !bytes.Equal(bytes.TrimSpace(message.Content), []byte("null")) { + content, err := convertChatContent(message.Content) + if err != nil { + return nil, fmt.Errorf("%s 消息内容无效: %w", role, err) + } + input = append(input, map[string]any{"type": "message", "role": role, "content": content}) + } + if role == "assistant" && !isEmptyJSON(message.ToolCalls) { + calls, err := convertAssistantToolCalls(message.ToolCalls) + if err != nil { + return nil, err + } + input = append(input, calls...) + } + case "tool": + if strings.TrimSpace(message.ToolCallID) == "" { + return nil, errors.New("tool 消息缺少 tool_call_id") + } + output, err := convertChatToolOutput(message.Content) + if err != nil { + return nil, err + } + input = append(input, map[string]any{"type": "function_call_output", "call_id": message.ToolCallID, "output": output}) + default: + return nil, fmt.Errorf("不支持 messages.role=%q", message.Role) + } + } + if len(input) == 0 { + return nil, errors.New("messages 中没有可发送内容") + } + return input, nil +} + +func convertChatContent(raw json.RawMessage) (any, error) { + var text string + if json.Unmarshal(raw, &text) == nil { + return text, nil + } + var parts []map[string]json.RawMessage + if err := json.Unmarshal(raw, &parts); err != nil { + return nil, errors.New("content 必须是字符串或内容数组") + } + result := make([]any, 0, len(parts)) + for _, part := range parts { + var typeName string + _ = json.Unmarshal(part["type"], &typeName) + switch typeName { + case "text", "input_text", "output_text": + var value string + if json.Unmarshal(part["text"], &value) != nil { + return nil, errors.New("text 内容无效") + } + result = append(result, map[string]any{"type": "input_text", "text": value}) + case "image_url", "input_image": + image, err := convertChatImagePart(part) + if err != nil { + return nil, err + } + result = append(result, image) + default: + return nil, fmt.Errorf("不支持 content.type=%q", typeName) + } + } + return result, nil +} + +func convertChatToolOutput(raw json.RawMessage) (any, error) { + var text string + if json.Unmarshal(raw, &text) == nil { + return text, nil + } + var parts []map[string]json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return contentAsText(raw) + } + hasImage := false + for _, part := range parts { + var typeName string + _ = json.Unmarshal(part["type"], &typeName) + if typeName == "image_url" || typeName == "input_image" { + hasImage = true + break + } + } + if !hasImage { + return contentAsText(raw) + } + return convertChatContent(raw) +} + +func convertChatImagePart(part map[string]json.RawMessage) (map[string]any, error) { + imageURL, detail, err := parseImageURL(part) + if err != nil { + return nil, err + } + return map[string]any{"type": "input_image", "detail": detail, "image_url": imageURL}, nil +} + +func parseImageURL(part map[string]json.RawMessage) (string, string, error) { + raw := firstJSON(part["image_url"], part["url"]) + detail := "auto" + var directDetail string + if json.Unmarshal(part["detail"], &directDetail) == nil && strings.TrimSpace(directDetail) != "" { + detail = strings.TrimSpace(directDetail) + } + var value string + if json.Unmarshal(raw, &value) == nil && strings.TrimSpace(value) != "" { + return value, detail, nil + } + var nested struct { + URL string `json:"url"` + Detail string `json:"detail"` + } + if json.Unmarshal(raw, &nested) == nil && strings.TrimSpace(nested.URL) != "" { + if strings.TrimSpace(nested.Detail) != "" { + detail = strings.TrimSpace(nested.Detail) + } + return nested.URL, detail, nil + } + return "", "", errors.New("image_url 缺少有效 url") +} + +func convertAssistantToolCalls(raw json.RawMessage) ([]any, error) { + var calls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &calls); err != nil { + return nil, errors.New("assistant.tool_calls 格式无效") + } + result := make([]any, 0, len(calls)) + for _, call := range calls { + if strings.TrimSpace(call.ID) == "" || strings.TrimSpace(call.Function.Name) == "" { + return nil, errors.New("assistant.tool_calls 缺少有效 id 或 name") + } + arguments := call.Function.Arguments + if arguments == "" { + arguments = "{}" + } + result = append(result, map[string]any{"type": "function_call", "call_id": call.ID, "name": call.Function.Name, "arguments": arguments}) + } + return result, nil +} + +func convertChatTools(raw json.RawMessage) ([]any, error) { + var tools []map[string]json.RawMessage + if err := json.Unmarshal(raw, &tools); err != nil { + return nil, errors.New("tools 必须是数组") + } + result := make([]any, 0, len(tools)) + for _, tool := range tools { + var typeName string + _ = json.Unmarshal(tool["type"], &typeName) + if typeName != "function" { + var value any + _ = json.Unmarshal(mustJSON(tool), &value) + object, _ := value.(map[string]any) + switch typeName { + case "web_search", "web_search_preview", "web_search_preview_2025_03_11", "web_search_2025_08_26": + converted := map[string]any{"type": "web_search"} + if filters, ok := object["filters"].(map[string]any); ok { + if domains, exists := filters["allowed_domains"]; exists { + converted["filters"] = map[string]any{"allowed_domains": domains} + } + } else if domains, exists := object["allowed_domains"]; exists { + converted["filters"] = map[string]any{"allowed_domains": domains} + } + result = append(result, converted) + default: + result = append(result, value) + } + continue + } + var function map[string]any + if json.Unmarshal(tool["function"], &function) != nil { + return nil, errors.New("function tool 格式无效") + } + function["type"] = "function" + result = append(result, function) + } + return result, nil +} + +func containsToolType(tools []any, kind string) bool { + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if ok && tool["type"] == kind { + return true + } + } + return false +} + +func convertChatToolChoice(raw json.RawMessage) (json.RawMessage, error) { + var value map[string]json.RawMessage + if json.Unmarshal(raw, &value) != nil { + return raw, nil + } + var typeName string + _ = json.Unmarshal(value["type"], &typeName) + if typeName != "function" { + return raw, nil + } + var function struct { + Name string `json:"name"` + } + if json.Unmarshal(value["function"], &function) != nil || strings.TrimSpace(function.Name) == "" { + return nil, errors.New("tool_choice.function.name 无效") + } + return mustJSON(map[string]any{"type": "function", "name": function.Name}), nil +} diff --git a/backend/internal/infra/provider/conversation/chat_response.go b/backend/internal/infra/provider/conversation/chat_response.go new file mode 100644 index 0000000000000000000000000000000000000000..ded55ff119f572029ebb7e403e9022fc32845656 --- /dev/null +++ b/backend/internal/infra/provider/conversation/chat_response.go @@ -0,0 +1,60 @@ +package conversation + +import "strings" + +func chatResponse(value parsedResponse) map[string]any { + message := map[string]any{"role": "assistant", "content": value.Text} + if value.Reasoning != "" { + message["reasoning_content"] = value.Reasoning + } + finishReason := "stop" + if len(value.Calls) > 0 { + finishReason = "tool_calls" + if value.Text == "" { + message["content"] = nil + } + calls := make([]any, 0, len(value.Calls)) + for _, call := range value.Calls { + calls = append(calls, map[string]any{ + "id": call.CallID, "type": "function", + "function": map[string]any{"name": call.Name, "arguments": call.Arguments}, + }) + } + message["tool_calls"] = calls + } else if value.Refusal != "" { + finishReason = "content_filter" + } else if value.Status == "incomplete" { + finishReason = "length" + } + if value.Refusal != "" { + message["refusal"] = value.Refusal + } + if len(value.Annotations) > 0 { + message["annotations"] = value.Annotations + } + id := strings.Replace(value.ID, "resp_", "chatcmpl_", 1) + return map[string]any{ + "id": id, "object": "chat.completion", "created": value.CreatedAt, "model": value.Model, + "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finishReason}}, + "usage": chatUsage(value.Usage), + } +} + +func chatUsage(value responseUsage) map[string]any { + total := value.TotalTokens + if total == 0 { + total = value.InputTokens + value.OutputTokens + } + return map[string]any{ + "prompt_tokens": value.InputTokens, "completion_tokens": value.OutputTokens, + "total_tokens": total, + "prompt_tokens_details": map[string]any{"cached_tokens": value.InputTokensDetails.CachedTokens}, + "completion_tokens_details": map[string]any{"reasoning_tokens": value.OutputTokensDetails.ReasoningTokens}, + "cost_in_usd_ticks": value.CostInUSDTicks, + "num_sources_used": value.NumSourcesUsed, + "num_server_side_tools_used": value.NumServerSideToolsUsed, + "context_details": map[string]any{ + "input_tokens": value.ContextDetails.InputTokens, "output_tokens": value.ContextDetails.OutputTokens, + }, + } +} diff --git a/backend/internal/infra/provider/conversation/chat_stream.go b/backend/internal/infra/provider/conversation/chat_stream.go new file mode 100644 index 0000000000000000000000000000000000000000..fe78807c8779d9679e7500feeaf0535d3744f2c8 --- /dev/null +++ b/backend/internal/infra/provider/conversation/chat_stream.go @@ -0,0 +1,128 @@ +package conversation + +import ( + "io" + "strings" +) + +func (c *streamConverter) startChat() error { + return c.writeData(map[string]any{ + "id": strings.Replace(c.id, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", + "created": c.created, "model": c.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}}, + }) +} + +func (c *streamConverter) textDeltaChat(delta string) error { + emit, matched := c.stopFilter.Push(delta) + if matched != "" { + c.stopSequence = matched + } + if emit == "" { + return nil + } + return c.chatDelta(map[string]any{"content": emit}) +} + +func (c *streamConverter) chatDelta(delta map[string]any) error { + if err := c.start(); err != nil { + return err + } + return c.writeData(map[string]any{ + "id": strings.Replace(c.id, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", "created": c.created, "model": c.model, + "choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": nil}}, + }) +} + +func (c *streamConverter) toolStartChat(item responseItem, _ int) error { + if err := c.start(); err != nil { + return err + } + if _, exists := c.tools[item.ID]; exists { + return nil + } + tool := streamTool{Index: len(c.tools), ID: item.CallID, Name: item.Name, Arguments: item.Arguments} + c.tools[item.ID] = tool + return c.chatDelta(map[string]any{"tool_calls": []any{map[string]any{ + "index": tool.Index, "id": tool.ID, "type": "function", "function": map[string]any{"name": tool.Name, "arguments": ""}, + }}}) +} + +func (c *streamConverter) toolDeltaChat(itemID, delta string) error { + tool, ok := c.tools[itemID] + if !ok { + return nil + } + tool.SentArgs = true + c.tools[itemID] = tool + return c.chatDelta(map[string]any{"tool_calls": []any{map[string]any{"index": tool.Index, "function": map[string]any{"arguments": delta}}}}) +} + +func (c *streamConverter) toolArgumentsDoneChat(itemID, arguments string) error { + tool, ok := c.tools[itemID] + if !ok || tool.Closed { + return nil + } + if !tool.SentArgs { + if arguments == "" { + arguments = tool.Arguments + } + if arguments != "" { + if err := c.chatDelta(map[string]any{"tool_calls": []any{map[string]any{"index": tool.Index, "function": map[string]any{"arguments": arguments}}}}); err != nil { + return err + } + tool.SentArgs = true + } + } + c.tools[itemID] = tool + return nil +} + +func (c *streamConverter) doneChat(status string) error { + if c.stopSequence == "" { + if pending := c.stopFilter.Flush(); pending != "" { + if err := c.chatDelta(map[string]any{"content": pending}); err != nil { + return err + } + } + } + finishReason := "stop" + if len(c.tools) > 0 { + finishReason = "tool_calls" + } else if c.refused { + finishReason = "content_filter" + } else if status == "incomplete" { + finishReason = "length" + } + if err := c.writeData(map[string]any{ + "id": strings.Replace(c.id, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", "created": c.created, "model": c.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": finishReason}}, "usage": chatUsage(c.usage), + }); err != nil { + return err + } + c.finished = true + _, err := io.WriteString(c.writer, "data: [DONE]\n\n") + return err +} + +func (c *streamConverter) streamErrorChat(data []byte) error { + if err := c.writeData(map[string]any{"error": normalizeOpenAIStreamError(streamErrorValue(data))}); err != nil { + return err + } + _, err := io.WriteString(c.writer, "data: [DONE]\n\n") + return err +} + +func normalizeOpenAIStreamError(value any) map[string]any { + result := map[string]any{"message": "Upstream request failed", "type": "api_error"} + if object, ok := value.(map[string]any); ok { + for _, key := range []string{"message", "type", "code", "param"} { + if field, exists := object[key]; exists && field != nil { + result[key] = field + } + } + } else if message, ok := value.(string); ok && strings.TrimSpace(message) != "" { + result["message"] = message + } + return result +} diff --git a/backend/internal/infra/provider/conversation/conversation_test.go b/backend/internal/infra/provider/conversation/conversation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c6b2cf69a32579f9314881bb36f5c745a42b6fc7 --- /dev/null +++ b/backend/internal/infra/provider/conversation/conversation_test.go @@ -0,0 +1,938 @@ +package conversation + +import ( + "encoding/json" + "io" + "strings" + "testing" +) + +func TestConvertChatRequestToResponses(t *testing.T) { + body := []byte(`{ + "model":"public-chat","stream":true,"max_completion_tokens":512, + "user":"client-user","presence_penalty":0,"frequency_penalty":0, + "web_search_options":{"search_context_size":"medium"}, + "messages":[ + {"role":"system","content":"be concise"}, + {"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}, + {"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"result"} + ], + "tools":[{"type":"function","function":{"name":"lookup","description":"lookup","parameters":{"type":"object"}}}], + "tool_choice":{"type":"function","function":{"name":"lookup"}} + }`) + converted, err := ConvertRequest(body, "grok-4.5", OperationChat) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if payload["model"] != "grok-4.5" || payload["max_output_tokens"] != float64(512) || payload["stream"] != true || payload["safety_identifier"] != "client-user" { + t.Fatalf("request fields = %#v", payload) + } + if _, exists := payload["user"]; exists { + t.Fatalf("Chat user 不应原样转发到 Responses: %#v", payload) + } + input := payload["input"].([]any) + if len(input) != 4 || input[2].(map[string]any)["type"] != "function_call" || input[3].(map[string]any)["type"] != "function_call_output" { + t.Fatalf("input = %#v", input) + } + content := input[1].(map[string]any)["content"].([]any) + if content[1].(map[string]any)["image_url"] != "data:image/png;base64,AA==" { + t.Fatalf("image content = %#v", content) + } + if content[1].(map[string]any)["detail"] != "auto" { + t.Fatalf("image detail = %#v", content[1]) + } + tools := payload["tools"].([]any) + if len(tools) != 2 || tools[0].(map[string]any)["name"] != "lookup" || tools[0].(map[string]any)["type"] != "function" || tools[1].(map[string]any)["type"] != "web_search" { + t.Fatalf("tools = %#v", tools) + } +} + +func TestConvertChatToolImageResultToMultimodalFunctionOutput(t *testing.T) { + body := []byte(`{ + "model":"public-chat", + "messages":[ + {"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":[ + {"type":"text","text":"Read image file"}, + {"type":"image_url","image_url":{"url":"data:image/png;base64,AA==","detail":"high"}} + ]} + ] + }`) + converted, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationChat) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + input := payload["input"].([]any) + output := input[1].(map[string]any)["output"].([]any) + if len(output) != 2 { + t.Fatalf("tool output = %#v", output) + } + textBlock := output[0].(map[string]any) + imageBlock := output[1].(map[string]any) + if textBlock["type"] != "input_text" || textBlock["text"] != "Read image file" || + imageBlock["type"] != "input_image" || imageBlock["detail"] != "high" || + imageBlock["image_url"] != "data:image/png;base64,AA==" { + t.Fatalf("tool output = %#v", output) + } +} + +func TestConvertChatKeepsNonMultimodalToolJSONAsText(t *testing.T) { + body := []byte(`{ + "model":"public-chat", + "messages":[{"role":"tool","tool_call_id":"call_1","content":[{"name":"value","value":1}]}] + }`) + converted, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationChat) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + output := payload["input"].([]any)[0].(map[string]any)["output"] + if output != `[{"name":"value","value":1}]` { + t.Fatalf("tool output = %#v", output) + } +} + +func TestConvertChatIgnoresSamplingFieldsWithoutResponsesEquivalent(t *testing.T) { + body := []byte(`{ + "model":"public","messages":[{"role":"user","content":"hi"}], + "presence_penalty":0.5,"frequency_penalty":-0.5,"seed":42 + }`) + converted, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationChat) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + for _, field := range []string{"presence_penalty", "frequency_penalty", "seed"} { + if _, exists := payload[field]; exists { + t.Fatalf("不受 Responses 支持的 %s 不应转发给上游: %#v", field, payload) + } + } +} + +func TestConvertChatStopSequencesLocally(t *testing.T) { + request := []byte(`{ + "model":"public-chat","stop":["STOP","END"], + "messages":[{"role":"user","content":"continue"}] + }`) + converted, options, err := ConvertRequestWithOptions(request, "grok-4.5", OperationChat) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload["stop"]; exists { + t.Fatalf("Responses 请求不应包含不受支持的 stop 字段: %#v", payload) + } + if len(options.StopSequences) != 2 || options.StopSequences[0] != "STOP" || options.StopSequences[1] != "END" { + t.Fatalf("stop options = %#v", options.StopSequences) + } + + body := []byte(`{"id":"resp_1","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"ABCSTOPXYZ"}]}]}`) + data, err := ConvertResponseJSONWithOptions(body, OperationChat, options) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(data, &response); err != nil { + t.Fatal(err) + } + message := response["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if message["content"] != "ABC" { + t.Fatalf("chat response = %#v", response) + } +} + +func TestConvertChatPreservesOpaqueToolArgumentsHistory(t *testing.T) { + body := []byte(`{ + "model":"public","messages":[ + {"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{partial"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"failed"} + ] + }`) + converted, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationChat) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + call := payload["input"].([]any)[0].(map[string]any) + if call["arguments"] != "{partial" { + t.Fatalf("function call = %#v", call) + } +} + +func TestConvertChatStopSequencesStream(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5","status":"in_progress"}}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"ABCST"}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"OPXYZ"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":3,"output_tokens":2}}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStreamWithOptions( + io.NopCloser(strings.NewReader(stream)), OperationChat, ResponseOptions{StopSequences: []string{"STOP"}}, + )) + if err != nil { + t.Fatal(err) + } + text := string(converted) + if strings.Contains(text, "STOP") || strings.Contains(text, "XYZ") || !strings.Contains(text, `"content":"ABC"`) || !strings.Contains(text, `"finish_reason":"stop"`) { + t.Fatalf("chat stream = %s", text) + } +} + +func TestConvertAnthropicMessagesRequestToResponses(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":1024,"stream":true, + "system":[{"type":"text","text":"You are precise."}], + "messages":[ + {"role":"user","content":[{"type":"text","text":"look"},{"type":"image","source":{"type":"url","url":"https://example.com/a.png"}}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]} + ], + "tools":[{"name":"lookup","description":"lookup","input_schema":{"type":"object","properties":{"q":{"type":"string"}}}}], + "tool_choice":{"type":"tool","name":"lookup","disable_parallel_tool_use":true} + }`) + converted, err := ConvertRequest(body, "grok-chat-fast", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if payload["model"] != "grok-chat-fast" || payload["instructions"] != "You are precise." || payload["parallel_tool_calls"] != false { + t.Fatalf("request = %#v", payload) + } + input := payload["input"].([]any) + if len(input) != 3 || input[1].(map[string]any)["type"] != "function_call" || input[2].(map[string]any)["type"] != "function_call_output" { + t.Fatalf("input = %#v", input) + } +} + +func TestConvertAnthropicMessagesDropsClaudeCodeBillingAttribution(t *testing.T) { + body := []byte(`{ + "model":"claude","max_tokens":128, + "system":[ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.170.abc; cc_entrypoint=sdk-cli; cch=12345;"}, + {"type":"text","text":"stable system instruction","cache_control":{"type":"ephemeral"}} + ], + "messages":[{"role":"user","content":"hello"}] + }`) + converted, err := ConvertRequest(body, "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if payload["instructions"] != "stable system instruction" { + t.Fatalf("instructions = %#v", payload["instructions"]) + } + if strings.Contains(string(converted), anthropicBillingHeaderPrefix) { + t.Fatalf("billing attribution leaked into Build request: %s", converted) + } +} + +func TestConvertAnthropicMessagesInlineSystemRole(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":1024, + "system":"Top-level rules.", + "messages":[ + {"role":"system","content":"Inline directive."}, + {"role":"system","content":[{"type":"text","text":"Inline block."}]}, + {"role":"user","content":"hi"} + ] + }`) + converted, err := ConvertRequest(body, "grok-chat-fast", OperationMessages) + if err != nil { + t.Fatalf("inline system role should not fail: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if instructions, _ := payload["instructions"].(string); instructions != "Top-level rules.\n\nInline directive.\n\nInline block." { + t.Fatalf("instructions = %q", instructions) + } + input := payload["input"].([]any) + if len(input) != 1 { + t.Fatalf("input should only contain the user message, got %#v", input) + } + if role := input[0].(map[string]any)["role"]; role != "user" { + t.Fatalf("remaining input role = %v", role) + } +} + +func TestConvertAnthropicMessagesInlineSystemOnly(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":256, + "messages":[ + {"role":"system","content":"Only inline."}, + {"role":"user","content":"go"} + ] + }`) + converted, err := ConvertRequest(body, "grok-chat-fast", OperationMessages) + if err != nil { + t.Fatalf("inline-only system should not fail: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if payload["instructions"] != "Only inline." { + t.Fatalf("instructions = %#v", payload["instructions"]) + } +} + +func TestConvertAnthropicMessagesRejectsUnknownRole(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":256, + "messages":[{"role":"tool","content":"nope"}] + }`) + if _, err := ConvertRequest(body, "grok-chat-fast", OperationMessages); err == nil { + t.Fatal("unknown role should be rejected") + } +} + +func TestConvertAnthropicRedactedThinkingIncludesEmptySummary(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":256, + "thinking":{"type":"enabled","budget_tokens":1024}, + "messages":[ + {"role":"assistant","content":[{"type":"redacted_thinking","data":"opaque-reasoning"}]}, + {"role":"user","content":"continue"} + ] + }`) + converted, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload struct { + Input []map[string]any `json:"input"` + } + if json.Unmarshal(converted, &payload) != nil || len(payload.Input) != 2 { + t.Fatalf("converted = %s", converted) + } + reasoning := payload.Input[0] + summary, ok := reasoning["summary"].([]any) + if reasoning["type"] != "reasoning" || reasoning["encrypted_content"] != "opaque-reasoning" || !ok || len(summary) != 0 { + t.Fatalf("reasoning = %#v", reasoning) + } +} + +func TestConvertAnthropicClaudeCodeRequestToResponses(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":4096,"stream":true, + "system":[{"type":"text","text":"top-level system","cache_control":{"type":"ephemeral"}}], + "messages":[ + {"role":"system","content":"legacy system"}, + {"role":"developer","content":[{"type":"text","text":"developer context","cache_control":{"type":"ephemeral"}}]}, + {"role":"assistant","content":[ + {"type":"thinking","thinking":"prior thought","signature":"encrypted-reasoning"}, + {"type":"tool_use","id":"toolu_1","name":"Read","input":{"path":"README.md"}} + ]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_1","is_error":true,"content":[ + {"type":"text","text":"failed"}, + {"type":"tool_reference","tool_name":"Read"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAAA"}} + ]}, + {"type":"document","title":"notes.txt","source":{"type":"text","data":"document text"}}, + {"type":"text","text":"continue"} + ]} + ], + "metadata":{"user_id":"cc-user"}, + "thinking":{"type":"enabled","budget_tokens":12000}, + "tools":[{"name":"Read","description":"Read file","input_schema":{"type":"object"},"strict":true,"cache_control":{"type":"ephemeral"}}], + "mcp_servers":[{"name":"github","url":"https://example.com/mcp","authorization_token":"token"}] + }`) + converted, options, err := ConvertRequestWithOptions(body, "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + if !options.AnthropicThinking { + t.Fatal("thinking option 未保留") + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if payload["instructions"] != "top-level system\n\nlegacy system\n\ndeveloper context" || payload["safety_identifier"] != "cc-user" || payload["store"] != false { + t.Fatalf("request metadata = %#v", payload) + } + reasoning := payload["reasoning"].(map[string]any) + if reasoning["effort"] != "high" || payload["include"].([]any)[0] != "reasoning.encrypted_content" { + t.Fatalf("reasoning = %#v, include = %#v", reasoning, payload["include"]) + } + input := payload["input"].([]any) + if len(input) != 4 || input[0].(map[string]any)["type"] != "reasoning" || input[1].(map[string]any)["type"] != "function_call" || input[2].(map[string]any)["type"] != "function_call_output" { + t.Fatalf("input = %#v", input) + } + output := input[2].(map[string]any)["output"].([]any) + if len(output) != 4 || !strings.Contains(output[0].(map[string]any)["text"].(string), "failed") || + !strings.Contains(output[2].(map[string]any)["text"].(string), `"Read"`) || output[3].(map[string]any)["type"] != "input_image" || output[3].(map[string]any)["detail"] != "auto" { + t.Fatalf("tool result = %#v", output) + } + tools := payload["tools"].([]any) + if len(tools) != 2 || tools[0].(map[string]any)["type"] != "function" || tools[0].(map[string]any)["strict"] != true || tools[1].(map[string]any)["type"] != "mcp" { + t.Fatalf("tools = %#v", tools) + } +} + +func TestConvertAnthropicMessagesPreservesExtendedReasoningEffort(t *testing.T) { + for _, effort := range []string{"xhigh", "max"} { + t.Run(effort, func(t *testing.T) { + body := []byte(`{ + "model":"public-chat","max_tokens":1024, + "messages":[{"role":"user","content":"hello"}], + "thinking":{"type":"enabled","budget_tokens":1024}, + "output_config":{"effort":"` + effort + `"} + }`) + converted, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + reasoning, ok := payload["reasoning"].(map[string]any) + if !ok || reasoning["effort"] != effort { + t.Fatalf("reasoning = %#v", payload["reasoning"]) + } + }) + } +} + +func TestConvertAnthropicToolReferenceValidatesDeclaredTool(t *testing.T) { + body := []byte(`{ + "model":"public","max_tokens":64, + "messages":[ + {"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"SearchTools","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"tool_reference","tool_name":"Missing"}]}]} + ], + "tools":[{"name":"SearchTools","input_schema":{"type":"object"}}] + }`) + _, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationMessages) + if err == nil || !strings.Contains(err.Error(), `未声明的工具 "Missing"`) { + t.Fatalf("error = %v", err) + } +} + +func TestConvertAnthropicMessagesValidatesToolRelationships(t *testing.T) { + tests := []struct { + name string + messages string + want string + }{ + {name: "orphan result", messages: `[{"role":"user","content":[{"type":"tool_result","tool_use_id":"missing","content":"x"}]}]`, want: "未匹配"}, + {name: "missing result", messages: `[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]}]`, want: "提供 tool_result"}, + {name: "result after text", messages: `[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},{"role":"user","content":[{"type":"text","text":"late"},{"type":"tool_result","tool_use_id":"toolu_1","content":"x"}]}]`, want: "必须位于"}, + {name: "user tool use", messages: `[{"role":"user","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]}]`, want: "只允许"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"public","max_tokens":64,"messages":` + test.messages + `}`) + _, _, err := ConvertRequestWithOptions(body, "grok-4.5", OperationMessages) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestConvertAnthropicMessagesIgnoresUnrepresentableTopK(t *testing.T) { + converted, _, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"top_k":10, + "messages":[{"role":"user","content":"hello"}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload["top_k"]; exists { + t.Fatalf("top_k 不应转发到 Responses: %#v", payload) + } +} + +func TestConvertAnthropicWebSearchControls(t *testing.T) { + converted, _, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"messages":[{"role":"user","content":"search"}], + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":3,"allowed_domains":["example.com"],"user_location":{"type":"approximate","country":"US"}}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + _ = json.Unmarshal(converted, &payload) + tool := payload["tools"].([]any)[0].(map[string]any) + domains := tool["filters"].(map[string]any)["allowed_domains"].([]any) + if tool["type"] != "web_search" || len(domains) != 1 || domains[0] != "example.com" || len(tool) != 2 { + t.Fatalf("tool = %#v", tool) + } + + converted, _, err = ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"messages":[{"role":"user","content":"search"}], + "tools":[{"type":"web_search_20250305","name":"web_search","search_context_size":"high"}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + payload = nil + _ = json.Unmarshal(converted, &payload) + tool = payload["tools"].([]any)[0].(map[string]any) + if len(tool) != 1 || tool["type"] != "web_search" { + t.Fatalf("downgraded tool = %#v", tool) + } +} + +func TestConvertResponsesJSONToChatAndMessages(t *testing.T) { + body := []byte(`{ + "id":"resp_1","object":"response","created_at":123,"model":"grok-4.5","status":"completed", + "output":[ + {"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello","annotations":[{"type":"url_citation","url":"https://example.com","title":"Example"}]}]}, + {"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"} + ], + "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1},"cost_in_usd_ticks":158500,"num_sources_used":3,"num_server_side_tools_used":2,"context_details":{"input_tokens":9,"output_tokens":4}} + }`) + chatData, err := ConvertResponseJSON(body, OperationChat) + if err != nil { + t.Fatal(err) + } + var chat map[string]any + _ = json.Unmarshal(chatData, &chat) + choice := chat["choices"].([]any)[0].(map[string]any) + message := choice["message"].(map[string]any) + if chat["object"] != "chat.completion" || choice["finish_reason"] != "tool_calls" || message["reasoning_content"] != "reason" { + t.Fatalf("chat = %#v", chat) + } + if annotations := message["annotations"].([]any); len(annotations) != 1 || annotations[0].(map[string]any)["url"] != "https://example.com" { + t.Fatalf("chat annotations = %#v", message) + } + chatUsage := chat["usage"].(map[string]any) + if chatUsage["cost_in_usd_ticks"] != float64(158500) || chatUsage["num_sources_used"] != float64(3) || chatUsage["context_details"].(map[string]any)["input_tokens"] != float64(9) { + t.Fatalf("chat usage = %#v", chatUsage) + } + + messagesData, err := ConvertResponseJSON(body, OperationMessages) + if err != nil { + t.Fatal(err) + } + var messages map[string]any + _ = json.Unmarshal(messagesData, &messages) + content := messages["content"].([]any) + if messages["type"] != "message" || messages["stop_reason"] != "tool_use" || content[1].(map[string]any)["type"] != "tool_use" { + t.Fatalf("messages = %#v", messages) + } + messagesUsage := messages["usage"].(map[string]any) + if messagesUsage["cost_in_usd_ticks"] != float64(158500) || messagesUsage["num_server_side_tools_used"] != float64(2) || messagesUsage["context_details"].(map[string]any)["output_tokens"] != float64(4) { + t.Fatalf("messages usage = %#v", messagesUsage) + } + if messagesUsage["input_tokens"] != float64(8) || messagesUsage["cache_read_input_tokens"] != float64(2) { + t.Fatalf("messages cache usage = %#v", messagesUsage) + } +} + +func TestAnthropicUsageClampsCacheReadToTotalInput(t *testing.T) { + usage := responseUsage{InputTokens: 10, OutputTokens: 2} + usage.InputTokensDetails.CachedTokens = 12 + + converted := anthropicUsage(usage, 0) + if converted["input_tokens"] != int64(0) || converted["cache_read_input_tokens"] != int64(10) { + t.Fatalf("clamped messages usage = %#v", converted) + } +} + +func TestConvertResponsesJSONToMessagesThinkingAndStop(t *testing.T) { + body := []byte(`{ + "id":"response-1","model":"grok-4.5","status":"completed", + "output":[ + {"type":"reasoning","summary":[{"type":"summary_text","text":"thought"}],"encrypted_content":"signature"}, + {"type":"message","content":[{"type":"output_text","text":"ABCSTOPXYZ"}]}, + {"type":"function_call","call_id":"call_1","name":"Read","arguments":"{}"} + ], + "usage":{"input_tokens":10,"output_tokens":5} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{AnthropicThinking: true, StopSequences: []string{"STOP"}}) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(data, &response); err != nil { + t.Fatal(err) + } + content := response["content"].([]any) + if response["id"] != "msg_response-1" || response["stop_reason"] != "tool_use" || len(content) != 3 { + t.Fatalf("response = %#v", response) + } + thinking := content[0].(map[string]any) + tool := content[2].(map[string]any) + if thinking["type"] != "thinking" || thinking["signature"] != "signature" || content[1].(map[string]any)["text"] != "ABC" || tool["id"] != "toolu_call_1" { + t.Fatalf("content = %#v", content) + } +} + +func TestConvertResponsesJSONUsesRawReasoningContentBeforeSummary(t *testing.T) { + body := []byte(`{ + "id":"resp_reasoning","model":"grok-4.5","status":"completed", + "output":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"raw thought"}],"summary":[{"type":"summary_text","text":"summary"}]}] + }`) + data, err := ConvertResponseJSON(body, OperationChat) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(data, &response); err != nil { + t.Fatal(err) + } + message := response["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if message["reasoning_content"] != "raw thought" { + t.Fatalf("message = %#v", message) + } +} + +func TestConvertResponsesRefusalAcrossChatAndMessages(t *testing.T) { + body := []byte(`{"id":"resp_refusal","status":"completed","output":[{"type":"message","content":[{"type":"refusal","refusal":"Cannot comply"}]}]}`) + chatData, err := ConvertResponseJSON(body, OperationChat) + if err != nil { + t.Fatal(err) + } + var chat map[string]any + _ = json.Unmarshal(chatData, &chat) + choice := chat["choices"].([]any)[0].(map[string]any) + if choice["finish_reason"] != "content_filter" || choice["message"].(map[string]any)["refusal"] != "Cannot comply" { + t.Fatalf("chat refusal = %#v", chat) + } + messagesData, err := ConvertResponseJSON(body, OperationMessages) + if err != nil { + t.Fatal(err) + } + var messages map[string]any + _ = json.Unmarshal(messagesData, &messages) + if messages["stop_reason"] != "refusal" { + t.Fatalf("messages refusal = %#v", messages) + } +} + +func TestConvertResponsesStreamRefusalAcrossChatAndMessages(t *testing.T) { + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_refusal","model":"grok-4.5"}}`, "", + `event: response.refusal.delta`, + `data: {"type":"response.refusal.delta","delta":"Cannot comply"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed"}}`, "", "", + }, "\n") + chat, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(source)), OperationChat)) + if err != nil { + t.Fatal(err) + } + if text := string(chat); !strings.Contains(text, `"refusal":"Cannot comply"`) || !strings.Contains(text, `"finish_reason":"content_filter"`) { + t.Fatalf("chat refusal stream = %s", text) + } + messages, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(source)), OperationMessages)) + if err != nil { + t.Fatal(err) + } + if text := string(messages); !strings.Contains(text, `"text":"Cannot comply"`) || !strings.Contains(text, `"stop_reason":"refusal"`) { + t.Fatalf("messages refusal stream = %s", text) + } +} + +func TestConvertResponsesJSONToMessagesStopSequence(t *testing.T) { + body := []byte(`{"id":"resp_1","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"ABCSTOPXYZ"}]}]}`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{StopSequences: []string{"STOP"}}) + if err != nil { + t.Fatal(err) + } + var response map[string]any + _ = json.Unmarshal(data, &response) + if response["stop_reason"] != "stop_sequence" || response["stop_sequence"] != "STOP" || response["content"].([]any)[0].(map[string]any)["text"] != "ABC" { + t.Fatalf("response = %#v", response) + } +} + +func TestConvertResponsesJSONToMessagesNormalizesErrorType(t *testing.T) { + tests := []struct { + name string + body string + want string + wantMessage string + }{ + {name: "preserve anthropic type", body: `{"error":{"message":"auth","type":"authentication_error"}}`, want: "authentication_error"}, + {name: "map openai type", body: `{"error":{"message":"invalid","type":"unsupported_parameter"}}`, want: "invalid_request_error"}, + {name: "map upstream code", body: `{"error":{"message":"limited","code":"rate_limit_exceeded"}}`, want: "rate_limit_error"}, + {name: "hide private type", body: `{"error":{"message":"failed","type":"private_internal"}}`, want: "api_error"}, + {name: "preserve string message", body: `{"error":"plain upstream failure"}`, want: "api_error", wantMessage: "plain upstream failure"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data, err := ConvertResponseJSON([]byte(test.body), OperationMessages) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(data, &response); err != nil { + t.Fatal(err) + } + errorObject := response["error"].(map[string]any) + if errorObject["type"] != test.want { + t.Fatalf("error = %#v", response) + } + if test.wantMessage != "" && errorObject["message"] != test.wantMessage { + t.Fatalf("error message = %#v", response) + } + }) + } +} + +func TestConvertResponsesStream(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5","status":"in_progress"}}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"hi"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","usage":{"input_tokens":3,"output_tokens":1}}}`, "", "", + }, "\n") + for _, operation := range []string{OperationChat, OperationMessages} { + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), operation)) + if err != nil { + t.Fatal(err) + } + value := string(converted) + if operation == OperationChat && (!strings.Contains(value, `"object":"chat.completion.chunk"`) || !strings.Contains(value, "data: [DONE]")) { + t.Fatalf("chat stream = %s", value) + } + if operation == OperationMessages && (!strings.Contains(value, "event: message_start") || !strings.Contains(value, "event: content_block_delta") || !strings.Contains(value, "event: message_stop")) { + t.Fatalf("messages stream = %s", value) + } + } +} + +func TestConvertResponsesStreamChatErrorIsTerminal(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5","status":"in_progress"}}`, "", + `event: response.failed`, + `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"message":"upstream failed"}}}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"late delta"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), OperationChat)) + if err != nil { + t.Fatal(err) + } + value := string(converted) + if !strings.Contains(value, `"error":{"message":"upstream failed","type":"api_error"}`) { + t.Fatalf("missing normalized upstream failure: %s", value) + } + if strings.Contains(value, `"finish_reason":"stop"`) { + t.Fatalf("error stream must not end successfully: %s", value) + } + if strings.Contains(value, "late delta") { + t.Fatalf("events after an error must be ignored: %s", value) + } + if strings.Count(value, "data: [DONE]") != 1 { + t.Fatalf("error stream must send one terminator: %s", value) + } +} + +func TestConvertResponsesStreamMessagesNormalizesTerminalError(t *testing.T) { + stream := strings.Join([]string{ + `event: response.failed`, + `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"message":"quota denied","code":"forbidden"}}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), OperationMessages)) + if err != nil { + t.Fatal(err) + } + text := string(converted) + if !strings.Contains(text, `event: error`) || !strings.Contains(text, `"type":"permission_error"`) || !strings.Contains(text, `"message":"quota denied"`) { + t.Fatalf("messages error stream = %s", text) + } + if strings.Contains(text, "message_stop") { + t.Fatalf("failed stream must not emit message_stop: %s", text) + } +} + +func TestConvertResponsesStreamToMessagesThinkingToolsAndStop(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"response-1","model":"grok-4.5","usage":{"input_tokens":3}}}`, "", + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"reasoning-1","type":"reasoning"}}`, "", + `event: response.reasoning_summary_text.delta`, + `data: {"type":"response.reasoning_summary_text.delta","item_id":"reasoning-1","delta":"thought"}`, "", + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"reasoning-1","type":"reasoning","encrypted_content":"signature"}}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"ABCST"}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"OPXYZ"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":3,"output_tokens":2}}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(stream)), OperationMessages, ResponseOptions{AnthropicThinking: true, StopSequences: []string{"STOP"}})) + if err != nil { + t.Fatal(err) + } + text := string(converted) + ordered := []string{"message_start", "thinking_delta", "signature_delta", "text_delta", "message_delta", "message_stop"} + position := 0 + for _, expected := range ordered { + index := strings.Index(text[position:], expected) + if index < 0 { + t.Fatalf("%q 缺失或乱序:\n%s", expected, text) + } + position += index + len(expected) + } + if strings.Contains(text, "XYZ") || !strings.Contains(text, `"text":"ABC"`) || !strings.Contains(text, `"stop_reason":"stop_sequence"`) || !strings.Contains(text, `"stop_sequence":"STOP"`) { + t.Fatalf("stream = %s", text) + } +} + +func TestConvertResponsesStreamEmitsDoneOnlyToolArguments(t *testing.T) { + stream := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"Read","arguments":""}}`, "", + `event: response.function_call_arguments.done`, + `data: {"type":"response.function_call_arguments.done","item_id":"item_1","arguments":"{\"path\":\"README.md\"}"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed"}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(stream)), OperationMessages, ResponseOptions{})) + if err != nil { + t.Fatal(err) + } + text := string(converted) + if !strings.Contains(text, `"id":"toolu_call_1"`) || !strings.Contains(text, `"partial_json":"{\"path\":\"README.md\"}"`) || !strings.Contains(text, `"stop_reason":"tool_use"`) { + t.Fatalf("stream = %s", text) + } + if strings.Count(text, `"type":"content_block_stop"`) != 1 { + t.Fatalf("tool block closed multiple times: %s", text) + } +} + +func TestConvertResponsesStreamChatUsesContiguousToolIndexes(t *testing.T) { + stream := strings.Join([]string{ + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"reasoning_1","type":"reasoning"}}`, "", + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":2,"item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"Read","arguments":"{}"}}`, "", + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","output_index":2,"item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"Read","arguments":"{}"}}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed"}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), OperationChat)) + if err != nil { + t.Fatal(err) + } + text := string(converted) + if !strings.Contains(text, `"tool_calls":[{"function":{"arguments":"","name":"Read"},"id":"call_1","index":0`) || strings.Contains(text, `"index":2`) { + t.Fatalf("chat tool stream = %s", text) + } +} + +func TestConvertResponsesStreamChatPreservesAnnotations(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, "", + `event: response.output_text.annotation.added`, + `data: {"type":"response.output_text.annotation.added","annotation":{"type":"url_citation","url":"https://example.com","title":"Example"}}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed"}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), OperationChat)) + if err != nil { + t.Fatal(err) + } + if text := string(converted); !strings.Contains(text, `"annotations":[{"title":"Example","type":"url_citation","url":"https://example.com"}]`) { + t.Fatalf("chat annotation stream = %s", text) + } +} + +func TestConvertResponsesStreamMessagesInputTokens(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5","status":"in_progress"}}`, "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"hello"}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","usage":{"input_tokens":194,"output_tokens":7,"cost_in_usd_ticks":9000,"context_details":{"input_tokens":180,"output_tokens":6}}}}`, "", "", + }, "\n") + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), OperationMessages)) + if err != nil { + t.Fatal(err) + } + text := string(converted) + + if !strings.Contains(text, `"input_tokens":194`) { + t.Fatalf("message_delta should contain input_tokens from response.completed usage:\n%s", text) + } + if !strings.Contains(text, `"cost_in_usd_ticks":9000`) || !strings.Contains(text, `"input_tokens":180`) { + t.Fatalf("message_delta should retain upstream usage extensions:\n%s", text) + } +} + +func TestConvertResponsesStreamMergesPartialUsageFrames(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_usage","model":"grok-4.5","status":"in_progress","usage":{"input_tokens":120,"output_tokens":30,"total_tokens":150,"cost_in_usd_ticks":9000,"output_tokens_details":{"reasoning_tokens":12},"context_details":{"input_tokens":110,"output_tokens":25}}}}`, "", + `event: response.in_progress`, + `data: {"type":"response.in_progress","response":{"usage":{"input_tokens_details":{"cached_tokens":80}}}}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_usage","model":"grok-4.5","status":"completed"}}`, "", "", + }, "\n") + + tests := []struct { + operation string + want []string + }{ + {operation: OperationChat, want: []string{`"prompt_tokens":120`, `"completion_tokens":30`, `"cached_tokens":80`, `"reasoning_tokens":12`, `"cost_in_usd_ticks":9000`}}, + {operation: OperationMessages, want: []string{`"input_tokens":40`, `"output_tokens":30`, `"cache_read_input_tokens":80`, `"cost_in_usd_ticks":9000`}}, + } + for _, test := range tests { + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), test.operation)) + if err != nil { + t.Fatalf("%s conversion: %v", test.operation, err) + } + text := string(converted) + for _, want := range test.want { + if !strings.Contains(text, want) { + t.Fatalf("%s partial usage lost %s:\n%s", test.operation, want, text) + } + } + } +} diff --git a/backend/internal/infra/provider/conversation/messages_request.go b/backend/internal/infra/provider/conversation/messages_request.go new file mode 100644 index 0000000000000000000000000000000000000000..b1cf09a2f490fef3f0ae0735da8f70ae4570781d --- /dev/null +++ b/backend/internal/infra/provider/conversation/messages_request.go @@ -0,0 +1,773 @@ +package conversation + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +const anthropicBillingHeaderPrefix = "x-anthropic-billing-header: " + +func convertMessagesRequest(body []byte, model string) ([]byte, ResponseOptions, error) { + var request anthropicRequest + if err := json.Unmarshal(body, &request); err != nil { + return nil, ResponseOptions{}, fmt.Errorf("解析 Messages 请求: %w", err) + } + if len(request.Messages) == 0 { + return nil, ResponseOptions{}, errors.New("messages 必须是非空数组") + } + if request.MaxTokens <= 0 { + return nil, ResponseOptions{}, errors.New("max_tokens 必须是正整数") + } + for name, value := range map[string]*float64{"temperature": request.Temperature, "top_p": request.TopP} { + if value != nil && (*value < 0 || *value > 1) { + return nil, ResponseOptions{}, fmt.Errorf("%s 必须在 0 到 1 之间", name) + } + } + for index, sequence := range request.StopSequences { + if sequence == "" { + return nil, ResponseOptions{}, fmt.Errorf("stop_sequences[%d] 不能为空", index) + } + } + thinkingEnabled := false + if request.Thinking != nil { + switch request.Thinking.Type { + case "", "disabled": + case "enabled", "adaptive": + thinkingEnabled = true + default: + return nil, ResponseOptions{}, fmt.Errorf("不支持 thinking.type=%q", request.Thinking.Type) + } + } + input, inlineInstructions, err := convertAnthropicMessages(request.Messages, anthropicDeclaredToolNames(request.Tools)) + if err != nil { + return nil, ResponseOptions{}, err + } + if len(input) == 0 { + return nil, ResponseOptions{}, errors.New("messages 中没有可发送的 user 或 assistant 内容") + } + target := map[string]any{ + "model": model, "input": input, "stream": request.Stream, + "max_output_tokens": request.MaxTokens, "store": false, + } + instructions := make([]string, 0, len(inlineInstructions)) + if system, err := anthropicSystemText(request.System); err != nil { + return nil, ResponseOptions{}, err + } else if system != "" { + instructions = append(instructions, system) + } + instructions = append(instructions, inlineInstructions...) + if len(instructions) > 0 { + target["instructions"] = strings.Join(instructions, "\n\n") + } + copyOptionalNumber(target, "temperature", request.Temperature) + copyOptionalNumber(target, "top_p", request.TopP) + if request.Metadata != nil { + if userID, _ := request.Metadata["user_id"].(string); strings.TrimSpace(userID) != "" { + target["safety_identifier"] = strings.TrimSpace(userID) + } + } + if request.OutputConfig != nil && request.OutputConfig.Format != nil { + if request.OutputConfig.Format.Type != "json_schema" || request.OutputConfig.Format.Schema == nil { + return nil, ResponseOptions{}, errors.New("output_config.format 必须是带 schema 的 json_schema") + } + target["text"] = map[string]any{"format": map[string]any{"type": "json_schema", "name": "anthropic_output", "schema": request.OutputConfig.Format.Schema}} + } + hasWebSearchTool := hasAnthropicWebSearchTool(request.Tools) + webSearchEnabled := hasWebSearchTool && (request.ToolChoice == nil || !strings.EqualFold(strings.TrimSpace(request.ToolChoice.Type), "none")) + webSearchRequired := webSearchEnabled && anthropicWebSearchRequired(request.Tools, request.ToolChoice) + webSearchQuery := "" + if webSearchEnabled { + webSearchQuery = anthropicWebSearchQuery(request.Messages) + } + if thinkingEnabled { + effort := anthropicThinkingEffort(request.Thinking.BudgetTokens) + if request.OutputConfig != nil && request.OutputConfig.Effort != "" { + effort = request.OutputConfig.Effort + } + switch effort { + case "minimal": + effort = "low" + case "low", "medium", "high", "xhigh", "max": + default: + return nil, ResponseOptions{}, fmt.Errorf("不支持 output_config.effort=%q", effort) + } + target["reasoning"] = map[string]any{"effort": effort, "summary": "detailed"} + target["include"] = []any{"reasoning.encrypted_content"} + } + if len(request.Tools) > 0 { + tools, err := convertAnthropicTools(request.Tools) + if err != nil { + return nil, ResponseOptions{}, err + } + target["tools"] = tools + } + if len(request.MCPServers) > 0 { + servers, err := convertAnthropicMCPServers(request.MCPServers) + if err != nil { + return nil, ResponseOptions{}, err + } + existing, _ := target["tools"].([]any) + target["tools"] = append(existing, servers...) + } + if request.ToolChoice != nil { + choice, parallel, err := convertAnthropicToolChoice(*request.ToolChoice, hasWebSearchTool) + if err != nil { + return nil, ResponseOptions{}, err + } + target["tool_choice"] = choice + target["parallel_tool_calls"] = parallel + } + converted, err := json.Marshal(target) + return converted, ResponseOptions{ + AnthropicThinking: thinkingEnabled, + AnthropicWebSearch: webSearchEnabled, + AnthropicWebSearchRequired: webSearchRequired, + AnthropicWebSearchQuery: webSearchQuery, + StopSequences: append([]string(nil), request.StopSequences...), + }, err +} + +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []anthropicMessage `json:"messages"` + System json.RawMessage `json:"system"` + Stream bool `json:"stream"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + StopSequences []string `json:"stop_sequences"` + Metadata map[string]any `json:"metadata"` + Thinking *struct { + Type string `json:"type"` + BudgetTokens int `json:"budget_tokens"` + } `json:"thinking"` + TopK json.RawMessage `json:"top_k"` + MCPServers []anthropicMCPServer `json:"mcp_servers"` + OutputConfig *struct { + Effort string `json:"effort"` + Format *struct { + Type string `json:"type"` + Schema map[string]any `json:"schema"` + } `json:"format"` + } `json:"output_config"` + Tools []map[string]json.RawMessage `json:"tools"` + ToolChoice *anthropicToolChoice `json:"tool_choice"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +type anthropicToolChoice struct { + Type string `json:"type"` + Name string `json:"name"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use"` +} + +func convertAnthropicMessages(messages []anthropicMessage, declaredTools map[string]struct{}) ([]any, []string, error) { + input := make([]any, 0, len(messages)) + instructions := make([]string, 0) + pendingCalls := make(map[string]struct{}) + usedCalls := make(map[string]struct{}) + serverSearches := make(map[string]map[string]any) + for messageIndex, message := range messages { + role := strings.ToLower(strings.TrimSpace(message.Role)) + if role == "system" || role == "developer" { + text, err := anthropicSystemText(message.Content) + if err != nil { + return nil, nil, fmt.Errorf("messages[%d] %s 内容无效: %w", messageIndex, role, err) + } + if text != "" { + instructions = append(instructions, text) + } + continue + } + if role != "user" && role != "assistant" { + return nil, nil, fmt.Errorf("Messages API 不支持 role=%q", message.Role) + } + if len(pendingCalls) > 0 && role != "user" { + return nil, nil, fmt.Errorf("messages[%d] 必须是包含 tool_result 的 user 消息", messageIndex) + } + var text string + if json.Unmarshal(message.Content, &text) == nil { + if len(pendingCalls) > 0 { + return nil, nil, fmt.Errorf("messages[%d] 必须返回全部待处理 tool_use", messageIndex) + } + input = append(input, map[string]any{"type": "message", "role": role, "content": text}) + continue + } + var blocks []map[string]json.RawMessage + if json.Unmarshal(message.Content, &blocks) != nil { + return nil, nil, fmt.Errorf("messages[%d].content 必须是字符串或内容块数组", messageIndex) + } + hadPending := len(pendingCalls) > 0 + regularBeforeResult := false + messageParts := make([]any, 0, len(blocks)) + flushMessage := func() { + if len(messageParts) > 0 { + input = append(input, map[string]any{"type": "message", "role": role, "content": messageParts}) + messageParts = nil + } + } + for blockIndex, block := range blocks { + path := fmt.Sprintf("messages[%d].content[%d]", messageIndex, blockIndex) + var typeName string + _ = json.Unmarshal(block["type"], &typeName) + switch typeName { + case "text": + regularBeforeResult = regularBeforeResult || len(pendingCalls) > 0 + var value string + if json.Unmarshal(block["text"], &value) != nil { + return nil, nil, fmt.Errorf("%s.text 无效", path) + } + messageParts = append(messageParts, map[string]any{"type": "input_text", "text": value}) + case "image": + regularBeforeResult = regularBeforeResult || len(pendingCalls) > 0 + imageURL, err := anthropicImageURL(block["source"]) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", path, err) + } + messageParts = append(messageParts, map[string]any{"type": "input_image", "image_url": imageURL}) + case "document": + regularBeforeResult = regularBeforeResult || len(pendingCalls) > 0 + document, err := anthropicDocument(block) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", path, err) + } + messageParts = append(messageParts, document) + case "tool_use": + if role != "assistant" { + return nil, nil, fmt.Errorf("%s tool_use 只允许出现在 assistant 消息", path) + } + flushMessage() + var value struct { + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } + if encoded, _ := json.Marshal(block); json.Unmarshal(encoded, &value) != nil || strings.TrimSpace(value.ID) == "" || strings.TrimSpace(value.Name) == "" || value.Input == nil { + return nil, nil, fmt.Errorf("%s 缺少有效 id、name 或 object input", path) + } + if _, exists := usedCalls[value.ID]; exists { + return nil, nil, fmt.Errorf("%s 包含重复 tool_use id %q", path, value.ID) + } + arguments, _ := json.Marshal(value.Input) + input = append(input, map[string]any{"type": "function_call", "call_id": value.ID, "name": value.Name, "arguments": string(arguments)}) + pendingCalls[value.ID] = struct{}{} + usedCalls[value.ID] = struct{}{} + case "tool_result": + if role != "user" { + return nil, nil, fmt.Errorf("%s tool_result 只允许出现在 user 消息", path) + } + flushMessage() + var toolUseID string + _ = json.Unmarshal(block["tool_use_id"], &toolUseID) + if _, exists := pendingCalls[toolUseID]; strings.TrimSpace(toolUseID) == "" || !exists { + return nil, nil, fmt.Errorf("%s.tool_use_id %q 未匹配待处理 tool_use", path, toolUseID) + } + if regularBeforeResult { + return nil, nil, fmt.Errorf("%s tool_result 必须位于文本、图片或文档块之前", path) + } + output, err := anthropicToolResult(block["content"], declaredTools) + if err != nil { + return nil, nil, fmt.Errorf("%s.content: %w", path, err) + } + if raw := block["is_error"]; !isEmptyJSON(raw) { + var isError bool + if json.Unmarshal(raw, &isError) != nil { + return nil, nil, fmt.Errorf("%s.is_error 必须是布尔值", path) + } + if isError { + output = markAnthropicToolError(output) + } + } + input = append(input, map[string]any{"type": "function_call_output", "call_id": toolUseID, "output": output}) + delete(pendingCalls, toolUseID) + case "thinking": + if role != "assistant" { + return nil, nil, fmt.Errorf("%s thinking 只允许出现在 assistant 消息", path) + } + flushMessage() + var thinking, signature string + _ = json.Unmarshal(block["thinking"], &thinking) + _ = json.Unmarshal(block["signature"], &signature) + item := map[string]any{"type": "reasoning", "summary": []any{map[string]any{"type": "summary_text", "text": thinking}}} + if signature != "" { + item["encrypted_content"] = signature + } + input = append(input, item) + case "redacted_thinking": + if role != "assistant" { + return nil, nil, fmt.Errorf("%s redacted_thinking 只允许出现在 assistant 消息", path) + } + flushMessage() + var data string + if json.Unmarshal(block["data"], &data) != nil || data == "" { + return nil, nil, fmt.Errorf("%s.data 无效", path) + } + // Grok Build requires summary to exist whenever encrypted_content is replayed. + // An empty array is the canonical Anthropic redacted_thinking representation. + input = append(input, map[string]any{"type": "reasoning", "summary": []any{}, "encrypted_content": data}) + case "server_tool_use": + if role != "assistant" { + continue + } + var value struct { + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } + encoded, _ := json.Marshal(block) + if json.Unmarshal(encoded, &value) != nil || value.Name != "web_search" || strings.TrimSpace(value.ID) == "" { + continue + } + flushMessage() + query, _ := value.Input["query"].(string) + call := map[string]any{ + "type": "web_search_call", "id": value.ID, "status": "completed", + "action": map[string]any{"type": "search", "query": query}, + } + serverSearches[value.ID] = call + input = append(input, call) + case "web_search_tool_result": + if role != "assistant" { + continue + } + var toolUseID string + _ = json.Unmarshal(block["tool_use_id"], &toolUseID) + call := serverSearches[strings.TrimSpace(toolUseID)] + if call == nil { + continue + } + applyAnthropicWebSearchResult(call, block["content"]) + default: + return nil, nil, fmt.Errorf("当前不支持 Anthropic content.type=%q", typeName) + } + } + flushMessage() + if hadPending && len(pendingCalls) > 0 { + return nil, nil, fmt.Errorf("messages[%d] 必须返回全部待处理 tool_use", messageIndex) + } + } + if len(pendingCalls) > 0 { + return nil, nil, errors.New("messages 必须为每个 tool_use 提供 tool_result") + } + return input, instructions, nil +} + +func applyAnthropicWebSearchResult(call map[string]any, raw json.RawMessage) { + var results []map[string]any + if json.Unmarshal(raw, &results) == nil { + sources := make([]any, 0, len(results)) + for _, result := range results { + if result["type"] != "web_search_result" { + continue + } + url, _ := result["url"].(string) + if strings.TrimSpace(url) != "" { + sources = append(sources, map[string]any{"type": "url", "url": strings.TrimSpace(url)}) + } + } + if action, ok := call["action"].(map[string]any); ok && len(sources) > 0 { + action["sources"] = sources + } + return + } + var result map[string]any + if json.Unmarshal(raw, &result) == nil && result["type"] == "web_search_tool_result_error" { + call["status"] = "failed" + } +} + +func anthropicSystemText(raw json.RawMessage) (string, error) { + if isEmptyJSON(raw) { + return "", nil + } + var text string + if json.Unmarshal(raw, &text) == nil { + if isAnthropicBillingHeaderText(text) { + return "", nil + } + return text, nil + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &blocks) != nil { + return "", errors.New("system 必须是字符串或 text block 数组") + } + parts := make([]string, 0, len(blocks)) + for _, block := range blocks { + if block.Type != "text" { + return "", fmt.Errorf("system 不支持 type=%q", block.Type) + } + // Claude Code identity blocks may carry a per-request cch fingerprint and are not model instructions. + // Keeping them during Build conversion changes the cache prefix each turn and reduces cache hits. + if isAnthropicBillingHeaderText(block.Text) { + continue + } + parts = append(parts, block.Text) + } + return strings.Join(parts, "\n\n"), nil +} + +func isAnthropicBillingHeaderText(text string) bool { + return strings.HasPrefix(strings.TrimLeft(text, " \t\r\n"), anthropicBillingHeaderPrefix) +} + +func anthropicImageURL(raw json.RawMessage) (string, error) { + var source struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + URL string `json:"url"` + } + if json.Unmarshal(raw, &source) != nil { + return "", errors.New("image.source 无效") + } + switch source.Type { + case "base64": + if source.MediaType == "" || source.Data == "" { + return "", errors.New("base64 image 缺少 media_type 或 data") + } + return "data:" + source.MediaType + ";base64," + source.Data, nil + case "url": + if strings.TrimSpace(source.URL) == "" { + return "", errors.New("url image 缺少 url") + } + return source.URL, nil + default: + return "", fmt.Errorf("不支持 image.source.type=%q", source.Type) + } +} + +func anthropicDocument(block map[string]json.RawMessage) (map[string]any, error) { + var source struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + URL string `json:"url"` + } + if json.Unmarshal(block["source"], &source) != nil { + return nil, errors.New("document.source 无效") + } + var title string + _ = json.Unmarshal(block["title"], &title) + switch source.Type { + case "text": + if source.Data == "" { + return nil, errors.New("text document 缺少 data") + } + return map[string]any{"type": "input_text", "text": source.Data}, nil + case "url": + if strings.TrimSpace(source.URL) == "" { + return nil, errors.New("url document 缺少 url") + } + value := map[string]any{"type": "input_file", "file_url": source.URL} + if title != "" { + value["filename"] = title + } + return value, nil + case "base64": + if source.MediaType == "" || source.Data == "" { + return nil, errors.New("base64 document 缺少 media_type 或 data") + } + value := map[string]any{"type": "input_file", "file_data": "data:" + source.MediaType + ";base64," + source.Data} + if title != "" { + value["filename"] = title + } + return value, nil + default: + return nil, fmt.Errorf("不支持 document.source.type=%q", source.Type) + } +} + +func anthropicToolResult(raw json.RawMessage, declaredTools map[string]struct{}) (any, error) { + if isEmptyJSON(raw) { + return "", nil + } + var text string + if json.Unmarshal(raw, &text) == nil { + return text, nil + } + var blocks []map[string]json.RawMessage + if json.Unmarshal(raw, &blocks) != nil { + return "", errors.New("tool_result.content 无效") + } + parts := make([]any, 0, len(blocks)) + for _, block := range blocks { + var typeName string + _ = json.Unmarshal(block["type"], &typeName) + switch typeName { + case "text": + var value string + if json.Unmarshal(block["text"], &value) != nil { + return nil, errors.New("tool_result text 无效") + } + parts = append(parts, map[string]any{"type": "input_text", "text": value}) + case "image": + imageURL, err := anthropicImageURL(block["source"]) + if err != nil { + return nil, err + } + parts = append(parts, map[string]any{"type": "input_image", "detail": "auto", "image_url": imageURL}) + case "document": + document, err := anthropicDocument(block) + if err != nil { + return nil, err + } + parts = append(parts, document) + case "tool_reference": + var toolName string + if json.Unmarshal(block["tool_name"], &toolName) != nil || strings.TrimSpace(toolName) == "" { + return nil, errors.New("tool_reference.tool_name 无效") + } + toolName = strings.TrimSpace(toolName) + if _, exists := declaredTools[toolName]; !exists { + return nil, fmt.Errorf("tool_reference 引用了未声明的工具 %q", toolName) + } + // Responses has no Anthropic tool_reference content block. All tool definitions from Messages + // have already been sent upstream, so deterministic result text preserves the search-hit meaning. + parts = append(parts, map[string]any{ + "type": "input_text", + "text": fmt.Sprintf("Tool search matched declared tool %q; its definition is available in this request.", toolName), + }) + default: + return nil, fmt.Errorf("tool_result 暂不支持 type=%q", typeName) + } + } + return parts, nil +} + +func anthropicDeclaredToolNames(tools []map[string]json.RawMessage) map[string]struct{} { + declared := make(map[string]struct{}, len(tools)) + for _, tool := range tools { + var name string + _ = json.Unmarshal(tool["name"], &name) + if name = strings.TrimSpace(name); name != "" { + declared[name] = struct{}{} + } + } + return declared +} + +func markAnthropicToolError(output any) any { + const prefix = "Tool execution failed: " + if text, ok := output.(string); ok { + return prefix + text + } + parts, _ := output.([]any) + return append([]any{map[string]any{"type": "input_text", "text": prefix}}, parts...) +} + +func convertAnthropicTools(tools []map[string]json.RawMessage) ([]any, error) { + result := make([]any, 0, len(tools)) + for index, tool := range tools { + var typeName string + _ = json.Unmarshal(tool["type"], &typeName) + if strings.HasPrefix(typeName, "web_search_") { + converted, err := convertAnthropicWebSearchTool(tool, index) + if err != nil { + return nil, err + } + result = append(result, converted) + continue + } + if typeName != "" && typeName != "custom" { + return nil, fmt.Errorf("当前不支持 Anthropic server tool type=%q", typeName) + } + var name, description string + _ = json.Unmarshal(tool["name"], &name) + _ = json.Unmarshal(tool["description"], &description) + if strings.TrimSpace(name) == "" { + return nil, errors.New("Anthropic tool 缺少 name") + } + var schema any = map[string]any{"type": "object", "properties": map[string]any{}} + if raw := tool["input_schema"]; !isEmptyJSON(raw) { + if json.Unmarshal(raw, &schema) != nil { + return nil, fmt.Errorf("tool %q 的 input_schema 无效", name) + } + } + converted := map[string]any{"type": "function", "name": name, "description": description, "parameters": schema} + var strict bool + if raw := tool["strict"]; !isEmptyJSON(raw) { + if json.Unmarshal(raw, &strict) != nil { + return nil, fmt.Errorf("tool %q 的 strict 必须是布尔值", name) + } + converted["strict"] = strict + } + result = append(result, converted) + } + return result, nil +} + +func hasAnthropicWebSearchTool(tools []map[string]json.RawMessage) bool { + for _, tool := range tools { + var typeName string + _ = json.Unmarshal(tool["type"], &typeName) + if strings.HasPrefix(typeName, "web_search_") { + return true + } + } + return false +} + +func anthropicWebSearchRequired(tools []map[string]json.RawMessage, choice *anthropicToolChoice) bool { + if choice == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(choice.Type)) { + case "tool": + return strings.EqualFold(strings.TrimSpace(choice.Name), "web_search") + case "any": + return len(tools) == 1 && hasAnthropicWebSearchTool(tools) + default: + return false + } +} + +func anthropicWebSearchQuery(messages []anthropicMessage) string { + const prefix = "perform a web search for the query:" + for index := len(messages) - 1; index >= 0; index-- { + if !strings.EqualFold(strings.TrimSpace(messages[index].Role), "user") { + continue + } + text := anthropicMessageText(messages[index].Content) + if text == "" { + continue + } + if offset := strings.Index(strings.ToLower(text), prefix); offset >= 0 { + text = strings.TrimSpace(text[offset+len(prefix):]) + } + return truncateRunes(strings.TrimSpace(text), 4096) + } + return "" +} + +func anthropicMessageText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return strings.TrimSpace(text) + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &blocks) != nil { + return "" + } + parts := make([]string, 0, len(blocks)) + for _, block := range blocks { + if block.Type == "text" && strings.TrimSpace(block.Text) != "" { + parts = append(parts, strings.TrimSpace(block.Text)) + } + } + return strings.Join(parts, "\n") +} + +func truncateRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func convertAnthropicWebSearchTool(tool map[string]json.RawMessage, index int) (map[string]any, error) { + converted := map[string]any{"type": "web_search"} + for key, raw := range tool { + switch key { + case "type", "name", "cache_control": + continue + case "allowed_domains": + var value any + if json.Unmarshal(raw, &value) != nil { + return nil, fmt.Errorf("tools[%d].%s 无效", index, key) + } + domains, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("tools[%d].%s 必须是字符串数组", index, key) + } + if len(domains) > 5 { + return nil, fmt.Errorf("tools[%d].%s 不能超过 5 个域名", index, key) + } + for domainIndex, domain := range domains { + if text, ok := domain.(string); !ok || strings.TrimSpace(text) == "" { + return nil, fmt.Errorf("tools[%d].%s[%d] 必须是非空字符串", index, key, domainIndex) + } + } + converted["filters"] = map[string]any{"allowed_domains": value} + case "max_uses", "blocked_domains", "user_location", "search_context_size": + // Build 0.2.110 supports only allowed_domains. Do not forward other optional Anthropic controls, + // preventing unknown parameters from causing the upstream to reject the request. + continue + default: + // Preserve forward compatibility with future hosted-tool optional fields. + continue + } + } + return converted, nil +} + +type anthropicMCPServer struct { + Name string `json:"name"` + URL string `json:"url"` + AuthorizationToken string `json:"authorization_token"` +} + +func convertAnthropicMCPServers(servers []anthropicMCPServer) ([]any, error) { + result := make([]any, 0, len(servers)) + for index, server := range servers { + name := strings.TrimSpace(server.Name) + url := strings.TrimSpace(server.URL) + if name == "" || url == "" { + return nil, fmt.Errorf("mcp_servers[%d] 缺少 name 或 url", index) + } + tool := map[string]any{"type": "mcp", "server_label": name, "server_url": url} + if server.AuthorizationToken != "" { + tool["authorization"] = server.AuthorizationToken + } + result = append(result, tool) + } + return result, nil +} + +func anthropicThinkingEffort(budget int) string { + switch { + case budget > 0 && budget <= 2048: + return "low" + case budget > 10000: + return "high" + default: + return "medium" + } +} + +func convertAnthropicToolChoice(choice anthropicToolChoice, hasHostedWebSearch bool) (any, bool, error) { + parallel := !choice.DisableParallelToolUse + switch choice.Type { + case "auto", "none": + return choice.Type, parallel, nil + case "any": + return "required", parallel, nil + case "tool": + if strings.TrimSpace(choice.Name) == "" { + return nil, false, errors.New("tool_choice.tool 缺少 name") + } + // Claude Code secondary search uses a hosted tool with name=web_search. + // When only one hosted tool remains, Grok Build accepts only required tool_choice. + if hasHostedWebSearch && strings.EqualFold(strings.TrimSpace(choice.Name), "web_search") { + return "required", parallel, nil + } + return map[string]any{"type": "function", "name": choice.Name}, parallel, nil + default: + return nil, false, fmt.Errorf("不支持 tool_choice.type=%q", choice.Type) + } +} diff --git a/backend/internal/infra/provider/conversation/messages_response.go b/backend/internal/infra/provider/conversation/messages_response.go new file mode 100644 index 0000000000000000000000000000000000000000..dea95a2c798b554625292ac2c34efab6730a83d2 --- /dev/null +++ b/backend/internal/infra/provider/conversation/messages_response.go @@ -0,0 +1,171 @@ +package conversation + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +func messagesResponse(value parsedResponse, options ResponseOptions) map[string]any { + webSearch := value.WebSearch + if options.AnthropicWebSearchRequired && len(webSearch) == 0 { + webSearch = []webSearchCall{unavailableWebSearchCall(options.AnthropicWebSearchQuery)} + } + // Avoid capacity arithmetic on untrusted upstream lengths (CodeQL size overflow). + // appendServerWebSearchContent already hard-caps search blocks. + content := make([]any, 0) + if options.AnthropicThinking && (value.Reasoning != "" || value.Signature != "") { + if value.Reasoning == "" { + content = append(content, map[string]any{"type": "redacted_thinking", "data": value.Signature}) + } else { + thinking := map[string]any{"type": "thinking", "thinking": value.Reasoning} + if value.Signature != "" { + thinking["signature"] = value.Signature + } + content = append(content, thinking) + } + } + // Hosted web search completes in-process; emit Anthropic server tool blocks first. + content = appendServerWebSearchContent(content, webSearch) + if value.Text != "" || (len(value.Calls) == 0 && len(webSearch) == 0) { + content = append(content, map[string]any{"type": "text", "text": value.Text}) + } + for _, call := range value.Calls { + var input any = map[string]any{} + if json.Unmarshal([]byte(call.Arguments), &input) != nil { + input = map[string]any{} + } + content = append(content, map[string]any{"type": "tool_use", "id": anthropicToolUseID(call.CallID), "name": call.Name, "input": input}) + } + stopReason := "end_turn" + if len(value.Calls) > 0 { + // Client tools still pause the turn. Hosted web_search does not. + stopReason = "tool_use" + } else if value.StopSequence != "" { + stopReason = "stop_sequence" + } else if value.Refusal != "" { + stopReason = "refusal" + } else if value.Status == "incomplete" { + stopReason = "max_tokens" + } + return map[string]any{ + "id": anthropicMessageID(value.ID), "type": "message", "role": "assistant", + "model": value.Model, "content": content, "stop_reason": stopReason, "stop_sequence": nullableAnthropicString(value.StopSequence), + "usage": anthropicUsage(value.Usage, webSearchRequestCount(webSearch)), + } +} + +func applyStopSequences(text string, sequences []string) (string, string) { + matchAt := -1 + matched := "" + for _, sequence := range sequences { + if sequence == "" { + continue + } + if index := strings.Index(text, sequence); index >= 0 && (matchAt < 0 || index < matchAt) { + matchAt = index + matched = sequence + } + } + if matchAt < 0 { + return text, "" + } + return text[:matchAt], matched +} + +func anthropicMessageID(value string) string { + if strings.HasPrefix(value, "msg_") { + return value + } + if strings.HasPrefix(value, "resp_") { + return "msg_" + strings.TrimPrefix(value, "resp_") + } + if value == "" { + return fmt.Sprintf("msg_%d", time.Now().UnixNano()) + } + return "msg_" + value +} + +func anthropicToolUseID(value string) string { + if strings.HasPrefix(value, "toolu_") { + return value + } + if value == "" { + return fmt.Sprintf("toolu_%d", time.Now().UnixNano()) + } + return "toolu_" + value +} + +func nullableAnthropicString(value string) any { + if value == "" { + return nil + } + return value +} + +func anthropicUsage(value responseUsage, webSearchRequests int) map[string]any { + inputTokens := max(int64(0), value.InputTokens) + cacheReadInputTokens := min(inputTokens, max(int64(0), value.InputTokensDetails.CachedTokens)) + usage := map[string]any{ + "input_tokens": inputTokens - cacheReadInputTokens, "output_tokens": value.OutputTokens, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": cacheReadInputTokens, + "cost_in_usd_ticks": value.CostInUSDTicks, + "num_sources_used": value.NumSourcesUsed, + "num_server_side_tools_used": value.NumServerSideToolsUsed, + "context_details": map[string]any{ + "input_tokens": value.ContextDetails.InputTokens, "output_tokens": value.ContextDetails.OutputTokens, + }, + } + if webSearchRequests > 0 { + usage["server_tool_use"] = map[string]any{"web_search_requests": webSearchRequests} + } + return usage +} + +func anthropicErrorJSON(value any) []byte { + data, _ := json.Marshal(map[string]any{"type": "error", "error": normalizeAnthropicError(value)}) + return data +} + +func normalizeAnthropicError(value any) map[string]any { + message := "Upstream request failed" + errorType := "api_error" + if object, ok := value.(map[string]any); ok { + if text, ok := object["message"].(string); ok && text != "" { + message = text + } + errorType = normalizeAnthropicErrorType(object) + } else if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + message = text + } + return map[string]any{"type": errorType, "message": message} +} + +func normalizeAnthropicErrorType(object map[string]any) string { + for _, field := range []string{"type", "code"} { + value, _ := object[field].(string) + switch strings.ToLower(strings.TrimSpace(value)) { + case "invalid_request_error", "authentication_error", "billing_error", "permission_error", + "not_found_error", "rate_limit_error", "timeout_error", "overloaded_error", "api_error": + return strings.ToLower(strings.TrimSpace(value)) + case "unsupported_parameter", "invalid_parameter", "bad_request", "invalid_request": + return "invalid_request_error" + case "unauthorized", "authentication_failed": + return "authentication_error" + case "forbidden": + return "permission_error" + case "not_found": + return "not_found_error" + case "rate_limit_exceeded", "too_many_requests": + return "rate_limit_error" + case "request_timeout", "timeout": + return "timeout_error" + case "service_unavailable", "overloaded": + return "overloaded_error" + case "server_error", "internal_error": + return "api_error" + } + } + return "api_error" +} diff --git a/backend/internal/infra/provider/conversation/messages_stream.go b/backend/internal/infra/provider/conversation/messages_stream.go new file mode 100644 index 0000000000000000000000000000000000000000..5a2b1d0599ce41b6397f6902e472acd33d94623a --- /dev/null +++ b/backend/internal/infra/provider/conversation/messages_stream.go @@ -0,0 +1,290 @@ +package conversation + +import ( + "sort" + "strings" +) + +func (c *streamConverter) startMessages() error { + return c.writeEvent("message_start", map[string]any{ + "type": "message_start", "message": map[string]any{ + "id": anthropicMessageID(c.id), "type": "message", "role": "assistant", + "model": c.model, "content": []any{}, "stop_reason": nil, "stop_sequence": nil, + "usage": anthropicUsage(c.usage, 0), + }, + }) +} + +func (c *streamConverter) textDeltaMessages(delta string) error { + if !c.textStarted { + c.textStarted = true + c.textIndex = c.nextIndex + c.nextIndex++ + if err := c.writeEvent("content_block_start", map[string]any{"type": "content_block_start", "index": c.textIndex, "content_block": map[string]any{"type": "text", "text": ""}}); err != nil { + return err + } + } + emit, matched := c.stopFilter.Push(delta) + if matched != "" { + c.stopSequence = matched + } + if emit == "" { + return nil + } + return c.writeEvent("content_block_delta", map[string]any{"type": "content_block_delta", "index": c.textIndex, "delta": map[string]any{"type": "text_delta", "text": emit}}) +} + +func (c *streamConverter) thinkingStart(itemID string) error { + if !c.options.AnthropicThinking || c.thinkingStarted { + return nil + } + if err := c.start(); err != nil { + return err + } + c.thinkingStarted = true + c.thinkingIndex = c.nextIndex + c.nextIndex++ + c.thinkingItemID = itemID + return c.writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": c.thinkingIndex, + "content_block": map[string]any{"type": "thinking", "thinking": ""}, + }) +} + +func (c *streamConverter) thinkingDelta(delta string) error { + if c.operation != OperationMessages || !c.options.AnthropicThinking { + return nil + } + if err := c.thinkingStart(""); err != nil { + return err + } + return c.writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": c.thinkingIndex, + "delta": map[string]any{"type": "thinking_delta", "thinking": delta}, + }) +} + +func (c *streamConverter) thinkingDone(item responseItem) error { + if c.operation != OperationMessages || !c.options.AnthropicThinking || !c.thinkingStarted || c.thinkingClosed { + return nil + } + if item.Encrypted != "" { + if err := c.writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": c.thinkingIndex, + "delta": map[string]any{"type": "signature_delta", "signature": item.Encrypted}, + }); err != nil { + return err + } + } + c.thinkingClosed = true + return c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": c.thinkingIndex}) +} + +func (c *streamConverter) textDeltaWithoutFilter(delta string) error { + if delta == "" { + return nil + } + if !c.textStarted { + c.textStarted = true + c.textIndex = c.nextIndex + c.nextIndex++ + if err := c.writeEvent("content_block_start", map[string]any{"type": "content_block_start", "index": c.textIndex, "content_block": map[string]any{"type": "text", "text": ""}}); err != nil { + return err + } + } + return c.writeEvent("content_block_delta", map[string]any{"type": "content_block_delta", "index": c.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta}}) +} + +type anthropicStreamStopFilter struct { + sequences []string + pending string + matched string +} + +func newAnthropicStreamStopFilter(sequences []string) *anthropicStreamStopFilter { + filtered := make([]string, 0, len(sequences)) + for _, sequence := range sequences { + if sequence != "" { + filtered = append(filtered, sequence) + } + } + return &anthropicStreamStopFilter{sequences: filtered} +} + +func (f *anthropicStreamStopFilter) Push(delta string) (string, string) { + if f == nil || len(f.sequences) == 0 { + return delta, "" + } + if f.matched != "" { + return "", f.matched + } + f.pending += delta + matchAt := -1 + matched := "" + for _, sequence := range f.sequences { + if index := strings.Index(f.pending, sequence); index >= 0 && (matchAt < 0 || index < matchAt) { + matchAt = index + matched = sequence + } + } + if matchAt >= 0 { + emit := f.pending[:matchAt] + f.pending = "" + f.matched = matched + return emit, matched + } + hold := 0 + for _, sequence := range f.sequences { + maxPrefix := min(len(sequence)-1, len(f.pending)) + for size := maxPrefix; size > hold; size-- { + if strings.HasSuffix(f.pending, sequence[:size]) { + hold = size + break + } + } + } + emitAt := len(f.pending) - hold + emit := f.pending[:emitAt] + f.pending = f.pending[emitAt:] + return emit, "" +} + +func (f *anthropicStreamStopFilter) Flush() string { + if f == nil || f.matched != "" { + return "" + } + value := f.pending + f.pending = "" + return value +} + +func (c *streamConverter) toolStartMessages(item responseItem) error { + if err := c.start(); err != nil { + return err + } + tool := streamTool{Index: c.nextIndex, ID: anthropicToolUseID(item.CallID), Name: item.Name, Arguments: item.Arguments} + c.nextIndex++ + c.tools[item.ID] = tool + return c.writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": tool.Index, + "content_block": map[string]any{"type": "tool_use", "id": tool.ID, "name": tool.Name, "input": map[string]any{}}, + }) +} + +func (c *streamConverter) toolDeltaMessages(itemID, delta string) error { + tool, ok := c.tools[itemID] + if !ok { + return nil + } + tool.SentArgs = true + c.tools[itemID] = tool + return c.writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": tool.Index, + "delta": map[string]any{"type": "input_json_delta", "partial_json": delta}, + }) +} + +func (c *streamConverter) toolArgumentsDoneMessages(itemID, arguments string) error { + tool, ok := c.tools[itemID] + if !ok || tool.Closed { + return nil + } + if !tool.SentArgs { + if arguments == "" { + arguments = tool.Arguments + } + if arguments != "" { + if err := c.writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": tool.Index, + "delta": map[string]any{"type": "input_json_delta", "partial_json": arguments}, + }); err != nil { + return err + } + tool.SentArgs = true + } + } + tool.Closed = true + c.tools[itemID] = tool + return c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": tool.Index}) +} + +func (c *streamConverter) doneMessages(status string) error { + if c.thinkingStarted && !c.thinkingClosed { + c.thinkingClosed = true + if err := c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": c.thinkingIndex}); err != nil { + return err + } + } + if c.options.AnthropicWebSearchRequired && len(c.webSearch) == 0 { + c.webSearch = []webSearchCall{unavailableWebSearchCall(c.options.AnthropicWebSearchQuery)} + } + if c.deferSearchText { + // Hosted search was observed before text, so preserve Anthropic's block order: + // server_tool_use → web_search_tool_result → text. + if err := c.emitPendingWebSearchResults(); err != nil { + return err + } + if pending := c.pendingSearchText.String(); pending != "" { + if err := c.textDeltaMessages(pending); err != nil { + return err + } + } + } + if c.stopSequence == "" { + if pending := c.stopFilter.Flush(); pending != "" { + if err := c.textDeltaWithoutFilter(pending); err != nil { + return err + } + } + } + if c.textStarted { + if err := c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": c.textIndex}); err != nil { + return err + } + } + if !c.deferSearchText { + // If text arrived before the search item, close it before starting any server + // tool blocks so content blocks never overlap. + if err := c.emitPendingWebSearchResults(); err != nil { + return err + } + } + openTools := make([]streamTool, 0) + for itemID, tool := range c.tools { + if !tool.Closed { + tool.Closed = true + c.tools[itemID] = tool + openTools = append(openTools, tool) + } + } + sort.Slice(openTools, func(i, j int) bool { return openTools[i].Index < openTools[j].Index }) + for _, tool := range openTools { + if err := c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": tool.Index}); err != nil { + return err + } + } + stopReason := "end_turn" + // Only client function tools force tool_use stop. Hosted web_search is end_turn. + if len(c.tools) > 0 { + stopReason = "tool_use" + } else if c.stopSequence != "" { + stopReason = "stop_sequence" + } else if c.refused { + stopReason = "refusal" + } else if status == "incomplete" { + stopReason = "max_tokens" + } + usage := anthropicUsage(c.usage, webSearchRequestCount(c.webSearch)) + if err := c.writeEvent("message_delta", map[string]any{ + "type": "message_delta", "delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nullableAnthropicString(c.stopSequence)}, + "usage": usage, + }); err != nil { + return err + } + c.finished = true + return c.writeEvent("message_stop", map[string]any{"type": "message_stop"}) +} + +func (c *streamConverter) streamErrorMessages(data []byte) error { + return c.writeEvent("error", map[string]any{"type": "error", "error": normalizeAnthropicError(streamErrorValue(data))}) +} diff --git a/backend/internal/infra/provider/conversation/request.go b/backend/internal/infra/provider/conversation/request.go new file mode 100644 index 0000000000000000000000000000000000000000..a1a2e062525cc815d5aa3f002ba270f736e21e8e --- /dev/null +++ b/backend/internal/infra/provider/conversation/request.go @@ -0,0 +1,110 @@ +package conversation + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +const ( + OperationResponses = "responses" + OperationCompaction = "compaction" + OperationChat = "chat" + OperationMessages = "messages" +) + +// ConvertRequest 将下游对话协议转换为 Responses 请求,作为 Provider 的统一上游协议。 +func ConvertRequest(body []byte, model, operation string) ([]byte, error) { + converted, _, err := ConvertRequestWithOptions(body, model, operation) + return converted, err +} + +// ConvertRequestWithOptions 同时返回下游协议特有的响应语义,供 JSON/SSE 转换阶段使用。 +func ConvertRequestWithOptions(body []byte, model, operation string) ([]byte, ResponseOptions, error) { + switch operation { + case OperationChat: + return convertChatRequest(body, model) + case OperationMessages: + return convertMessagesRequest(body, model) + default: + converted, err := replaceModel(body, model) + return converted, ResponseOptions{}, err + } +} + +func replaceModel(body []byte, model string) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("解析 Responses 请求: %w", err) + } + payload["model"] = mustJSON(model) + return json.Marshal(payload) +} + +func convertResponseFormat(raw json.RawMessage) (json.RawMessage, error) { + var format map[string]json.RawMessage + if json.Unmarshal(raw, &format) != nil { + return nil, errors.New("response_format 无效") + } + var typeName string + _ = json.Unmarshal(format["type"], &typeName) + if typeName != "json_schema" || isEmptyJSON(format["json_schema"]) { + return raw, nil + } + var schema map[string]json.RawMessage + if json.Unmarshal(format["json_schema"], &schema) != nil { + return nil, errors.New("response_format.json_schema 无效") + } + result := map[string]json.RawMessage{"type": mustJSON("json_schema")} + for key, value := range schema { + result[key] = value + } + return mustJSON(result), nil +} + +func contentAsText(raw json.RawMessage) (string, error) { + var value string + if json.Unmarshal(raw, &value) == nil { + return value, nil + } + var arbitrary any + if json.Unmarshal(raw, &arbitrary) != nil { + return "", errors.New("tool content 无效") + } + encoded, _ := json.Marshal(arbitrary) + return string(encoded), nil +} + +func copyFields(target, source map[string]json.RawMessage, names ...string) { + for _, name := range names { + if raw := source[name]; !isEmptyJSON(raw) { + target[name] = raw + } + } +} + +func copyOptionalNumber(target map[string]any, name string, value *float64) { + if value != nil { + target[name] = *value + } +} + +func firstJSON(values ...json.RawMessage) json.RawMessage { + for _, value := range values { + if !isEmptyJSON(value) { + return value + } + } + return nil +} + +func isEmptyJSON(raw json.RawMessage) bool { + value := bytes.TrimSpace(raw) + return len(value) == 0 || bytes.Equal(value, []byte("null")) +} + +func mustJSON(value any) json.RawMessage { + encoded, _ := json.Marshal(value) + return encoded +} diff --git a/backend/internal/infra/provider/conversation/response.go b/backend/internal/infra/provider/conversation/response.go new file mode 100644 index 0000000000000000000000000000000000000000..603c539d2e90a856e44de4049bf9e19bfe99833d --- /dev/null +++ b/backend/internal/infra/provider/conversation/response.go @@ -0,0 +1,176 @@ +package conversation + +import ( + "encoding/json" + "fmt" + "time" +) + +// ResponseOptions 保留无法直接交给 Responses 上游执行的下游协议语义。 +type ResponseOptions struct { + AnthropicThinking bool + AnthropicWebSearch bool + AnthropicWebSearchRequired bool + AnthropicWebSearchQuery string + StopSequences []string +} + +type responseEnvelope struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + Output []responseItem `json:"output"` + Usage responseUsage `json:"usage"` + Error any `json:"error"` +} + +type responseItem struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + Content []responseContent `json:"content"` + Summary []responseContent `json:"summary"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + Encrypted string `json:"encrypted_content"` + // Action is populated for Build hosted tool items such as web_search_call. + Action map[string]any `json:"action"` +} + +type responseContent struct { + Type string `json:"type"` + Text string `json:"text"` + Refusal string `json:"refusal"` + Annotations []any `json:"annotations"` +} + +type responseUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + CostInUSDTicks int64 `json:"cost_in_usd_ticks"` + NumSourcesUsed int64 `json:"num_sources_used"` + NumServerSideToolsUsed int64 `json:"num_server_side_tools_used"` + InputTokensDetails struct { + CachedTokens int64 `json:"cached_tokens"` + } `json:"input_tokens_details"` + OutputTokensDetails struct { + ReasoningTokens int64 `json:"reasoning_tokens"` + } `json:"output_tokens_details"` + ContextDetails struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + } `json:"context_details"` +} + +type parsedResponse struct { + ID string + Model string + CreatedAt int64 + Text string + Reasoning string + Signature string + Refusal string + Calls []responseItem + WebSearch []webSearchCall + Annotations []any + Usage responseUsage + Status string + StopSequence string +} + +// ConvertResponseJSON 将 Responses 非流式结果转换为 Chat Completions 或 Anthropic Messages。 +func ConvertResponseJSON(body []byte, operation string) ([]byte, error) { + return ConvertResponseJSONWithOptions(body, operation, ResponseOptions{}) +} + +// ConvertResponseJSONWithOptions 按下游协议选项恢复 thinking、搜索与 stop sequence。 +func ConvertResponseJSONWithOptions(body []byte, operation string, options ResponseOptions) ([]byte, error) { + if operation == OperationResponses { + return body, nil + } + var envelope responseEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("解析 Responses 响应: %w", err) + } + if envelope.Error != nil { + if operation == OperationMessages { + return anthropicErrorJSON(envelope.Error), nil + } + return body, nil + } + parsed := parseResponse(envelope) + if operation == OperationMessages || operation == OperationChat { + parsed.Text, parsed.StopSequence = applyStopSequences(parsed.Text, options.StopSequences) + } + if operation == OperationMessages { + if !options.AnthropicWebSearch { + parsed.WebSearch = nil + } + } + var result any + if operation == OperationMessages { + result = messagesResponse(parsed, options) + } else { + result = chatResponse(parsed) + } + return json.Marshal(result) +} + +func parseResponse(value responseEnvelope) parsedResponse { + parsed := parsedResponse{ID: value.ID, Model: value.Model, CreatedAt: value.CreatedAt, Usage: value.Usage, Status: value.Status} + if parsed.CreatedAt == 0 { + parsed.CreatedAt = time.Now().Unix() + } + var annotations []map[string]any + for _, item := range value.Output { + switch item.Type { + case "message": + annotations = append(annotations, extractMessageAnnotations(item)...) + for _, content := range item.Content { + parsed.Annotations = append(parsed.Annotations, content.Annotations...) + switch content.Type { + case "output_text": + parsed.Text += content.Text + case "refusal": + parsed.Refusal += content.Refusal + } + } + case "reasoning": + reasoning := "" + for _, content := range item.Content { + if content.Type == "reasoning_text" { + reasoning += content.Text + } + } + if reasoning == "" { + for _, summary := range item.Summary { + reasoning += summary.Text + } + } + parsed.Reasoning += reasoning + if item.Encrypted != "" { + parsed.Signature = item.Encrypted + } + case "function_call": + parsed.Calls = append(parsed.Calls, item) + case "web_search_call": + // Cap candidates early so pathological upstream envelopes cannot + // retain unbounded intermediate search state before dedupe. + if len(parsed.WebSearch) >= maxWebSearchCalls { + continue + } + if call, ok := parseWebSearchCallItem(item); ok { + parsed.WebSearch = append(parsed.WebSearch, call) + } + } + } + if len(parsed.WebSearch) > 0 { + parsed.WebSearch = dedupeWebSearchCalls(parsed.WebSearch) + parsed.WebSearch = mergeAnnotationTitles(parsed.WebSearch, annotations) + } + return parsed +} diff --git a/backend/internal/infra/provider/conversation/server_web_search.go b/backend/internal/infra/provider/conversation/server_web_search.go new file mode 100644 index 0000000000000000000000000000000000000000..9df448d9333c4961cab8c7a61f4f63a0436c60d7 --- /dev/null +++ b/backend/internal/infra/provider/conversation/server_web_search.go @@ -0,0 +1,341 @@ +package conversation + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/chenyme/grok2api/backend/internal/infra/provider/searchresult" +) + +// webSearchHit is the minimal Claude Code WebSearchTool success payload. +type webSearchHit struct { + Title string + URL string +} + +// webSearchCall is one Build web_search_call mapped for Anthropic Messages. +type webSearchCall struct { + ID string + Query string + Hits []webSearchHit + Failed bool + Code string +} + +const maxWebSearchCalls = 32 + +func unavailableWebSearchCall(query string) webSearchCall { + fallback := map[string]string{"type": "web_search", "query": query, "error": "unavailable"} + return webSearchCall{ + ID: anthropicServerToolUseID("", fallback), Query: query, + Failed: true, Code: "unavailable", + } +} + +func anthropicServerToolUseID(raw string, fallback any) string { + original := raw + raw = strings.TrimPrefix(raw, "srvtoolu_") + if raw == "" { + encoded := []byte(original) + if original == "" { + encoded, _ = json.Marshal(fallback) + } + sum := sha256.Sum256(encoded) + return "srvtoolu_" + hex.EncodeToString(sum[:16]) + } + // Build ids are long; keep stable prefix for multi-block pairing. + cleaned := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + return r + } + return '_' + }, raw) + if len(cleaned) > 48 || cleaned != raw { + sum := sha256.Sum256([]byte(original)) + if len(cleaned) > 31 { + cleaned = cleaned[:31] + } + cleaned += "_" + hex.EncodeToString(sum[:8]) + } + return "srvtoolu_" + cleaned +} + +func parseWebSearchCallItem(item responseItem) (webSearchCall, bool) { + if item.Type != "web_search_call" { + return webSearchCall{}, false + } + action := item.Action + call := webSearchCall{ID: anthropicServerToolUseID(item.ID, action)} + if action == nil { + call.Failed = true + call.Code = "unavailable" + return call, true + } + if q, _ := action["query"].(string); strings.TrimSpace(q) != "" { + call.Query = truncateRunes(strings.TrimSpace(q), 4096) + } + // Prefer action.sources[].url + if rawSources, ok := action["sources"].([]any); ok { + seen := make(map[string]struct{}, len(rawSources)) + for _, raw := range rawSources { + if len(call.Hits) >= searchresult.MaxResults { + break + } + source, _ := raw.(map[string]any) + if source == nil { + continue + } + link, _ := source["url"].(string) + link, valid := searchresult.NormalizeURL(link) + if !valid { + continue + } + if _, exists := seen[link]; exists { + continue + } + seen[link] = struct{}{} + title, _ := source["title"].(string) + title = searchresult.NormalizeTitle(title, titleFromURL(link)) + call.Hits = append(call.Hits, webSearchHit{Title: title, URL: link}) + } + } + // Fallback: message annotations often have better titles; filled later by merge. + status := strings.ToLower(strings.TrimSpace(item.Status)) + if (status == "failed" || status == "incomplete") && len(call.Hits) == 0 { + call.Failed = true + call.Code = "unavailable" + } + return call, true +} + +func titleFromURL(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return raw + } + return parsed.Host +} + +func mergeAnnotationTitles(calls []webSearchCall, annotations []map[string]any) []webSearchCall { + if len(annotations) == 0 || len(calls) == 0 { + return calls + } + titles := make(map[string]string) + for _, ann := range annotations { + if strings.TrimSpace(fmt.Sprint(ann["type"])) != "url_citation" { + // nested url_citation object + if nested, ok := ann["url_citation"].(map[string]any); ok { + ann = nested + } else { + continue + } + } + link, _ := ann["url"].(string) + title, _ := ann["title"].(string) + link, valid := searchresult.NormalizeURL(link) + title = strings.TrimSpace(title) + if !valid || title == "" { + continue + } + if unhelpfulCitationTitle(title) { + continue + } + titles[link] = searchresult.NormalizeTitle(title, link) + } + for i := range calls { + for j := range calls[i].Hits { + if better, ok := titles[calls[i].Hits[j].URL]; ok { + calls[i].Hits[j].Title = better + } + } + } + return calls +} + +func unhelpfulCitationTitle(title string) bool { + value := strings.ToLower(strings.TrimSpace(title)) + if value == "" { + return true + } + isDigits := func(candidate string) bool { + if candidate == "" { + return false + } + for _, r := range candidate { + if r < '0' || r > '9' { + return false + } + } + return true + } + if isDigits(value) { + return true + } + for _, prefix := range []string{"source", "citation"} { + if strings.HasPrefix(value, prefix) && isDigits(strings.Trim(strings.TrimPrefix(value, prefix), " #:-")) { + return true + } + } + return false +} + +func extractMessageAnnotations(item responseItem) []map[string]any { + if item.Type != "message" { + return nil + } + var out []map[string]any + for _, content := range item.Content { + for _, ann := range content.Annotations { + if m, ok := ann.(map[string]any); ok { + out = append(out, m) + } else { + // re-marshal generic + raw, _ := json.Marshal(ann) + var m map[string]any + if json.Unmarshal(raw, &m) == nil { + out = append(out, m) + } + } + } + } + return out +} + +// dedupeWebSearchCalls keeps one entry per call id, preferring the payload with +// more hits / a non-empty query. Build sometimes repeats web_search_call items. +func dedupeWebSearchCalls(calls []webSearchCall) []webSearchCall { + if len(calls) == 0 { + return nil + } + order := make([]string, 0, len(calls)) + best := make(map[string]webSearchCall, len(calls)) + for _, call := range calls { + call.Hits = boundedWebSearchHits(call.Hits) + if !call.Failed && strings.TrimSpace(call.Query) == "" && len(call.Hits) == 0 { + continue + } + id := call.ID + if id == "" { + if len(order) >= maxWebSearchCalls { + continue + } + order = append(order, fmt.Sprintf("__anon_%d", len(order))) + best[order[len(order)-1]] = call + continue + } + prev, exists := best[id] + if !exists { + if len(order) >= maxWebSearchCalls { + continue + } + order = append(order, id) + best[id] = call + continue + } + // Prefer richer payload. + score := func(c webSearchCall) int { + n := len(c.Hits) * 10 + if strings.TrimSpace(c.Query) != "" { + n += 5 + } + if !c.Failed { + n += 1 + } + return n + } + if score(call) >= score(prev) { + // Keep non-empty query from either side. + if call.Query == "" { + call.Query = prev.Query + } + // Union hits by URL if both have sources. + if len(prev.Hits) > 0 && len(call.Hits) > 0 { + call.Hits = boundedWebSearchHits(call.Hits, prev.Hits) + } else if len(call.Hits) == 0 { + call.Hits = prev.Hits + } + best[id] = call + } else if prev.Query == "" && call.Query != "" { + prev.Query = call.Query + best[id] = prev + } + } + out := make([]webSearchCall, 0, len(order)) + for _, id := range order { + out = append(out, best[id]) + } + return out +} + +func boundedWebSearchHits(groups ...[]webSearchHit) []webSearchHit { + seen := make(map[string]struct{}, searchresult.MaxResults) + bounded := make([]webSearchHit, 0, searchresult.MaxResults) + for _, hits := range groups { + for _, hit := range hits { + if len(bounded) >= searchresult.MaxResults { + return bounded + } + if _, exists := seen[hit.URL]; exists { + continue + } + seen[hit.URL] = struct{}{} + bounded = append(bounded, hit) + } + } + return bounded +} + +func appendServerWebSearchContent(content []any, calls []webSearchCall) []any { + calls = dedupeWebSearchCalls(calls) + for _, call := range calls { + content = append(content, map[string]any{ + "type": "server_tool_use", + "id": call.ID, + "name": "web_search", + "input": map[string]any{"query": call.Query}, + }) + if call.Failed { + code := call.Code + if code == "" { + code = "unavailable" + } + content = append(content, map[string]any{ + "type": "web_search_tool_result", + "tool_use_id": call.ID, + "content": map[string]any{ + "type": "web_search_tool_result_error", + "error_code": code, + }, + }) + continue + } + hits := make([]any, 0, len(call.Hits)) + for _, hit := range call.Hits { + hits = append(hits, map[string]any{ + "type": "web_search_result", + "title": hit.Title, + "url": hit.URL, + }) + } + content = append(content, map[string]any{ + "type": "web_search_tool_result", + "tool_use_id": call.ID, + "content": hits, + }) + } + return content +} + +func webSearchRequestCount(calls []webSearchCall) int { + return len(dedupeWebSearchCalls(calls)) +} + +func queryJSONPartial(query string) string { + // Stable single-shot partial JSON for stream input_json_delta (CC regex-parses "query"). + raw, _ := json.Marshal(map[string]string{"query": query}) + return string(raw) +} diff --git a/backend/internal/infra/provider/conversation/server_web_search_test.go b/backend/internal/infra/provider/conversation/server_web_search_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5f9e743ec6195d3f78460a83a42412a4a48c9459 --- /dev/null +++ b/backend/internal/infra/provider/conversation/server_web_search_test.go @@ -0,0 +1,929 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "io" + "reflect" + "strconv" + "strings" + "testing" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/infra/provider/searchresult" +) + +func TestParseAndMapBuildWebSearchCall(t *testing.T) { + body := []byte(`{ + "id":"resp_ws1","model":"grok-4.5","status":"completed","created_at":123, + "output":[ + {"type":"web_search_call","id":"ws_abc","status":"completed","action":{ + "type":"search","query":"Claude Fable 5", + "sources":[ + {"type":"url","url":"https://example.com/a"}, + {"type":"url","url":"https://example.com/b","title":"Beta"} + ] + }}, + {"type":"web_search_call","id":"ws_abc","status":"completed","action":{"type":"search"}}, + {"type":"web_search_call","id":"ws_abc","status":"completed","action":{"type":"search","query":""}}, + {"type":"message","role":"assistant","content":[ + {"type":"output_text","text":"Fable 5 is public.","annotations":[ + {"type":"url_citation","url":"https://EXAMPLE.com/a","title":"Alpha Title","start_index":0,"end_index":5} + ]} + ]} + ], + "usage":{"input_tokens":10,"output_tokens":5} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + if err != nil { + t.Fatal(err) + } + var msg map[string]any + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatal(err) + } + if msg["stop_reason"] != "end_turn" { + t.Fatalf("stop_reason = %#v", msg["stop_reason"]) + } + content, _ := msg["content"].([]any) + if len(content) < 3 { + t.Fatalf("content = %#v", content) + } + use := content[0].(map[string]any) + if use["type"] != "server_tool_use" || use["name"] != "web_search" { + t.Fatalf("server_tool_use = %#v", use) + } + input := use["input"].(map[string]any) + if input["query"] != "Claude Fable 5" { + t.Fatalf("query = %#v", input) + } + result := content[1].(map[string]any) + if result["type"] != "web_search_tool_result" || result["tool_use_id"] != use["id"] { + t.Fatalf("result = %#v", result) + } + hits, _ := result["content"].([]any) + if len(hits) != 2 { + t.Fatalf("hits = %#v", hits) + } + h0 := hits[0].(map[string]any) + if h0["url"] != "https://example.com/a" || h0["title"] != "Alpha Title" { + t.Fatalf("hit0 title from annotation expected Alpha Title, got %#v", h0) + } + text := content[2].(map[string]any) + if text["type"] != "text" || text["text"] != "Fable 5 is public." { + t.Fatalf("text = %#v", text) + } + usage := msg["usage"].(map[string]any) + stu := usage["server_tool_use"].(map[string]any) + if stu["web_search_requests"] != float64(1) { + t.Fatalf("usage = %#v", usage) + } + // Duplicate empty web_search_call items must collapse to one pair of blocks. + serverUses := 0 + for _, raw := range content { + if block, _ := raw.(map[string]any); block["type"] == "server_tool_use" { + serverUses++ + } + } + if serverUses != 1 { + t.Fatalf("expected 1 deduped server_tool_use, got %d in %#v", serverUses, content) + } +} + +func TestMergeAnnotationTitlesSkipsFootnoteLabels(t *testing.T) { + for _, title := range []string{"123", "Source 1", "citation 2"} { + calls := []webSearchCall{{Hits: []webSearchHit{{Title: "example.com", URL: "https://example.com/a"}}}} + merged := mergeAnnotationTitles(calls, []map[string]any{{ + "type": "url_citation", "url": "https://example.com/a", "title": title, + }}) + if got := merged[0].Hits[0].Title; got != "example.com" { + t.Fatalf("footnote title %q replaced fallback with %q", title, got) + } + } +} + +func TestUnrequestedWebSearchItemsAreNotExposed(t *testing.T) { + body := []byte(`{ + "id":"resp_ws_unrequested","model":"grok-4.5","status":"completed", + "output":[ + {"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"private internal search","sources":[{"url":"https://example.com"}]}}, + {"type":"message","content":[{"type":"output_text","text":"Plain answer."}]} + ], + "usage":{"input_tokens":3,"output_tokens":2} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{}) + if err != nil { + t.Fatal(err) + } + var msg map[string]any + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatal(err) + } + content := msg["content"].([]any) + if len(content) != 1 || content[0].(map[string]any)["type"] != "text" { + t.Fatalf("unrequested server search was exposed: %#v", content) + } + if _, exists := msg["usage"].(map[string]any)["server_tool_use"]; exists { + t.Fatalf("unrequested search usage was exposed: %#v", msg["usage"]) + } +} + +func TestUnrequestedWebSearchStreamItemsAreNotExposed(t *testing.T) { + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"private internal search","sources":[{"url":"https://example.com"}]}}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Plain answer."}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"private internal search","sources":[{"url":"https://example.com"}]}},{"type":"message","content":[{"type":"output_text","text":"Plain answer."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + raw, err := io.ReadAll(ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, ResponseOptions{})) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if strings.Contains(text, "server_tool_use") || strings.Contains(text, "web_search_tool_result") || !strings.Contains(text, "Plain answer.") { + t.Fatalf("unrequested stream search was exposed:\n%s", text) + } +} + +func TestRequestedBuildSearchWithoutCallEmitsUnavailableResult(t *testing.T) { + _, options, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"Perform a web search for the query: rust tutorials"}], + "tools":[{"type":"web_search_20250305","name":"web_search"}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + body := []byte(`{ + "id":"resp_no_call","model":"grok-4.5","status":"completed", + "output":[{"type":"message","content":[{"type":"output_text","text":"Search was unavailable."}]}], + "usage":{"input_tokens":3,"output_tokens":2} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, options) + if err != nil { + t.Fatal(err) + } + var message map[string]any + if err := json.Unmarshal(data, &message); err != nil { + t.Fatal(err) + } + content := message["content"].([]any) + if len(content) != 3 || content[0].(map[string]any)["type"] != "server_tool_use" || content[1].(map[string]any)["type"] != "web_search_tool_result" || content[2].(map[string]any)["text"] != "Search was unavailable." { + t.Fatalf("content = %#v", content) + } + use := content[0].(map[string]any) + if use["input"].(map[string]any)["query"] != "rust tutorials" || content[1].(map[string]any)["tool_use_id"] != use["id"] { + t.Fatalf("fallback linkage = %#v", content) + } + errorContent := content[1].(map[string]any)["content"].(map[string]any) + if errorContent["type"] != "web_search_tool_result_error" || errorContent["error_code"] != "unavailable" { + t.Fatalf("fallback error = %#v", errorContent) + } + usage := message["usage"].(map[string]any)["server_tool_use"].(map[string]any) + if usage["web_search_requests"] != float64(1) { + t.Fatalf("usage = %#v", usage) + } +} + +func TestOptionalBuildSearchWithoutCallRemainsPlainText(t *testing.T) { + _, options, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"answer with or without search"}], + "tools":[{"type":"web_search_20250305","name":"web_search"}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + body := []byte(`{ + "id":"resp_optional","model":"grok-4.5","status":"completed", + "output":[{"type":"message","content":[{"type":"output_text","text":"Plain answer."}]}], + "usage":{"input_tokens":3,"output_tokens":2} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, options) + if err != nil { + t.Fatal(err) + } + var message map[string]any + if err := json.Unmarshal(data, &message); err != nil { + t.Fatal(err) + } + content := message["content"].([]any) + if len(content) != 1 || content[0].(map[string]any)["type"] != "text" || content[0].(map[string]any)["text"] != "Plain answer." { + t.Fatalf("optional search fabricated server blocks: %#v", content) + } + if _, exists := message["usage"].(map[string]any)["server_tool_use"]; exists { + t.Fatalf("optional search fabricated usage: %#v", message["usage"]) + } +} + +func TestRequestedBuildSearchStreamWithoutCallEmitsUnavailableBeforeText(t *testing.T) { + _, options, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"stream":true, + "messages":[{"role":"user","content":"Perform a web search for the query: rust tutorials"}], + "tools":[{"type":"web_search_20250305","name":"web_search"}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_no_call","model":"grok-4.5"}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Search was unavailable."}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_no_call","model":"grok-4.5","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Search was unavailable."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + raw, err := io.ReadAll(ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, options)) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if got, want := contentBlockStartTypes(t, text), []string{"server_tool_use", "web_search_tool_result", "text"}; !reflect.DeepEqual(got, want) { + t.Fatalf("content block order = %v, want %v\n%s", got, want, text) + } + if !strings.Contains(text, `"error_code":"unavailable"`) || !strings.Contains(text, `"web_search_requests":1`) || !strings.Contains(text, "rust tutorials") { + t.Fatalf("missing unavailable fallback: %s", text) + } +} + +func TestConvertAnthropicWebSearchToolChoiceRequired(t *testing.T) { + converted, options, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"Perform a web search for the query: x"}], + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":8}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + _ = json.Unmarshal(converted, &payload) + tools := payload["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["type"] != "web_search" { + t.Fatalf("tools = %#v", tools) + } + if payload["tool_choice"] != "required" { + t.Fatalf("tool_choice = %#v", payload["tool_choice"]) + } + if !options.AnthropicWebSearchRequired { + t.Fatalf("forced hosted search was not retained in response options: %#v", options) + } +} + +func TestConvertAnthropicWebSearchHistoryBackToResponses(t *testing.T) { + converted, _, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[ + {"role":"assistant","content":[ + {"type":"server_tool_use","id":"ws_1","name":"web_search","input":{"query":"rust docs"}}, + {"type":"web_search_tool_result","tool_use_id":"ws_1","content":[{"type":"web_search_result","title":"Rust","url":"https://doc.rust-lang.org"}]}, + {"type":"text","text":"Use the Rust docs."} + ]}, + {"role":"user","content":"continue"} + ] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + input := payload["input"].([]any) + if len(input) != 3 { + t.Fatalf("input = %#v", input) + } + call := input[0].(map[string]any) + action := call["action"].(map[string]any) + sources := action["sources"].([]any) + if call["type"] != "web_search_call" || call["id"] != "ws_1" || action["query"] != "rust docs" || sources[0].(map[string]any)["url"] != "https://doc.rust-lang.org" { + t.Fatalf("web search history = %#v", call) + } +} + +func TestClientWebSearchFunctionNotPromoted(t *testing.T) { + converted, _, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"search"}], + "tools":[{"name":"WebSearch","description":"Search","input_schema":{"type":"object","properties":{"query":{"type":"string"}}}}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + _ = json.Unmarshal(converted, &payload) + tools := payload["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("tools = %#v", tools) + } + tool := tools[0].(map[string]any) + if tool["type"] != "function" || tool["name"] != "WebSearch" { + t.Fatalf("client WebSearch must remain function, got %#v", tool) + } +} + +func TestClientLowercaseWebSearchToolChoiceRemainsFunction(t *testing.T) { + converted, _, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"search"}], + "tools":[{"name":"web_search","description":"Search","input_schema":{"type":"object","properties":{"query":{"type":"string"}}}}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(converted, &payload); err != nil { + t.Fatal(err) + } + want := map[string]any{"type": "function", "name": "web_search"} + if got := payload["tool_choice"]; !reflect.DeepEqual(got, want) { + t.Fatalf("client function tool_choice = %#v, want %#v", got, want) + } +} + +func TestResponseOptionsRetainOnlyExplicitWebSearchQuery(t *testing.T) { + _, searchOptions, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: rust tutorials"}]}], + "tools":[{"type":"web_search_20250305","name":"web_search"}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + if !searchOptions.AnthropicWebSearch || searchOptions.AnthropicWebSearchRequired || searchOptions.AnthropicWebSearchQuery != "rust tutorials" { + t.Fatalf("search options = %#v", searchOptions) + } + + _, regularOptions, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"private user prompt"}] + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + if regularOptions.AnthropicWebSearch || regularOptions.AnthropicWebSearchQuery != "" { + t.Fatalf("regular request retained prompt data: %#v", regularOptions) + } +} + +func TestWebSearchToolChoiceNoneDisablesResponseMapping(t *testing.T) { + _, options, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64, + "messages":[{"role":"user","content":"Perform a web search for the query: private query"}], + "tools":[{"type":"web_search_20250305","name":"web_search"}], + "tool_choice":{"type":"none"} + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + if options.AnthropicWebSearch || options.AnthropicWebSearchQuery != "" { + t.Fatalf("tool_choice none retained web search state: %#v", options) + } +} + +func TestMapBuildWebSearchFiltersEmptyDistinctCalls(t *testing.T) { + body := []byte(`{ + "id":"resp_ws_empty","model":"grok-4.5","status":"completed", + "output":[ + {"type":"web_search_call","id":"ws_real","status":"completed","action":{ + "type":"search","query":"rust tutorials", + "sources":[{"type":"url","url":"https://doc.rust-lang.org"}] + }}, + {"type":"web_search_call","id":"ws_empty_1","status":"completed","action":{"type":"search"}}, + {"type":"web_search_call","id":"ws_empty_2","status":"completed","action":{"type":"search","query":""}}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"Here you go."}]} + ], + "usage":{"input_tokens":3,"output_tokens":2} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + if err != nil { + t.Fatal(err) + } + var msg map[string]any + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatal(err) + } + content := msg["content"].([]any) + serverUses := 0 + results := 0 + for _, raw := range content { + block := raw.(map[string]any) + switch block["type"] { + case "server_tool_use": + serverUses++ + case "web_search_tool_result": + results++ + } + } + if serverUses != 1 || results != 1 { + t.Fatalf("expected one real search pair, got uses=%d results=%d content=%#v", serverUses, results, content) + } + usage := msg["usage"].(map[string]any)["server_tool_use"].(map[string]any) + if usage["web_search_requests"] != float64(1) { + t.Fatalf("usage must count only real searches: %#v", usage) + } +} + +func TestMapBuildWebSearchDerivesDistinctMissingIDs(t *testing.T) { + body := []byte(`{ + "id":"resp_ws_missing_ids","model":"grok-4.5","status":"completed", + "output":[ + {"type":"web_search_call","status":"completed","action":{"type":"search","query":"rust","sources":[{"url":"https://www.rust-lang.org"}]}}, + {"type":"web_search_call","status":"completed","action":{"type":"search","query":"go","sources":[{"url":"https://go.dev"}]}} + ], + "usage":{"input_tokens":3,"output_tokens":2} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + if err != nil { + t.Fatal(err) + } + var msg map[string]any + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatal(err) + } + content := msg["content"].([]any) + var ids []string + for _, raw := range content { + block := raw.(map[string]any) + if block["type"] == "server_tool_use" { + ids = append(ids, block["id"].(string)) + } + } + if len(ids) != 2 || ids[0] == ids[1] { + t.Fatalf("missing upstream IDs must derive distinct stable IDs, got %v content=%#v", ids, content) + } + usage := msg["usage"].(map[string]any)["server_tool_use"].(map[string]any) + if usage["web_search_requests"] != float64(2) { + t.Fatalf("usage = %#v, want two searches", usage) + } +} + +func TestAnthropicServerToolUseIDDoesNotCollideOnLongCommonSuffix(t *testing.T) { + suffix := strings.Repeat("z", 48) + first := anthropicServerToolUseID("first-prefix-"+suffix, nil) + second := anthropicServerToolUseID("second-prefix-"+suffix, nil) + if first == second { + t.Fatalf("long upstream IDs collided: %q", first) + } +} + +func TestAnthropicServerToolUseIDNormalizesPrefixedUntrustedID(t *testing.T) { + raw := "srvtoolu_" + strings.Repeat("a", 80) + " bad\n" + got := anthropicServerToolUseID(raw, nil) + if !strings.HasPrefix(got, "srvtoolu_") || len(got) > 64 { + t.Fatalf("normalized id = %q (len=%d)", got, len(got)) + } + if strings.IndexFunc(got, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') + }) >= 0 { + t.Fatalf("normalized id retained unsafe characters: %q", got) + } + if again := anthropicServerToolUseID(raw, nil); again != got { + t.Fatalf("normalization is unstable: first=%q second=%q", got, again) + } +} + +func TestMapBuildWebSearchFiltersUnsafeAndDuplicateSources(t *testing.T) { + body := []byte(`{ + "id":"resp_ws_sources","model":"grok-4.5","status":"completed", + "output":[{"type":"web_search_call","id":"ws_1","status":"completed","action":{ + "type":"search","query":"security", + "sources":[ + {"url":"https://example.com/a","title":"Example"}, + {"url":"https://example.com/a","title":"Duplicate"}, + {"url":"javascript:alert(1)","title":"Script"}, + {"url":"file:///etc/passwd","title":"File"}, + {"url":"/relative/path","title":"Relative"}, + {"url":"https://user:secret@example.com/private","title":"Credential URL"} + ] + }}], + "usage":{"input_tokens":3,"output_tokens":2} + }`) + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + if err != nil { + t.Fatal(err) + } + var msg map[string]any + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatal(err) + } + content := msg["content"].([]any) + hits := content[1].(map[string]any)["content"].([]any) + if len(hits) != 1 { + t.Fatalf("unsafe or duplicate sources leaked into hits: %#v", hits) + } + hit := hits[0].(map[string]any) + if hit["url"] != "https://example.com/a" || hit["title"] != "Example" { + t.Fatalf("hit = %#v", hit) + } +} + +func TestParseBuildWebSearchBoundsResultsAndTitles(t *testing.T) { + sources := make([]any, 0, 60) + for index := 0; index < 60; index++ { + sources = append(sources, map[string]any{ + "url": "https://example.com/" + strconv.Itoa(index), + "title": strings.Repeat("界", 600), + }) + } + call, ok := parseWebSearchCallItem(responseItem{ + ID: "ws_bounds", Type: "web_search_call", Status: "completed", + Action: map[string]any{"query": "bounds", "sources": sources}, + }) + if !ok { + t.Fatal("web search call was not parsed") + } + if len(call.Hits) != 50 { + t.Fatalf("hits = %d, want 50", len(call.Hits)) + } + if got := utf8.RuneCountInString(call.Hits[0].Title); got != 512 { + t.Fatalf("title runes = %d, want 512", got) + } +} + +func TestParseResponseCapsWebSearchCandidatesBeforeDedupe(t *testing.T) { + output := make([]any, 0, maxWebSearchCalls+20) + for index := 0; index < maxWebSearchCalls+20; index++ { + output = append(output, map[string]any{ + "type": "web_search_call", "id": "ws_" + strconv.Itoa(index), "status": "completed", + "action": map[string]any{ + "type": "search", "query": "q" + strconv.Itoa(index), + "sources": []any{map[string]any{"type": "url", "url": "https://example.com/" + strconv.Itoa(index)}}, + }, + }) + } + body, err := json.Marshal(map[string]any{ + "id": "resp_cap", "model": "grok-4.5", "status": "completed", "created_at": 1, + "output": output, "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, + }) + if err != nil { + t.Fatal(err) + } + data, err := ConvertResponseJSONWithOptions(body, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + if err != nil { + t.Fatal(err) + } + var msg map[string]any + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatal(err) + } + content, _ := msg["content"].([]any) + uses := 0 + for _, block := range content { + m, _ := block.(map[string]any) + if m["type"] == "server_tool_use" { + uses++ + } + } + if uses != maxWebSearchCalls { + t.Fatalf("server_tool_use count = %d, want %d", uses, maxWebSearchCalls) + } +} + +func TestDedupeWebSearchCallsBoundsCallCount(t *testing.T) { + calls := make([]webSearchCall, 0, 40) + for index := 0; index < 40; index++ { + calls = append(calls, webSearchCall{ + ID: "srvtoolu_" + strconv.Itoa(index), + Query: "query " + strconv.Itoa(index), + Hits: []webSearchHit{{Title: "Example", URL: "https://example.com/" + strconv.Itoa(index)}}, + }) + } + if got := len(dedupeWebSearchCalls(calls)); got != 32 { + t.Fatalf("deduped calls = %d, want 32", got) + } +} + +func TestDedupeWebSearchCallsBoundsMergedHits(t *testing.T) { + first := webSearchCall{ID: "srvtoolu_merge", Query: "query"} + second := webSearchCall{ID: "srvtoolu_merge", Query: "query"} + for index := 0; index < searchresult.MaxResults; index++ { + first.Hits = append(first.Hits, webSearchHit{Title: "First", URL: "https://example.com/a/" + strconv.Itoa(index)}) + second.Hits = append(second.Hits, webSearchHit{Title: "Second", URL: "https://example.com/b/" + strconv.Itoa(index)}) + } + merged := dedupeWebSearchCalls([]webSearchCall{first, second}) + if len(merged) != 1 || len(merged[0].Hits) != searchresult.MaxResults { + t.Fatalf("merged calls = %#v", merged) + } +} + +func TestParseWebSearchCallBoundsUpstreamQuery(t *testing.T) { + call, ok := parseWebSearchCallItem(responseItem{ + ID: "ws_long_query", Type: "web_search_call", Status: "completed", + Action: map[string]any{"type": "search", "query": strings.Repeat("界", 5000)}, + }) + if !ok { + t.Fatal("web search call was not parsed") + } + if got := utf8.RuneCountInString(call.Query); got != 4096 { + t.Fatalf("query runes = %d, want 4096", got) + } +} + +func TestStreamCapsWebSearchCallsBeforeEmission(t *testing.T) { + var output strings.Builder + converter := newStreamConverter(&output, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + for index := 0; index < maxWebSearchCalls+10; index++ { + call := webSearchCall{ + ID: "srvtoolu_stream_" + strconv.Itoa(index), + Query: "query " + strconv.Itoa(index), + Hits: []webSearchHit{{Title: "Example", URL: "https://example.com/" + strconv.Itoa(index)}}, + } + if err := converter.noteWebSearch(call, false); err != nil { + t.Fatal(err) + } + } + if len(converter.webSearch) != maxWebSearchCalls { + t.Fatalf("tracked web search calls = %d, want %d", len(converter.webSearch), maxWebSearchCalls) + } + if got := strings.Count(output.String(), `"type":"server_tool_use"`); got != maxWebSearchCalls { + t.Fatalf("emitted server tool uses = %d, want %d", got, maxWebSearchCalls) + } +} + +func TestStreamRejectsOversizedDeferredSearchText(t *testing.T) { + converter := newStreamConverter(io.Discard, OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + data, err := json.Marshal(map[string]any{ + "type": "response.output_text.delta", + "delta": strings.Repeat("x", (8<<20)+1), + }) + if err != nil { + t.Fatal(err) + } + err = converter.handle("response.output_text.delta", data) + if err == nil || !strings.Contains(err.Error(), "缓冲") { + t.Fatalf("oversized deferred text error = %v", err) + } +} + +func TestStreamEmitsServerWebSearchBlocks(t *testing.T) { + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"ws_1","type":"web_search_call","status":"in_progress","action":{"type":"search","query":"rust tutorials"}}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Here you go."}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust tutorials","sources":[{"type":"url","url":"https://doc.rust-lang.org"}]}},{"type":"message","content":[{"type":"output_text","text":"Here you go."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + stream := ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + raw, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if !strings.Contains(text, `"type":"server_tool_use"`) { + t.Fatalf("missing server_tool_use in stream:\n%s", text) + } + if !strings.Contains(text, `"type":"web_search_tool_result"`) { + t.Fatalf("missing web_search_tool_result in stream:\n%s", text) + } + if !strings.Contains(text, `https://doc.rust-lang.org`) { + t.Fatalf("missing hit url in stream:\n%s", text) + } + if !strings.Contains(text, `"query":"rust tutorials"`) && !strings.Contains(text, `"query\": \"rust tutorials\"`) { + // partial_json embeds query + if !strings.Contains(text, "rust tutorials") { + t.Fatalf("missing query in stream:\n%s", text) + } + } + if !strings.Contains(text, `"stop_reason":"end_turn"`) { + t.Fatalf("expected end_turn:\n%s", text) + } + assertSequentialContentBlocks(t, text) + wantOrder := []string{"server_tool_use", "web_search_tool_result", "text"} + if got := contentBlockStartTypes(t, text); !reflect.DeepEqual(got, wantOrder) { + t.Fatalf("content block order = %v, want %v\n%s", got, wantOrder, text) + } +} + +func TestStreamFiltersEmptyDistinctWebSearchCalls(t *testing.T) { + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"ws_real","type":"web_search_call","status":"in_progress","action":{"type":"search","query":"rust tutorials"}}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"ws_empty_1","type":"web_search_call","status":"in_progress","action":{"type":"search"}}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"ws_empty_2","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Here you go."}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"web_search_call","id":"ws_real","status":"completed","action":{"type":"search","query":"rust tutorials","sources":[{"type":"url","url":"https://doc.rust-lang.org"}]}},{"type":"web_search_call","id":"ws_empty_1","status":"completed","action":{"type":"search"}},{"type":"web_search_call","id":"ws_empty_2","status":"completed","action":{"type":"search","query":""}},{"type":"message","content":[{"type":"output_text","text":"Here you go."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + stream := ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, ResponseOptions{AnthropicWebSearch: true}) + raw, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if got := strings.Count(text, `"type":"server_tool_use"`); got != 1 { + t.Fatalf("expected one real server_tool_use, got %d\n%s", got, text) + } + if got := strings.Count(text, `"type":"web_search_tool_result"`); got != 1 { + t.Fatalf("expected one real web_search_tool_result, got %d\n%s", got, text) + } + if !strings.Contains(text, `"web_search_requests":1`) { + t.Fatalf("usage must count only real searches\n%s", text) + } + assertSequentialContentBlocks(t, text) +} + +func TestStreamDefersTextWhenWebSearchArrivesDuringThinking(t *testing.T) { + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"reasoning_1","type":"reasoning"}}`, + ``, + `event: response.reasoning_text.delta`, + `data: {"type":"response.reasoning_text.delta","delta":"Need current sources."}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"ws_1","type":"web_search_call","status":"in_progress","action":{"type":"search","query":"rust tutorials"}}}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"reasoning_1","type":"reasoning","encrypted_content":"sig"}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Here you go."}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust tutorials","sources":[{"type":"url","url":"https://doc.rust-lang.org"}]}},{"type":"message","content":[{"type":"output_text","text":"Here you go."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + stream := ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, ResponseOptions{AnthropicThinking: true, AnthropicWebSearch: true}) + raw, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(raw) + assertSequentialContentBlocks(t, text) + wantOrder := []string{"thinking", "server_tool_use", "web_search_tool_result", "text"} + if got := contentBlockStartTypes(t, text); !reflect.DeepEqual(got, wantOrder) { + t.Fatalf("content block order = %v, want %v\n%s", got, wantOrder, text) + } +} + +func TestStreamUsesRequestContextWhenTextArrivesBeforeWebSearchItem(t *testing.T) { + _, options, err := ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"stream":true, + "messages":[{"role":"user","content":"Perform a web search for the query: rust tutorials"}], + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":8}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-4.5", OperationMessages) + if err != nil { + t.Fatal(err) + } + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Here you go."}`, + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"rust tutorials","sources":[{"type":"url","url":"https://doc.rust-lang.org"}]}}}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust tutorials","sources":[{"type":"url","url":"https://doc.rust-lang.org"}]}},{"type":"message","content":[{"type":"output_text","text":"Here you go."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + stream := ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, options) + raw, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + text := string(raw) + assertSequentialContentBlocks(t, text) + wantOrder := []string{"server_tool_use", "web_search_tool_result", "text"} + if got := contentBlockStartTypes(t, text); !reflect.DeepEqual(got, wantOrder) { + t.Fatalf("content block order = %v, want %v\n%s", got, wantOrder, text) + } +} + +func TestStreamFinalEnvelopeDoesNotOrphanEarlierWebSearchUse(t *testing.T) { + source := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1","model":"grok-4.5"}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"ws_early","type":"web_search_call","status":"in_progress","action":{"type":"search","query":"early query"}}}`, + ``, + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Answer."}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5","status":"completed","output":[{"type":"web_search_call","id":"ws_final","status":"completed","action":{"type":"search","query":"final query","sources":[{"url":"https://example.com/final"}]}},{"type":"message","content":[{"type":"output_text","text":"Answer."}]}],"usage":{"input_tokens":3,"output_tokens":2}}}`, + ``, + }, "\n") + raw, err := io.ReadAll(ConvertResponseStreamWithOptions(io.NopCloser(strings.NewReader(source)), OperationMessages, ResponseOptions{AnthropicWebSearch: true})) + if err != nil { + t.Fatal(err) + } + text := string(raw) + uses := strings.Count(text, `"type":"server_tool_use"`) + results := strings.Count(text, `"type":"web_search_tool_result"`) + if uses != 2 || results != uses { + t.Fatalf("uses=%d results=%d; earlier use was orphaned\n%s", uses, results, text) + } + if !strings.Contains(text, "early query") || !strings.Contains(text, "final query") || !strings.Contains(text, `"web_search_requests":2`) { + t.Fatalf("merged stream lost a search call\n%s", text) + } +} + +func contentBlockStartTypes(t *testing.T, stream string) []string { + t.Helper() + var types []string + scanner := bufio.NewScanner(strings.NewReader(stream)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var event struct { + Type string `json:"type"` + ContentBlock struct { + Type string `json:"type"` + } `json:"content_block"` + } + if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event) != nil { + continue + } + if event.Type == "content_block_start" { + types = append(types, event.ContentBlock.Type) + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return types +} + +func assertSequentialContentBlocks(t *testing.T, stream string) { + t.Helper() + openIndex := -1 + scanner := bufio.NewScanner(strings.NewReader(stream)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var event struct { + Type string `json:"type"` + Index int `json:"index"` + } + if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event) != nil { + continue + } + switch event.Type { + case "content_block_start": + if openIndex >= 0 { + t.Fatalf("content block %d started before block %d stopped\n%s", event.Index, openIndex, stream) + } + openIndex = event.Index + case "content_block_delta": + if openIndex != event.Index { + t.Fatalf("delta for block %d while block %d is open\n%s", event.Index, openIndex, stream) + } + case "content_block_stop": + if openIndex != event.Index { + t.Fatalf("stop for block %d while block %d is open\n%s", event.Index, openIndex, stream) + } + openIndex = -1 + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if openIndex >= 0 { + t.Fatalf("content block %d never stopped\n%s", openIndex, stream) + } +} diff --git a/backend/internal/infra/provider/conversation/stream.go b/backend/internal/infra/provider/conversation/stream.go new file mode 100644 index 0000000000000000000000000000000000000000..e8b8fde38edd7a0d9950bc035ebf9f74cbe06c1d --- /dev/null +++ b/backend/internal/infra/provider/conversation/stream.go @@ -0,0 +1,527 @@ +package conversation + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +const maxDeferredSearchTextBytes = 8 << 20 + +// ConvertResponseStream 将 Responses SSE 转换为 Chat Completions 或 Anthropic Messages SSE。 +func ConvertResponseStream(source io.ReadCloser, operation string) io.ReadCloser { + return ConvertResponseStreamWithOptions(source, operation, ResponseOptions{}) +} + +// ConvertResponseStreamWithOptions 按下游协议选项生成 Chat 或 Anthropic SSE。 +func ConvertResponseStreamWithOptions(source io.ReadCloser, operation string, options ResponseOptions) io.ReadCloser { + if operation == OperationResponses { + return source + } + reader, writer := io.Pipe() + go func() { + defer source.Close() + converter := newStreamConverter(writer, operation, options) + err := consumeSSE(source, converter.handle) + if err == nil { + err = converter.finish() + } + _ = writer.CloseWithError(err) + }() + return reader +} + +type streamConverter struct { + writer io.Writer + operation string + id string + model string + created int64 + started bool + finished bool + textStarted bool + textIndex int + thinkingStarted bool + thinkingClosed bool + thinkingIndex int + thinkingItemID string + nextIndex int + tools map[string]streamTool + webSearch []webSearchCall + webSearchEmitted map[string]bool + deferSearchText bool + pendingSearchText strings.Builder + usage responseUsage + options ResponseOptions + stopFilter *anthropicStreamStopFilter + stopSequence string + refused bool +} + +type streamTool struct { + Index int + ID string + Name string + Arguments string + SentArgs bool + Closed bool +} + +func newStreamConverter(writer io.Writer, operation string, options ResponseOptions) *streamConverter { + return &streamConverter{ + writer: writer, operation: operation, created: time.Now().Unix(), tools: make(map[string]streamTool), + webSearchEmitted: make(map[string]bool), + deferSearchText: operation == OperationMessages && options.AnthropicWebSearch, + options: options, stopFilter: newAnthropicStreamStopFilter(options.StopSequences), + } +} + +// noteWebSearch records a Build web_search_call. Emission is deferred to doneMessages +// so we always use the completed action.sources payload from the final envelope when available. +// For progressive UI we still emit server_tool_use as soon as we see the call. +func (c *streamConverter) noteWebSearch(call webSearchCall, final bool) error { + filtered := dedupeWebSearchCalls([]webSearchCall{call}) + if len(filtered) == 0 { + return nil + } + call = filtered[0] + replaced := false + for i, existing := range c.webSearch { + if existing.ID == call.ID { + // Prefer richer final payload. + if final || len(call.Hits) >= len(existing.Hits) { + c.webSearch[i] = call + } + replaced = true + break + } + } + if !replaced { + if len(c.webSearch) >= maxWebSearchCalls { + return nil + } + c.webSearch = append(c.webSearch, call) + } + if !c.textStarted { + c.deferSearchText = true + } + if c.textStarted || (c.thinkingStarted && !c.thinkingClosed) { + return nil + } + // Emit server_tool_use promptly so Claude Code can show "Searching: …". + return c.emitWebSearchUse(call) +} + +func (c *streamConverter) emitWebSearchUse(call webSearchCall) error { + if err := c.start(); err != nil { + return err + } + if c.webSearchEmitted[call.ID+"#use"] { + return nil + } + index := c.nextIndex + c.nextIndex++ + c.webSearchEmitted[call.ID+"#use"] = true + if err := c.writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": index, + "content_block": map[string]any{"type": "server_tool_use", "id": call.ID, "name": "web_search", "input": map[string]any{}}, + }); err != nil { + return err + } + if call.Query != "" { + if err := c.writeEvent("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": index, + "delta": map[string]any{"type": "input_json_delta", "partial_json": queryJSONPartial(call.Query)}, + }); err != nil { + return err + } + } + if err := c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": index}); err != nil { + return err + } + return nil +} + +func (c *streamConverter) emitPendingWebSearchResults() error { + c.webSearch = dedupeWebSearchCalls(c.webSearch) + for _, call := range c.webSearch { + if c.webSearchEmitted[call.ID+"#result"] { + continue + } + if !c.webSearchEmitted[call.ID+"#use"] { + if err := c.emitWebSearchUse(call); err != nil { + return err + } + } + if err := c.start(); err != nil { + return err + } + index := c.nextIndex + c.nextIndex++ + c.webSearchEmitted[call.ID+"#result"] = true + var content any + if call.Failed { + code := call.Code + if code == "" { + code = "unavailable" + } + content = map[string]any{"type": "web_search_tool_result_error", "error_code": code} + } else { + hits := make([]any, 0, len(call.Hits)) + for _, hit := range call.Hits { + hits = append(hits, map[string]any{"type": "web_search_result", "title": hit.Title, "url": hit.URL}) + } + content = hits + } + if err := c.writeEvent("content_block_start", map[string]any{ + "type": "content_block_start", "index": index, + "content_block": map[string]any{ + "type": "web_search_tool_result", "tool_use_id": call.ID, "content": content, + }, + }); err != nil { + return err + } + if err := c.writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": index}); err != nil { + return err + } + } + return nil +} + +func (c *streamConverter) handle(event string, data []byte) error { + if c.finished { + return nil + } + if bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { + return nil + } + var root map[string]json.RawMessage + if json.Unmarshal(data, &root) != nil { + return nil + } + typeName := event + if raw := root["type"]; typeName == "" { + _ = json.Unmarshal(raw, &typeName) + } + if c.stopSequence != "" && typeName != "response.completed" && typeName != "response.incomplete" && typeName != "response.failed" && typeName != "error" { + return nil + } + switch typeName { + case "response.created", "response.in_progress": + var response responseEnvelope + _ = json.Unmarshal(root["response"], &response) + c.setResponse(response) + return c.start() + case "response.output_text.delta": + var delta string + _ = json.Unmarshal(root["delta"], &delta) + if err := c.start(); err != nil { + return err + } + if c.operation == OperationMessages && c.deferSearchText { + return c.bufferSearchText(delta) + } + return c.textDelta(delta) + case "response.refusal.delta": + var delta string + _ = json.Unmarshal(root["delta"], &delta) + c.refused = true + if c.operation == OperationChat { + return c.chatDelta(map[string]any{"refusal": delta}) + } + return c.textDeltaMessages(delta) + case "response.output_text.annotation.added": + if c.operation != OperationChat { + return nil + } + var annotation any + if json.Unmarshal(root["annotation"], &annotation) != nil || annotation == nil { + return nil + } + return c.chatDelta(map[string]any{"annotations": []any{annotation}}) + case "response.reasoning_summary_text.delta": + var delta string + _ = json.Unmarshal(root["delta"], &delta) + if c.operation == OperationChat { + return c.chatDelta(map[string]any{"reasoning_content": delta}) + } + return c.thinkingDelta(delta) + case "response.reasoning_text.delta": + var delta string + _ = json.Unmarshal(root["delta"], &delta) + if c.operation == OperationChat { + return c.chatDelta(map[string]any{"reasoning_content": delta}) + } + if c.operation == OperationMessages { + return c.thinkingDelta(delta) + } + return nil + case "response.output_item.added": + var item responseItem + _ = json.Unmarshal(root["item"], &item) + if item.Type == "reasoning" && c.operation == OperationMessages && c.options.AnthropicThinking { + return c.thinkingStart(item.ID) + } + if item.Type == "web_search_call" && c.operation == OperationMessages && c.options.AnthropicWebSearch { + if call, ok := parseWebSearchCallItem(item); ok { + return c.noteWebSearch(call, false) + } + return nil + } + if item.Type != "function_call" { + return nil + } + var outputIndex int + _ = json.Unmarshal(root["output_index"], &outputIndex) + return c.toolStart(item, outputIndex) + case "response.function_call_arguments.delta": + var itemID, delta string + _ = json.Unmarshal(root["item_id"], &itemID) + _ = json.Unmarshal(root["delta"], &delta) + return c.toolDelta(itemID, delta) + case "response.function_call_arguments.done": + var itemID, arguments string + _ = json.Unmarshal(root["item_id"], &itemID) + _ = json.Unmarshal(root["arguments"], &arguments) + return c.toolArgumentsDone(itemID, arguments) + case "response.output_item.done": + var item responseItem + _ = json.Unmarshal(root["item"], &item) + if item.Type == "function_call" { + return c.toolArgumentsDone(item.ID, item.Arguments) + } + if item.Type == "reasoning" { + return c.thinkingDone(item) + } + if item.Type == "web_search_call" && c.operation == OperationMessages && c.options.AnthropicWebSearch { + if call, ok := parseWebSearchCallItem(item); ok { + return c.noteWebSearch(call, true) + } + } + case "response.completed", "response.incomplete": + var response responseEnvelope + _ = json.Unmarshal(root["response"], &response) + c.setResponse(response) + if c.operation == OperationMessages && c.options.AnthropicWebSearch { + parsed := parseResponse(response) + for _, call := range parsed.WebSearch { + if err := c.noteWebSearch(call, true); err != nil { + return err + } + } + } + status := response.Status + if status == "" && typeName == "response.incomplete" { + status = "incomplete" + } + return c.done(status) + case "error", "response.failed": + return c.streamError(data) + } + return nil +} + +func (c *streamConverter) bufferSearchText(delta string) error { + pending := c.pendingSearchText.Len() + if pending >= maxDeferredSearchTextBytes || len(delta) > maxDeferredSearchTextBytes-pending { + return fmt.Errorf("WebSearch 延迟文本缓冲超过 %d MiB", maxDeferredSearchTextBytes>>20) + } + c.pendingSearchText.WriteString(delta) + return nil +} + +func (c *streamConverter) setResponse(value responseEnvelope) { + if value.ID != "" { + c.id = value.ID + } + if value.Model != "" { + c.model = value.Model + } + if value.CreatedAt != 0 { + c.created = value.CreatedAt + } + c.usage = mergeResponseUsage(c.usage, value.Usage) +} + +func mergeResponseUsage(current, update responseUsage) responseUsage { + if update.InputTokens != 0 { + current.InputTokens = update.InputTokens + } + if update.OutputTokens != 0 { + current.OutputTokens = update.OutputTokens + } + if update.TotalTokens != 0 { + current.TotalTokens = update.TotalTokens + } + if update.CostInUSDTicks != 0 { + current.CostInUSDTicks = update.CostInUSDTicks + } + if update.NumSourcesUsed != 0 { + current.NumSourcesUsed = update.NumSourcesUsed + } + if update.NumServerSideToolsUsed != 0 { + current.NumServerSideToolsUsed = update.NumServerSideToolsUsed + } + if update.InputTokensDetails.CachedTokens != 0 { + current.InputTokensDetails.CachedTokens = update.InputTokensDetails.CachedTokens + } + if update.OutputTokensDetails.ReasoningTokens != 0 { + current.OutputTokensDetails.ReasoningTokens = update.OutputTokensDetails.ReasoningTokens + } + if update.ContextDetails.InputTokens != 0 { + current.ContextDetails.InputTokens = update.ContextDetails.InputTokens + } + if update.ContextDetails.OutputTokens != 0 { + current.ContextDetails.OutputTokens = update.ContextDetails.OutputTokens + } + return current +} + +func (c *streamConverter) start() error { + if c.started { + return nil + } + c.started = true + if c.id == "" { + c.id = "resp_" + fmt.Sprint(time.Now().UnixNano()) + } + if c.operation == OperationChat { + return c.startChat() + } + return c.startMessages() +} + +func (c *streamConverter) textDelta(delta string) error { + if c.operation == OperationChat { + return c.textDeltaChat(delta) + } + return c.textDeltaMessages(delta) +} + +func (c *streamConverter) toolStart(item responseItem, outputIndex int) error { + if c.operation == OperationMessages { + return c.toolStartMessages(item) + } + return c.toolStartChat(item, outputIndex) +} + +func (c *streamConverter) toolDelta(itemID, delta string) error { + if c.operation == OperationChat { + return c.toolDeltaChat(itemID, delta) + } + return c.toolDeltaMessages(itemID, delta) +} + +func (c *streamConverter) toolArgumentsDone(itemID, arguments string) error { + if c.operation == OperationChat { + return c.toolArgumentsDoneChat(itemID, arguments) + } + return c.toolArgumentsDoneMessages(itemID, arguments) +} + +func (c *streamConverter) done(status string) error { + if c.finished { + return nil + } + if err := c.start(); err != nil { + return err + } + if c.operation == OperationChat { + return c.doneChat(status) + } + return c.doneMessages(status) +} + +func (c *streamConverter) streamError(data []byte) error { + c.finished = true + if c.operation == OperationMessages { + return c.streamErrorMessages(data) + } + return c.streamErrorChat(data) +} + +func (c *streamConverter) finish() error { + if c.finished { + return nil + } + return c.done("") +} + +func streamErrorValue(data []byte) any { + var root map[string]any + if json.Unmarshal(data, &root) != nil { + return strings.TrimSpace(string(data)) + } + if response, ok := root["response"].(map[string]any); ok { + if value, exists := response["error"]; exists && value != nil { + return value + } + } + if value, exists := root["error"]; exists && value != nil { + return value + } + if message, ok := root["message"].(string); ok { + return message + } + return strings.TrimSpace(string(data)) +} + +func (c *streamConverter) writeData(value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + _, err = fmt.Fprintf(c.writer, "data: %s\n\n", data) + return err +} + +func (c *streamConverter) writeEvent(event string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + _, err = fmt.Fprintf(c.writer, "event: %s\ndata: %s\n\n", event, data) + return err +} + +func consumeSSE(source io.Reader, handle func(string, []byte) error) error { + reader := bufio.NewReaderSize(source, 64<<10) + var event string + var data strings.Builder + for { + line, err := reader.ReadString('\n') + if line != "" { + line = strings.TrimRight(line, "\r\n") + switch { + case strings.HasPrefix(line, "event:"): + event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + if data.Len() > 0 { + data.WriteByte('\n') + } + data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + case line == "": + if data.Len() > 0 { + if handleErr := handle(event, []byte(data.String())); handleErr != nil { + return handleErr + } + } + event = "" + data.Reset() + } + } + if err != nil { + if err == io.EOF { + if data.Len() > 0 { + return handle(event, []byte(data.String())) + } + return nil + } + return err + } + } +} diff --git a/backend/internal/infra/provider/definition.go b/backend/internal/infra/provider/definition.go new file mode 100644 index 0000000000000000000000000000000000000000..e68d30f0c99e5c2dc7ee3b5877a7b418fb26e707 --- /dev/null +++ b/backend/internal/infra/provider/definition.go @@ -0,0 +1,174 @@ +package provider + +import ( + "fmt" + "strings" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +// ModelCatalogKind 表示模型目录来自真实上游发现还是项目内置目录。 +type ModelCatalogKind string + +const ( + ModelCatalogRemote ModelCatalogKind = "remote" + ModelCatalogStatic ModelCatalogKind = "static" +) + +// QuotaKind 表示号池可用额度的权威来源。 +type QuotaKind string + +const ( + QuotaBilling QuotaKind = "billing" + QuotaRemoteWindow QuotaKind = "remote_window" + QuotaLocalWindow QuotaKind = "local_window" +) + +// ConversationSurface 描述统一对话 API 在当前 Provider 上的真实能力边界。 +type ConversationSurface struct { + Responses bool + ChatCompletions bool + Messages bool + Compact bool + StoredResponses bool +} + +// Supports 判断统一对话协议入口是否由当前 Provider 明确声明。 +func (s ConversationSurface) Supports(operation string) bool { + switch operation { + case "responses": + return s.Responses + case "compaction": + return s.Compact + case "chat": + return s.ChatCompletions + case "messages": + return s.Messages + default: + return false + } +} + +// MediaSurface 描述图像与视频 API 在当前 Provider 上的真实能力边界。 +type MediaSurface struct { + ImageGeneration bool + ImageEdit bool + VideoGeneration bool +} + +// CredentialSurface 描述号池凭据的接入和维护方式。 +type CredentialSurface struct { + AuthType account.AuthType + Import bool + Refresh bool + DeviceOAuth bool +} + +// UsageKind 表示文本推理 usage 的权威性;媒体请求仍由独立计费路径记录。 +type UsageKind string + +const ( + UsageUpstream UsageKind = "upstream" + UsageEstimated UsageKind = "estimated" +) + +// InferencePolicy 描述与上游协议相关、但必须由统一网关执行的运行策略。 +type InferencePolicy struct { + Usage UsageKind + RetryForbiddenAsEgress bool +} + +// Definition 是 Provider 注册表的权威静态描述;渠道专属协议仍由各 Adapter 实现。 +type Definition struct { + Provider account.Provider + ModelNamespace string + ModelCatalog ModelCatalogKind + ModelCapabilities []modeldomain.Capability + Quota QuotaKind + Credential CredentialSurface + Conversation ConversationSurface + Media MediaSurface + Inference InferencePolicy +} + +// DefinitionAdapter 由生产 Provider 实现,用于注册明确且可校验的能力边界。 +type DefinitionAdapter interface { + Adapter + Definition() Definition +} + +// Clone 返回可安全交给调用方读取的副本,避免切片字段被外部修改后污染注册表。 +func (d Definition) Clone() Definition { + d.ModelCapabilities = append([]modeldomain.Capability(nil), d.ModelCapabilities...) + return d +} + +// SupportsModelCapability 判断该渠道能否注册指定类型的模型路由。 +func (d Definition) SupportsModelCapability(capability modeldomain.Capability) bool { + for _, supported := range d.ModelCapabilities { + if supported == capability { + return true + } + } + return false +} + +// Validate 检查静态描述内部是否自洽,避免接口实现和后台路由配置悄悄漂移。 +func (d Definition) Validate() error { + if !d.Provider.IsValid() { + return fmt.Errorf("Provider %q 无效", d.Provider) + } + if strings.TrimSpace(d.ModelNamespace) == "" || d.ModelNamespace != d.Provider.ModelNamespace() { + return fmt.Errorf("Provider %s 的模型命名空间无效", d.Provider) + } + if d.ModelCatalog != ModelCatalogRemote && d.ModelCatalog != ModelCatalogStatic { + return fmt.Errorf("Provider %s 的模型目录类型无效", d.Provider) + } + if len(d.ModelCapabilities) == 0 { + return fmt.Errorf("Provider %s 未声明模型能力", d.Provider) + } + capabilities := make(map[modeldomain.Capability]struct{}, len(d.ModelCapabilities)) + for _, capability := range d.ModelCapabilities { + switch capability { + case modeldomain.CapabilityResponses, modeldomain.CapabilityChat, modeldomain.CapabilityImage, modeldomain.CapabilityImageEdit, modeldomain.CapabilityVideo: + default: + return fmt.Errorf("Provider %s 声明了无效模型能力 %q", d.Provider, capability) + } + if _, exists := capabilities[capability]; exists { + return fmt.Errorf("Provider %s 重复声明模型能力 %q", d.Provider, capability) + } + capabilities[capability] = struct{}{} + } + if d.Quota != QuotaBilling && d.Quota != QuotaRemoteWindow && d.Quota != QuotaLocalWindow { + return fmt.Errorf("Provider %s 的额度策略无效", d.Provider) + } + if d.Credential.AuthType != account.AuthTypeOAuth && d.Credential.AuthType != account.AuthTypeSSO { + return fmt.Errorf("Provider %s 的认证类型无效", d.Provider) + } + if d.Inference.Usage != UsageUpstream && d.Inference.Usage != UsageEstimated { + return fmt.Errorf("Provider %s 的 usage 策略无效", d.Provider) + } + if (d.Conversation.Compact || d.Conversation.StoredResponses) && !d.Conversation.Responses { + return fmt.Errorf("Provider %s 的 Responses 扩展能力缺少 Responses 基础能力", d.Provider) + } + if d.Credential.AuthType != account.AuthTypeOAuth && (d.Credential.Refresh || d.Credential.DeviceOAuth) { + return fmt.Errorf("Provider %s 的非 OAuth 凭据不能声明刷新或 Device OAuth", d.Provider) + } + mediaCapabilities := []struct { + enabled bool + capability modeldomain.Capability + name string + }{ + {enabled: d.Media.ImageGeneration, capability: modeldomain.CapabilityImage, name: "图像生成"}, + {enabled: d.Media.ImageEdit, capability: modeldomain.CapabilityImageEdit, name: "图像编辑"}, + {enabled: d.Media.VideoGeneration, capability: modeldomain.CapabilityVideo, name: "视频生成"}, + } + for _, item := range mediaCapabilities { + _, declared := capabilities[item.capability] + if item.enabled != declared { + return fmt.Errorf("Provider %s 的%s接口与模型能力声明不一致", d.Provider, item.name) + } + } + return nil +} diff --git a/backend/internal/infra/provider/definition_contract_test.go b/backend/internal/infra/provider/definition_contract_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1d51cbade81505e337c2b9f6fe19332186258b37 --- /dev/null +++ b/backend/internal/infra/provider/definition_contract_test.go @@ -0,0 +1,116 @@ +package provider_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + cliprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/cli" + consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console" + webprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/web" +) + +func TestReadDiagnosticBodyAppliesHardLimit(t *testing.T) { + body := strings.Repeat("x", provider.MaxDiagnosticBodyBytes+4096) + data, truncated, err := provider.ReadDiagnosticBody(strings.NewReader(body)) + if err != nil || !truncated || len(data) != provider.MaxDiagnosticBodyBytes { + t.Fatalf("diagnostic body length = %d, truncated = %v, err = %v", len(data), truncated, err) + } +} + +func TestProductionProviderDefinitionsMatchImplementedCapabilities(t *testing.T) { + registry := provider.NewRegistry( + cliprovider.NewAdapter(cliprovider.Config{}, nil), + webprovider.NewAdapter(webprovider.Config{}, nil, nil, nil, nil), + consoleprovider.NewAdapter(consoleprovider.Config{}, nil, nil), + ) + if err := registry.Validate(); err != nil { + t.Fatalf("production registry validation failed: %v", err) + } + if values := registry.Providers(); !reflect.DeepEqual(values, account.Providers()) { + t.Fatalf("providers = %#v, want %#v", values, account.Providers()) + } + + tests := []struct { + provider account.Provider + catalog provider.ModelCatalogKind + quota provider.QuotaKind + capabilities []modeldomain.Capability + credential provider.CredentialSurface + conversation provider.ConversationSurface + media provider.MediaSurface + inference provider.InferencePolicy + }{ + { + provider: account.ProviderBuild, catalog: provider.ModelCatalogRemote, quota: provider.QuotaBilling, + capabilities: []modeldomain.Capability{modeldomain.CapabilityResponses, modeldomain.CapabilityVideo}, + credential: provider.CredentialSurface{AuthType: account.AuthTypeOAuth, Import: true, Refresh: true, DeviceOAuth: true}, + conversation: provider.ConversationSurface{Responses: true, ChatCompletions: true, Messages: true, Compact: true, StoredResponses: true}, + media: provider.MediaSurface{VideoGeneration: true}, + inference: provider.InferencePolicy{Usage: provider.UsageUpstream}, + }, + { + provider: account.ProviderWeb, catalog: provider.ModelCatalogStatic, quota: provider.QuotaRemoteWindow, + capabilities: []modeldomain.Capability{modeldomain.CapabilityChat, modeldomain.CapabilityImage, modeldomain.CapabilityImageEdit, modeldomain.CapabilityVideo}, + credential: provider.CredentialSurface{AuthType: account.AuthTypeSSO, Import: true}, + conversation: provider.ConversationSurface{Responses: true, ChatCompletions: true, Messages: true, StoredResponses: true}, + media: provider.MediaSurface{ImageGeneration: true, ImageEdit: true, VideoGeneration: true}, + inference: provider.InferencePolicy{Usage: provider.UsageEstimated, RetryForbiddenAsEgress: true}, + }, + { + provider: account.ProviderConsole, catalog: provider.ModelCatalogStatic, quota: provider.QuotaLocalWindow, + capabilities: []modeldomain.Capability{modeldomain.CapabilityResponses}, + credential: provider.CredentialSurface{AuthType: account.AuthTypeSSO, Import: true}, + conversation: provider.ConversationSurface{Responses: true, ChatCompletions: true, Messages: true}, + inference: provider.InferencePolicy{Usage: provider.UsageUpstream, RetryForbiddenAsEgress: true}, + }, + } + for _, test := range tests { + t.Run(string(test.provider), func(t *testing.T) { + definition, ok := registry.Definition(test.provider) + if !ok { + t.Fatal("definition not registered") + } + if definition.ModelNamespace != test.provider.ModelNamespace() || definition.ModelCatalog != test.catalog || definition.Quota != test.quota { + t.Fatalf("definition identity = %#v", definition) + } + if !reflect.DeepEqual(definition.ModelCapabilities, test.capabilities) || definition.Credential != test.credential || definition.Conversation != test.conversation || definition.Media != test.media || definition.Inference != test.inference { + t.Fatalf("definition capabilities = %#v", definition) + } + for _, operation := range []string{"responses", "chat", "messages"} { + if !registry.SupportsConversation(test.provider, operation) { + t.Fatalf("%s does not expose declared %s compatibility", test.provider, operation) + } + } + }) + } + if !registry.SupportsResponseCompaction(account.ProviderBuild) || registry.SupportsResponseCompaction(account.ProviderWeb) || registry.SupportsResponseCompaction(account.ProviderConsole) { + t.Fatal("response compaction capability boundary is inconsistent") + } + if !registry.SupportsConversation(account.ProviderBuild, "compaction") || registry.SupportsConversation(account.ProviderWeb, "compaction") || registry.SupportsConversation(account.ProviderConsole, "compaction") { + t.Fatal("compaction operation capability boundary is inconsistent") + } + definition, _ := registry.Definition(account.ProviderWeb) + definition.ModelCapabilities[0] = modeldomain.CapabilityResponses + stored, _ := registry.Definition(account.ProviderWeb) + if stored.ModelCapabilities[0] != modeldomain.CapabilityChat { + t.Fatal("registry definition was mutated through a returned slice") + } +} + +func TestProviderDefinitionRejectsInconsistentMediaCapability(t *testing.T) { + definition := provider.Definition{ + Provider: account.ProviderWeb, + ModelNamespace: account.ProviderWeb.ModelNamespace(), + ModelCatalog: provider.ModelCatalogStatic, + ModelCapabilities: []modeldomain.Capability{modeldomain.CapabilityImage}, + Quota: provider.QuotaRemoteWindow, + Credential: provider.CredentialSurface{AuthType: account.AuthTypeSSO}, + } + if err := definition.Validate(); err == nil { + t.Fatal("inconsistent media declaration was accepted") + } +} diff --git a/backend/internal/infra/provider/import_json.go b/backend/internal/infra/provider/import_json.go new file mode 100644 index 0000000000000000000000000000000000000000..8498c608ede4c3b3e85135de758e6e3d52a1a3fe --- /dev/null +++ b/backend/internal/infra/provider/import_json.go @@ -0,0 +1,103 @@ +package provider + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +var utf8BOM = []byte{0xef, 0xbb, 0xbf} + +// DecodeCredentialJSONEntries accepts a regular credential document or a +// sequence of JSON objects, including one object per line. +func DecodeCredentialJSONEntries[T any](data []byte, expectedProvider string, limit int) ([]T, error) { + data = bytes.TrimPrefix(data, utf8BOM) + decoder := json.NewDecoder(bytes.NewReader(data)) + entries := make([]T, 0) + for { + start := nextJSONValueOffset(data, decoder.InputOffset()) + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + if errors.Is(err, io.EOF) { + return entries, nil + } + return nil, fmt.Errorf("第 %d 行的账号 JSON 格式无效", jsonErrorLine(data, start, err)) + } + + line := lineAtJSONOffset(data, start) + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil || object == nil { + return nil, fmt.Errorf("第 %d 行必须是 JSON 对象", line) + } + if rawProvider, exists := object["provider"]; exists { + var providerName string + if err := json.Unmarshal(rawProvider, &providerName); err != nil { + return nil, fmt.Errorf("第 %d 行的 provider 必须是字符串", line) + } + providerName = strings.TrimSpace(providerName) + if providerName != "" && providerName != expectedProvider { + return nil, fmt.Errorf("第 %d 行的 Provider 必须是 %s", line, expectedProvider) + } + } + + if rawAccounts, batch := object["accounts"]; batch { + var values []T + if err := json.Unmarshal(rawAccounts, &values); err != nil { + return nil, fmt.Errorf("第 %d 行的 accounts 必须是 JSON 对象数组", line) + } + if err := appendCredentialJSONEntries(&entries, values, limit); err != nil { + return nil, err + } + continue + } + + var value T + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("第 %d 行的账号 JSON 格式无效", line) + } + if err := appendCredentialJSONEntries(&entries, []T{value}, limit); err != nil { + return nil, err + } + } +} + +func appendCredentialJSONEntries[T any](target *[]T, values []T, limit int) error { + if limit > 0 && len(values) > limit-len(*target) { + return fmt.Errorf("%w: 单次最多导入 %d 个账号", ErrCredentialLimit, limit) + } + *target = append(*target, values...) + return nil +} + +func nextJSONValueOffset(data []byte, offset int64) int64 { + for offset < int64(len(data)) { + switch data[offset] { + case ' ', '\t', '\r', '\n': + offset++ + default: + return offset + } + } + return offset +} + +func jsonErrorLine(data []byte, fallback int64, err error) int { + var syntaxError *json.SyntaxError + if errors.As(err, &syntaxError) && syntaxError.Offset > 0 { + return lineAtJSONOffset(data, syntaxError.Offset-1) + } + return lineAtJSONOffset(data, fallback) +} + +func lineAtJSONOffset(data []byte, offset int64) int { + if offset < 0 { + offset = 0 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + return 1 + bytes.Count(data[:offset], []byte{'\n'}) +} diff --git a/backend/internal/infra/provider/import_json_test.go b/backend/internal/infra/provider/import_json_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ae6574927437ba9e19eb932b1df95531ff02d88e --- /dev/null +++ b/backend/internal/infra/provider/import_json_test.go @@ -0,0 +1,55 @@ +package provider + +import ( + "errors" + "strings" + "testing" +) + +type credentialJSONTestEntry struct { + Token string `json:"token"` +} + +func TestDecodeCredentialJSONEntriesAcceptsDocumentAndJSONLines(t *testing.T) { + data := []byte("\xef\xbb\xbf{\n \"provider\": \"grok_test\",\n \"accounts\": [{\"token\":\"one\"}]\n}\r\n\r\n{\"token\":\"two\"}\r\n") + values, err := DecodeCredentialJSONEntries[credentialJSONTestEntry](data, "grok_test", 10) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].Token != "one" || values[1].Token != "two" { + t.Fatalf("values = %#v", values) + } +} + +func TestDecodeCredentialJSONEntriesReportsMalformedLineWithoutContent(t *testing.T) { + _, err := DecodeCredentialJSONEntries[credentialJSONTestEntry]([]byte("{\"token\":\"safe\"}\n\nnot-json-secret\n"), "grok_test", 10) + if err == nil || !strings.Contains(err.Error(), "第 3 行") { + t.Fatalf("error = %v, want line number", err) + } + if strings.Contains(err.Error(), "not-json-secret") || strings.Contains(err.Error(), "safe") { + t.Fatalf("error exposes import contents: %v", err) + } +} + +func TestDecodeCredentialJSONEntriesRejectsNonObjects(t *testing.T) { + for _, data := range []string{`[{"token":"one"}]`, `"token"`, `null`} { + if _, err := DecodeCredentialJSONEntries[credentialJSONTestEntry]([]byte(data), "grok_test", 10); err == nil || !strings.Contains(err.Error(), "必须是 JSON 对象") { + t.Fatalf("data = %s, error = %v", data, err) + } + } +} + +func TestDecodeCredentialJSONEntriesEnforcesLimitAcrossValues(t *testing.T) { + data := []byte("{\"accounts\":[{\"token\":\"one\"}]}\n{\"token\":\"two\"}\n") + _, err := DecodeCredentialJSONEntries[credentialJSONTestEntry](data, "grok_test", 1) + if !errors.Is(err, ErrCredentialLimit) { + t.Fatalf("error = %v, want credential limit", err) + } +} + +func TestDecodeCredentialJSONEntriesRejectsWrongProvider(t *testing.T) { + _, err := DecodeCredentialJSONEntries[credentialJSONTestEntry]([]byte(`{"provider":"other","token":"one"}`), "grok_test", 10) + if err == nil || !strings.Contains(err.Error(), "Provider 必须是 grok_test") { + t.Fatalf("error = %v", err) + } +} diff --git a/backend/internal/infra/provider/provider.go b/backend/internal/infra/provider/provider.go new file mode 100644 index 0000000000000000000000000000000000000000..b5187ce3572c4f384bb67d0a237f5346d247ee29 --- /dev/null +++ b/backend/internal/infra/provider/provider.go @@ -0,0 +1,817 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/media" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +var ( + ErrAuthorizationPending = errors.New("authorization pending") + ErrSlowDown = errors.New("authorization polling too fast") + ErrAuthorizationDenied = errors.New("authorization denied") + ErrCredentialLimit = errors.New("credential count exceeds limit") + ErrUnauthorized = errors.New("upstream credential unauthorized") + ErrBirthDateAlreadySet = errors.New("upstream birth date is already set") +) + +// HTTPStatusError preserves the upstream status when a streaming or asynchronous Provider cannot return a Response. +type HTTPStatusError interface { + error + HTTPStatusCode() int +} + +// ErrorHTTPStatus extracts the upstream HTTP status from a Provider error chain. +func ErrorHTTPStatus(err error) (int, bool) { + var statusError HTTPStatusError + if !errors.As(err, &statusError) { + return 0, false + } + status := statusError.HTTPStatusCode() + return status, status > 0 +} + +// MediaPostProcessingStage identifies a local processing stage that failed after media generation. +type MediaPostProcessingStage string + +const ( + MediaPostProcessingDownload MediaPostProcessingStage = "download" + MediaPostProcessingStorage MediaPostProcessingStage = "storage" +) + +// MediaPostProcessingError indicates that upstream media was created but download or storage failed. +// These errors must not trigger generation on another account or reduce the generating account's health. +type MediaPostProcessingError struct { + Stage MediaPostProcessingStage + Cause error +} + +func (e *MediaPostProcessingError) Error() string { + if e == nil || e.Cause == nil { + return "media post-processing failed" + } + return fmt.Sprintf("media post-processing %s failed: %v", e.Stage, e.Cause) +} + +func (e *MediaPostProcessingError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// NewMediaPostProcessingError marks a download or storage error as non-retryable across accounts. +func NewMediaPostProcessingError(stage MediaPostProcessingStage, cause error) error { + if cause == nil { + return nil + } + return &MediaPostProcessingError{Stage: stage, Cause: cause} +} + +// IsMediaPostProcessingError reports whether an error occurred during local processing after media generation. +func IsMediaPostProcessingError(err error) bool { + var target *MediaPostProcessingError + return errors.As(err, &target) +} + +// CredentialRefreshError distinguishes permanent OAuth errors requiring reauthorization from temporary errors that can retry with backoff. +type CredentialRefreshError struct { + Status int + Code string + Message string + // Response is a bounded, redacted representation of the upstream OAuth + // response. It is diagnostic only and must never contain credentials. + Response string + Permanent bool + RetryAfter time.Duration + Cause error +} + +func (e *CredentialRefreshError) Error() string { + if e == nil { + return "credential refresh failed" + } + if e.Code != "" { + if e.Message != "" { + return "credential refresh failed: " + e.Code + ": " + e.Message + } + return "credential refresh failed: " + e.Code + } + if e.Message != "" { + return "credential refresh failed: " + e.Message + } + if e.Cause != nil { + return "credential refresh failed: " + e.Cause.Error() + } + return "credential refresh failed" +} + +func (e *CredentialRefreshError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// ResponseResourceRequest describes a common upstream request to a Responses resource endpoint. +type ResponseResourceRequest struct { + Credential account.Credential + // Billing is used only to determine XAI eligibility in Build auto mode; nil means the account tier is unknown. + Billing *account.Billing + Method string + Path string + Body []byte + Model string + PromptCacheKey string + // ReasoningReplayKey comes only from explicit client session identity; soft cache identity must not replay ciphertext. + ReasoningReplayKey string + // AllowClientToolCacheRoute allows the Build native cache route to supplement existing client tools. + // This is a protocol compatibility signal, not a client authentication result. + AllowClientToolCacheRoute bool + // GrokTurnIndex is the explicit Grok Shell client turn; it is validated before Build egress and never fabricated by the server. + GrokTurnIndex string + IdempotencyID string + Streaming bool + NormalizeBody bool + Operation string +} + +// Response represents an upstream response that has not yet been written downstream. +type Response struct { + StatusCode int + Status string + Header http.Header + Body io.ReadCloser + QuotaUnits int + UpstreamURL string + Diagnostic *DiagnosticResponse + // RecoveredPrimaryFailure records a primary-plane failure hidden by a successful Provider fallback. + RecoveredPrimaryFailure *DiagnosticResponse + RateLimit *RateLimitMetadata + // ModelCatalogChanged indicates that the model catalog ETag in an inference response differs from + // the ETag from the account's most recent successful /models sync. + ModelCatalogChanged bool +} + +const ( + RateLimitScopeRPS = "rps" + RateLimitScopeRPM = "rpm" +) + +// RateLimitMetadata contains transient rate-limit metadata that is safe to propagate from upstream. +type RateLimitMetadata struct { + Scope string + TeamID string + Model string + Actual int + Limit int + RetryAfter time.Duration +} + +const MaxDiagnosticBodyBytes = 64 << 10 + +// DiagnosticResponse retains a size-limited failure response before Provider conversion. +type DiagnosticResponse struct { + StatusCode int + Status string + Header http.Header + Body []byte + BodyTruncated bool +} + +// ReadDiagnosticBody reads up to the diagnostic body limit and reports whether upstream content was truncated. +func ReadDiagnosticBody(body io.Reader) ([]byte, bool, error) { + if body == nil { + return nil, false, nil + } + data, err := io.ReadAll(io.LimitReader(body, MaxDiagnosticBodyBytes+1)) + if len(data) <= MaxDiagnosticBodyBytes { + return data, false, err + } + return data[:MaxDiagnosticBodyBytes], true, err +} + +// DeviceAuthorization represents the result of starting Device OAuth. +type DeviceAuthorization struct { + DeviceCode string + UserCode string + VerificationURI string + VerificationURIComplete string + Interval time.Duration + ExpiresIn time.Duration +} + +// CredentialSeed represents an OAuth credential not yet persisted after login or import. +type CredentialSeed struct { + Provider account.Provider + AuthType account.AuthType + WebTier account.WebTier + Name string + Email string + UserID string + TeamID string + SourceKey string + OIDCClientID string + AccessToken string + RefreshToken string + CloudflareCookies string + ExpiresAt time.Time + WebNSFWEnabledAt *time.Time + WebTermsAcceptedAt *time.Time + WebTermsAcceptedVersion int + WebBirthDateSetAt *time.Time +} + +type QuotaSnapshot struct { + Tier account.WebTier + Windows []account.QuotaWindow + SyncedAt time.Time +} + +type ImageGenerationRequest struct { + Credential account.Credential + Model string + Prompt string + Count int + Size string + AspectRatio string + Resolution string + ResponseFormat string + Streaming bool + PartialImages int +} + +type ImageInput struct { + Filename string + MIMEType string + Data []byte +} + +type ImageEditRequest struct { + Credential account.Credential + Model string + Prompt string + ImageURLs []string + Count int + Size string + AspectRatio string + Resolution string + ResponseFormat string + Streaming bool + PartialImages int +} + +type VideoRequest struct { + Credential account.Credential + // Billing is used only to determine XAI eligibility in Build auto mode; nil means the account tier is unknown. + Billing *account.Billing + // JobID binds the local video job to XAI ZDR upload tickets and result assets. + JobID string + Prompt string + Duration int + AspectRatio string + Resolution string + ReferenceURLs []string + Progress func(int) +} + +type VideoResult struct { + URL string + ContentType string + // A non-empty AssetID means the result is stored as a local media asset; content reads must use MediaObjectStorage. + AssetID string +} + +// RefreshedCredential represents rotated credentials returned by an OAuth refresh. +type RefreshedCredential struct { + EncryptedAccessToken string + EncryptedRefreshToken string + ExpiresAt time.Time +} + +// Adapter defines only Provider identity; concrete capabilities are registered through small interfaces as needed. +type Adapter interface { + Provider() account.Provider +} + +type ResponseAdapter interface { + Adapter + ForwardResponse(ctx context.Context, request ResponseResourceRequest) (*Response, error) +} + +type ModelCatalogAdapter interface { + Adapter + ListModels(ctx context.Context, credential account.Credential) ([]string, error) +} + +// AccountModelCapabilityNormalizer is optional and normalizes account model capabilities from Billing and credential entitlement. +// Without it, model sync writes the upstream catalog unchanged; nil billing means Unknown with no snapshot. +// credential is used for Build Super entitlement; default Providers may ignore it. +type AccountModelCapabilityNormalizer interface { + Adapter + NormalizeAccountModelCapabilities(models []string, billing *account.Billing, credential account.Credential) []string +} + +type BillingAdapter interface { + Adapter + GetBilling(ctx context.Context, credential account.Credential) (account.Billing, error) +} + +type CredentialRefreshAdapter interface { + Adapter + RefreshCredential(ctx context.Context, credential account.Credential) (RefreshedCredential, error) +} + +type DeviceOAuthAdapter interface { + Adapter + StartDeviceAuthorization(ctx context.Context) (DeviceAuthorization, error) + PollDeviceAuthorization(ctx context.Context, deviceCode string) (CredentialSeed, error) +} + +type CredentialCodecAdapter interface { + Adapter + ParseImportedCredentials(data []byte) ([]CredentialSeed, error) + MarshalCredentials(values []CredentialSeed) ([]byte, error) +} + +// CredentialMetadata contains non-sensitive display data safely derived from a stored credential. +// Raw tokens and complete JWT claims must never be exposed through this structure. +type CredentialMetadata struct { + BuildBotFlagged bool +} + +type CredentialMetadataAdapter interface { + Adapter + CredentialMetadata(credential account.Credential) CredentialMetadata +} + +// AccountIdentity contains non-sensitive account identity metadata confirmed by upstream. +// Email is for display only; cross-Provider automatic linking uses stable UserID only. +type AccountIdentity struct { + Email string + UserID string + TeamID string +} + +type AccountIdentityAdapter interface { + Adapter + SyncAccountIdentity(ctx context.Context, credential account.Credential) (AccountIdentity, error) +} + +type BuildCredentialConverter interface { + Adapter + ConvertToBuild(ctx context.Context, credential account.Credential) (CredentialSeed, error) +} + +type QuotaAdapter interface { + Adapter + SyncQuota(ctx context.Context, credential account.Credential) (QuotaSnapshot, error) + SyncQuotaMode(ctx context.Context, credential account.Credential, mode string) (account.QuotaWindow, error) +} + +// WebAccountSettingsAdapter defines upstream profile-setting capabilities for Grok Web SSO accounts. +// This capability belongs only to the Web Provider; Build and Console must not emulate it through generic account logic. +type WebAccountSettingsAdapter interface { + Adapter + AcceptTerms(ctx context.Context, credential account.Credential) error + SetBirthDate(ctx context.Context, credential account.Credential, birthDate time.Time) error + EnableNSFW(ctx context.Context, credential account.Credential) error +} + +// ImageGenerationAdapter defines an optional Provider image-generation capability. +type ImageGenerationAdapter interface { + Adapter + GenerateImage(ctx context.Context, request ImageGenerationRequest) (*Response, error) +} + +// ImageEditAdapter defines an optional Provider image-editing capability. +type ImageEditAdapter interface { + Adapter + EditImage(ctx context.Context, request ImageEditRequest) (*Response, error) +} + +// ImageAssetStore archives generated images as local resources that the backend can read reliably. +type ImageAssetStore interface { + SaveImage(ctx context.Context, data []byte) (media.Asset, error) + PublicImageURL(id string) string +} + +type VideoAdapter interface { + Adapter + GenerateVideo(ctx context.Context, request VideoRequest) (VideoResult, error) +} + +// VideoContentDownloader reads completed video content using the credential that created the task. +// Callers must verify task ownership first. +type VideoContentDownloader interface { + VideoAdapter + DownloadVideo(ctx context.Context, credential account.Credential, rawURL string) (io.ReadCloser, string, int64, error) +} + +type RoutingMetadataAdapter interface { + Adapter + QuotaMode(upstreamModel string) string + TierOrder(upstreamModel string) []account.WebTier +} + +// ModelAlias resolves a hidden compatibility model name to one public route and can fix reasoning effort. +type ModelAlias struct { + Alias string + PublicModel string + Provider account.Provider + UpstreamModel string + ReasoningEffort string +} + +type ModelAliasAdapter interface { + Adapter + ModelAliases() []ModelAlias +} + +// PricingMetadataAdapter maps Provider-private model identifiers to public billing models. +type PricingMetadataAdapter interface { + Adapter + PricingModel(upstreamModel string) string +} + +// Registry stores enabled Provider Adapters and does not create placeholders for unsupported sources. +type Registry struct { + adapters map[account.Provider]Adapter + definitions map[account.Provider]Definition + aliases map[string]ModelAlias + issues []error +} + +func NewRegistry(adapters ...Adapter) *Registry { + registry := &Registry{ + adapters: make(map[account.Provider]Adapter, len(adapters)), + definitions: make(map[account.Provider]Definition, len(adapters)), + aliases: make(map[string]ModelAlias), + } + for _, adapter := range adapters { + if adapter == nil { + registry.issues = append(registry.issues, errors.New("Provider Adapter 不能为空")) + continue + } + providerValue := adapter.Provider() + if !providerValue.IsValid() { + registry.issues = append(registry.issues, fmt.Errorf("Provider Adapter 身份 %q 无效", providerValue)) + continue + } + if _, exists := registry.adapters[providerValue]; exists { + registry.issues = append(registry.issues, fmt.Errorf("Provider %s 重复注册", providerValue)) + continue + } + registry.adapters[providerValue] = adapter + if source, ok := adapter.(DefinitionAdapter); ok { + registry.definitions[providerValue] = source.Definition().Clone() + } + if source, ok := adapter.(ModelAliasAdapter); ok { + for _, value := range source.ModelAliases() { + if value.Alias == "" || value.PublicModel == "" { + continue + } + if value.Provider != providerValue { + registry.issues = append(registry.issues, fmt.Errorf("Provider %s 的模型别名 %q 指向了 %s", providerValue, value.Alias, value.Provider)) + continue + } + if !modeldomain.IsCanonicalPublicID(value.Provider, value.PublicModel) { + registry.issues = append(registry.issues, fmt.Errorf("Provider %s 的模型别名 %q 目标 %q 不是规范内部路由 ID", providerValue, value.Alias, value.PublicModel)) + continue + } + if existing, exists := registry.aliases[value.Alias]; exists { + if existing != value { + registry.issues = append(registry.issues, fmt.Errorf("模型别名 %q 重复注册", value.Alias)) + } + continue + } + registry.aliases[value.Alias] = value + } + } + } + return registry +} + +// Get returns a registered Provider Adapter. +func (r *Registry) Get(value account.Provider) (Adapter, bool) { + adapter, ok := r.adapters[value] + return adapter, ok +} + +// ResolveModelAlias returns the canonical internal route for a hidden compatibility model name. +func (r *Registry) ResolveModelAlias(value string) (ModelAlias, bool) { + result, ok := r.aliases[value] + return result, ok +} + +// Definition returns the stable capability declaration from a production Adapter. +func (r *Registry) Definition(value account.Provider) (Definition, bool) { + definition, ok := r.definitions[value] + return definition.Clone(), ok +} + +// Providers returns registered Providers in fixed channel order with capability definitions. +func (r *Registry) Providers() []account.Provider { + values := make([]account.Provider, 0, len(r.definitions)) + for _, value := range account.Providers() { + if _, ok := r.definitions[value]; ok { + values = append(values, value) + } + } + return values +} + +// Validate checks that production registry definitions match their implemented capability interfaces. +func (r *Registry) Validate() error { + if r == nil { + return errors.New("Provider Registry 不能为空") + } + if len(r.issues) > 0 { + return errors.Join(r.issues...) + } + for _, value := range account.Providers() { + adapter, registered := r.adapters[value] + definition, described := r.definitions[value] + if !registered || !described { + return fmt.Errorf("Provider %s 未完整注册 Adapter 与 Definition", value) + } + if definition.Provider != value { + return fmt.Errorf("Provider %s 的 Definition 身份不一致", value) + } + if err := definition.Validate(); err != nil { + return err + } + if definition.Conversation.Responses || definition.Conversation.ChatCompletions || definition.Conversation.Messages { + if _, ok := adapter.(ResponseAdapter); !ok { + return fmt.Errorf("Provider %s 声明对话能力但未实现适配器", value) + } + } + if _, ok := adapter.(ModelCatalogAdapter); !ok { + return fmt.Errorf("Provider %s 未实现模型目录适配器", value) + } + switch definition.Quota { + case QuotaBilling: + if _, ok := adapter.(BillingAdapter); !ok { + return fmt.Errorf("Provider %s 声明 Billing 额度但未实现适配器", value) + } + case QuotaRemoteWindow, QuotaLocalWindow: + if _, ok := adapter.(QuotaAdapter); !ok { + return fmt.Errorf("Provider %s 声明窗口额度但未实现适配器", value) + } + } + if definition.Credential.Import { + if _, ok := adapter.(CredentialCodecAdapter); !ok { + return fmt.Errorf("Provider %s 声明凭据导入但未实现适配器", value) + } + } + if definition.Credential.Refresh { + if _, ok := adapter.(CredentialRefreshAdapter); !ok { + return fmt.Errorf("Provider %s 声明凭据刷新但未实现适配器", value) + } + } + if definition.Credential.DeviceOAuth { + if _, ok := adapter.(DeviceOAuthAdapter); !ok { + return fmt.Errorf("Provider %s 声明 Device OAuth 但未实现适配器", value) + } + } + if definition.Media.ImageGeneration { + if _, ok := adapter.(ImageGenerationAdapter); !ok { + return fmt.Errorf("Provider %s 声明图像生成能力但未实现适配器", value) + } + } + if definition.Media.ImageEdit { + if _, ok := adapter.(ImageEditAdapter); !ok { + return fmt.Errorf("Provider %s 声明图像编辑能力但未实现适配器", value) + } + } + if definition.Media.VideoGeneration { + if _, ok := adapter.(VideoAdapter); !ok { + return fmt.Errorf("Provider %s 声明视频能力但未实现适配器", value) + } + } + } + return nil +} + +func (r *Registry) SupportsStoredResponses(value account.Provider) bool { + definition, ok := r.Definition(value) + return ok && definition.Conversation.StoredResponses +} + +func (r *Registry) SupportsConversation(value account.Provider, operation string) bool { + definition, ok := r.Definition(value) + return ok && definition.Conversation.Supports(operation) +} + +func (r *Registry) SupportsResponseCompaction(value account.Provider) bool { + definition, ok := r.Definition(value) + return ok && definition.Conversation.Compact +} + +func (r *Registry) SupportsCredentialRefresh(value account.Provider) bool { + definition, ok := r.Definition(value) + return ok && definition.Credential.Refresh +} + +func (r *Registry) QuotaKind(value account.Provider) (QuotaKind, bool) { + definition, ok := r.Definition(value) + if !ok { + return "", false + } + return definition.Quota, true +} + +func (r *Registry) UsageKind(value account.Provider) (UsageKind, bool) { + definition, ok := r.Definition(value) + if !ok { + return "", false + } + return definition.Inference.Usage, true +} + +func (r *Registry) RetryForbiddenAsEgress(value account.Provider) bool { + definition, ok := r.Definition(value) + return ok && definition.Inference.RetryForbiddenAsEgress +} + +func (r *Registry) Responses(value account.Provider) (ResponseAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(ResponseAdapter) + return result, ok +} + +func (r *Registry) Models(value account.Provider) (ModelCatalogAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(ModelCatalogAdapter) + return result, ok +} + +func (r *Registry) Billing(value account.Provider) (BillingAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(BillingAdapter) + return result, ok +} + +func (r *Registry) CredentialRefresh(value account.Provider) (CredentialRefreshAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(CredentialRefreshAdapter) + return result, ok +} + +func (r *Registry) DeviceOAuth(value account.Provider) (DeviceOAuthAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(DeviceOAuthAdapter) + return result, ok +} + +func (r *Registry) CredentialCodec(value account.Provider) (CredentialCodecAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(CredentialCodecAdapter) + return result, ok +} + +// CredentialMetadata returns derived credential metadata safe for admin display. +func (r *Registry) CredentialMetadata(credential account.Credential) CredentialMetadata { + if r == nil { + return CredentialMetadata{} + } + adapter, ok := r.adapters[credential.Provider] + if !ok { + return CredentialMetadata{} + } + inspector, ok := adapter.(CredentialMetadataAdapter) + if !ok { + return CredentialMetadata{} + } + return inspector.CredentialMetadata(credential) +} + +func (r *Registry) AccountIdentity(value account.Provider) (AccountIdentityAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(AccountIdentityAdapter) + return result, ok +} + +func (r *Registry) BuildConverter(value account.Provider) (BuildCredentialConverter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(BuildCredentialConverter) + return result, ok +} + +func (r *Registry) Quota(value account.Provider) (QuotaAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(QuotaAdapter) + return result, ok +} + +// WebAccountSettings returns the Grok Web-specific account profile settings capability. +func (r *Registry) WebAccountSettings() (WebAccountSettingsAdapter, bool) { + adapter, ok := r.Get(account.ProviderWeb) + if !ok { + return nil, false + } + result, ok := adapter.(WebAccountSettingsAdapter) + return result, ok +} + +func (r *Registry) QuotaMode(value account.Provider, upstreamModel string) string { + adapter, ok := r.Get(value) + if !ok { + return "" + } + metadata, ok := adapter.(RoutingMetadataAdapter) + if !ok { + return "" + } + return metadata.QuotaMode(upstreamModel) +} + +func (r *Registry) TierOrder(value account.Provider, upstreamModel string) []account.WebTier { + adapter, ok := r.Get(value) + if !ok { + return nil + } + metadata, ok := adapter.(RoutingMetadataAdapter) + if !ok { + return nil + } + return metadata.TierOrder(upstreamModel) +} + +func (r *Registry) PricingModel(value account.Provider, upstreamModel string) string { + adapter, ok := r.Get(value) + if !ok { + return upstreamModel + } + metadata, ok := adapter.(PricingMetadataAdapter) + if !ok { + return upstreamModel + } + if model := metadata.PricingModel(upstreamModel); model != "" { + return model + } + return upstreamModel +} + +// ImageGeneration returns the image-generation capability registered by the Provider. +func (r *Registry) ImageGeneration(value account.Provider) (ImageGenerationAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(ImageGenerationAdapter) + return result, ok +} + +// ImageEdit returns the image-editing capability registered by the Provider. +func (r *Registry) ImageEdit(value account.Provider) (ImageEditAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(ImageEditAdapter) + return result, ok +} + +func (r *Registry) Videos(value account.Provider) (VideoAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(VideoAdapter) + return result, ok +} diff --git a/backend/internal/infra/provider/rate_limit.go b/backend/internal/infra/provider/rate_limit.go new file mode 100644 index 0000000000000000000000000000000000000000..bfd34619b46930511aa43f5c9a762e43982e6256 --- /dev/null +++ b/backend/internal/infra/provider/rate_limit.go @@ -0,0 +1,171 @@ +package provider + +import ( + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + rateLimitUsagePattern = regexp.MustCompile(`(?i)\bRequests?\s+per\s+(Second|Minute)\s*\(\s*actual\s*/\s*limit\s*\)\s*:\s*(\d+)\s*/\s*(\d+)`) + rateLimitTeamPattern = regexp.MustCompile(`(?i)\bteam\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b`) + rateLimitModelPattern = regexp.MustCompile(`(?i)\bmodel\s+["']?([A-Za-z0-9][A-Za-z0-9._:/-]*)`) + rateLimitModelTrimChars = ".,;" + rateLimitResetPattern = regexp.MustCompile(`(?i)(\d+)\s*([dhms])`) +) + +// ParseRateLimitMetadata extracts Team+Model RPS/RPM limit metadata from an upstream 429 body. +// It accepts both Console and Build CLI resource-exhausted text shapes. +func ParseRateLimitMetadata(body []byte) *RateLimitMetadata { + for _, text := range rateLimitTexts(body) { + if metadata := parseRateLimitText(text); metadata != nil { + return metadata + } + } + return nil +} + +// RateLimitFromResponse derives RateLimitMetadata from a 429 status, headers, and body. +// When Retry-After is absent but the body contains a reset window, the header is updated. +func RateLimitFromResponse(status int, header http.Header, body []byte) *RateLimitMetadata { + if status != http.StatusTooManyRequests { + return nil + } + metadata := ParseRateLimitMetadata(body) + if metadata == nil { + return nil + } + if header != nil { + if headerValue := header.Get("Retry-After"); headerValue != "" { + if retryAfter := parseRetryAfterHeader(headerValue, time.Now().UTC()); retryAfter > 0 { + metadata.RetryAfter = retryAfter + } + } else if metadata.RetryAfter > 0 { + header.Set("Retry-After", strconv.FormatInt(int64(metadata.RetryAfter/time.Second), 10)) + } + } + return metadata +} + +func rateLimitTexts(body []byte) []string { + texts := []string{string(body)} + var value any + if err := json.Unmarshal(body, &value); err != nil { + return texts + } + collectRateLimitTexts(value, &texts) + return texts +} + +func collectRateLimitTexts(value any, texts *[]string) { + switch typed := value.(type) { + case map[string]any: + if message, ok := typed["message"].(string); ok { + appendRateLimitText(message, texts) + } + if errText, ok := typed["error"].(string); ok { + appendRateLimitText(errText, texts) + } + for _, nested := range typed { + collectRateLimitTexts(nested, texts) + } + case []any: + for _, nested := range typed { + collectRateLimitTexts(nested, texts) + } + case string: + appendRateLimitText(typed, texts) + } +} + +func appendRateLimitText(text string, texts *[]string) { + if strings.TrimSpace(text) == "" { + return + } + *texts = append(*texts, text) +} + +func parseRateLimitText(text string) *RateLimitMetadata { + match := rateLimitUsagePattern.FindStringSubmatch(text) + if match == nil { + return nil + } + actual, actualErr := strconv.Atoi(match[2]) + limit, limitErr := strconv.Atoi(match[3]) + if actualErr != nil || limitErr != nil { + return nil + } + scope := RateLimitScopeRPM + retryAfter := time.Minute + if strings.EqualFold(match[1], "second") { + scope = RateLimitScopeRPS + retryAfter = 2 * time.Second + } + if parsed := rateLimitResetAfter(text); parsed > 0 { + retryAfter = parsed + if scope == RateLimitScopeRPS && retryAfter < 2*time.Second { + retryAfter = 2 * time.Second + } + } + return &RateLimitMetadata{ + Scope: scope, + TeamID: rateLimitTeamID(text), + Model: rateLimitModel(text), + Actual: actual, + Limit: limit, + RetryAfter: retryAfter, + } +} + +func rateLimitTeamID(text string) string { + match := rateLimitTeamPattern.FindStringSubmatch(text) + if match == nil { + return "" + } + return match[1] +} + +func rateLimitModel(text string) string { + match := rateLimitModelPattern.FindStringSubmatch(text) + if match == nil { + return "" + } + return strings.TrimRight(match[1], rateLimitModelTrimChars) +} + +func rateLimitResetAfter(body string) time.Duration { + index := strings.Index(strings.ToLower(body), "resets in:") + if index < 0 { + return 0 + } + text := body[index+len("resets in:"):] + var total time.Duration + for _, match := range rateLimitResetPattern.FindAllStringSubmatch(text, -1) { + value, _ := strconv.Atoi(match[1]) + switch strings.ToLower(match[2]) { + case "d": + total += time.Duration(value) * 24 * time.Hour + case "h": + total += time.Duration(value) * time.Hour + case "m": + total += time.Duration(value) * time.Minute + case "s": + total += time.Duration(value) * time.Second + } + } + return total +} + +func parseRetryAfterHeader(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if at, err := http.ParseTime(value); err == nil && at.After(now) { + return at.Sub(now) + } + return 0 +} diff --git a/backend/internal/infra/provider/rate_limit_test.go b/backend/internal/infra/provider/rate_limit_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8c4a4f93a9212e221ab2691f6db3faccbf1ea435 --- /dev/null +++ b/backend/internal/infra/provider/rate_limit_test.go @@ -0,0 +1,35 @@ +package provider + +import ( + "testing" + "time" +) + +func TestParseRateLimitMetadataBuildTeamRPS(t *testing.T) { + body := []byte(`{"code":"resource-exhausted","error":"Too many requests for team f1692451-874f-4765-ab9b-5285f6c6ff65 and model grok-4.5-build-free. Your team's rate limit is — Requests per Second (actual/limit): 2/2."}`) + metadata := ParseRateLimitMetadata(body) + if metadata == nil { + t.Fatal("expected metadata") + } + if metadata.Scope != RateLimitScopeRPS { + t.Fatalf("scope = %q", metadata.Scope) + } + if metadata.TeamID != "f1692451-874f-4765-ab9b-5285f6c6ff65" { + t.Fatalf("team = %q", metadata.TeamID) + } + if metadata.Model != "grok-4.5-build-free" { + t.Fatalf("model = %q", metadata.Model) + } + if metadata.Actual != 2 || metadata.Limit != 2 { + t.Fatalf("actual/limit = %d/%d", metadata.Actual, metadata.Limit) + } + if metadata.RetryAfter != 2*time.Second { + t.Fatalf("retryAfter = %s, want 2s for RPS without resets-in", metadata.RetryAfter) + } +} + +func TestParseRateLimitMetadataOrdinary429(t *testing.T) { + if metadata := ParseRateLimitMetadata([]byte(`{"error":"You are sending requests too quickly"}`)); metadata != nil { + t.Fatalf("ordinary 429 must not parse as team rate limit: %#v", metadata) + } +} diff --git a/backend/internal/infra/provider/searchresult/url.go b/backend/internal/infra/provider/searchresult/url.go new file mode 100644 index 0000000000000000000000000000000000000000..c9f04f36643debe718a80ad014dc01476a5fbfba --- /dev/null +++ b/backend/internal/infra/provider/searchresult/url.go @@ -0,0 +1,66 @@ +package searchresult + +import ( + "net/url" + "strings" + "unicode" +) + +const ( + maxURLBytes = 8 << 10 + MaxResults = 50 + MaxTitleRunes = 512 +) + +// NormalizeURL accepts only public-link schemes and rejects credential-bearing +// URLs so upstream search data cannot introduce active or privacy-sensitive links. +func NormalizeURL(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > maxURLBytes || strings.IndexFunc(raw, unsafeTextRune) >= 0 { + return "", false + } + parsed, err := url.Parse(raw) + if err != nil || !parsed.IsAbs() || parsed.Opaque != "" || parsed.Hostname() == "" || parsed.User != nil { + return "", false + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", false + } + parsed.Host = strings.ToLower(parsed.Host) + normalized := parsed.String() + if len(normalized) > maxURLBytes { + return "", false + } + return normalized, true +} + +func NormalizeTitle(raw, fallback string) string { + value := sanitizeTitle(raw) + if value == "" { + value = sanitizeTitle(fallback) + } + runes := []rune(value) + if len(runes) > MaxTitleRunes { + value = string(runes[:MaxTitleRunes]) + } + return value +} + +func unsafeTextRune(r rune) bool { + return unicode.IsControl(r) || unicode.In(r, unicode.Cf) +} + +func sanitizeTitle(value string) string { + value = strings.Map(func(r rune) rune { + switch r { + case '\r', '\n', '\t': + return ' ' + } + if unsafeTextRune(r) { + return -1 + } + return r + }, value) + return strings.Join(strings.Fields(value), " ") +} diff --git a/backend/internal/infra/provider/searchresult/url_test.go b/backend/internal/infra/provider/searchresult/url_test.go new file mode 100644 index 0000000000000000000000000000000000000000..fa1dc89ce59acfbef6f1c462b86fce8d905cd443 --- /dev/null +++ b/backend/internal/infra/provider/searchresult/url_test.go @@ -0,0 +1,80 @@ +package searchresult + +import ( + "net/url" + "strings" + "testing" + "unicode" + "unicode/utf8" +) + +func TestNormalizeURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + ok bool + }{ + {name: "https", raw: " HTTPS://Example.COM/path?q=1#fragment ", want: "https://example.com/path?q=1#fragment", ok: true}, + {name: "http", raw: "http://example.com", want: "http://example.com", ok: true}, + {name: "script", raw: "javascript:alert(1)"}, + {name: "file", raw: "file:///etc/passwd"}, + {name: "relative", raw: "/relative/path"}, + {name: "credentials", raw: "https://user:secret@example.com/private"}, + {name: "control", raw: "https://example.com/\nprivate"}, + {name: "bidi control", raw: "https://example.com/\u202Ehidden"}, + {name: "oversized", raw: "https://example.com/" + strings.Repeat("a", maxURLBytes)}, + {name: "encoding expansion", raw: "https://example.com/#" + strings.Repeat("´", maxURLBytes/2)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := NormalizeURL(test.raw) + if ok != test.ok || got != test.want { + t.Fatalf("NormalizeURL(%q) = %q, %v; want %q, %v", test.raw, got, ok, test.want, test.ok) + } + }) + } +} + +func TestNormalizeTitle(t *testing.T) { + value := NormalizeTitle(strings.Repeat("界", MaxTitleRunes+10), "fallback") + if utf8.RuneCountInString(value) != MaxTitleRunes { + t.Fatalf("title runes = %d", utf8.RuneCountInString(value)) + } + if got := NormalizeTitle(" ", " fallback "); got != "fallback" { + t.Fatalf("fallback title = %q", got) + } + if got := NormalizeTitle("Safe\x1b[31m\nTitle\u202E", "fallback"); got != "Safe[31m Title" { + t.Fatalf("control-safe title = %q", got) + } + if got := NormalizeTitle("\x1b\u202E", "Clean fallback"); got != "Clean fallback" { + t.Fatalf("sanitized-empty fallback title = %q", got) + } +} + +func FuzzNormalizeURL(f *testing.F) { + for _, seed := range []string{ + "https://example.com/path?q=1", + "http://localhost:8080/test", + "javascript:alert(1)", + "https://user:pass@example.com", + "\x00https://example.com", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, raw string) { + normalized, ok := NormalizeURL(raw) + if !ok { + return + } + parsed, err := url.Parse(normalized) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil { + t.Fatalf("unsafe normalized URL %q from %q", normalized, raw) + } + if len(normalized) > maxURLBytes || strings.IndexFunc(normalized, func(r rune) bool { + return unicode.IsControl(r) || unicode.In(r, unicode.Cf) + }) >= 0 { + t.Fatalf("unbounded normalized URL %q", normalized) + } + }) +} diff --git a/backend/internal/infra/provider/sessionidentity/session.go b/backend/internal/infra/provider/sessionidentity/session.go new file mode 100644 index 0000000000000000000000000000000000000000..87500eb53c532eca6e4f4ab4beb1afa21c2ccbcb --- /dev/null +++ b/backend/internal/infra/provider/sessionidentity/session.go @@ -0,0 +1,150 @@ +package sessionidentity + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/browserheaders" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const responseBodyLimit = 64 << 10 + +// Fetch 通过 Grok Web Session 接口读取 SSO 账号的稳定身份元数据。 +// Web 与 Console 共用该链路,确保代理、Cookie、UA 和 Resin 身份一致。 +func Fetch(ctx context.Context, baseURL string, credential account.Credential, egress *infraegress.Manager, cipher *security.Cipher) (provider.AccountIdentity, error) { + if credential.AuthType != account.AuthTypeSSO || (credential.Provider != account.ProviderWeb && credential.Provider != account.ProviderConsole) { + return provider.AccountIdentity{}, fmt.Errorf("仅 Grok Web 与 Console SSO 账号支持身份同步") + } + if egress == nil || cipher == nil { + return provider.AccountIdentity{}, fmt.Errorf("Session 身份同步依赖未初始化") + } + token, err := cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return provider.AccountIdentity{}, err + } + if strings.TrimSpace(token) == "" { + return provider.AccountIdentity{}, provider.ErrUnauthorized + } + lease, err := egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return provider.AccountIdentity{}, err + } + defer lease.Release() + + requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + origin := strings.TrimRight(strings.TrimSpace(baseURL), "/") + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, origin+"/api/auth/session", nil) + if err != nil { + return provider.AccountIdentity{}, err + } + request.Header = browserHeaders(token, origin, lease) + response, err := lease.Do(request) + if err != nil { + egress.FeedbackForScope(context.WithoutCancel(ctx), domainegress.ScopeWeb, lease.NodeID, 0, err) + return provider.AccountIdentity{}, err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, responseBodyLimit+1)) + if err != nil { + return provider.AccountIdentity{}, err + } + if len(body) > responseBodyLimit { + return provider.AccountIdentity{}, fmt.Errorf("Grok Session 响应超过安全上限") + } + egress.FeedbackForScope(context.WithoutCancel(ctx), domainegress.ScopeWeb, lease.NodeID, response.StatusCode, nil) + if response.StatusCode == http.StatusUnauthorized { + return provider.AccountIdentity{}, provider.ErrUnauthorized + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return provider.AccountIdentity{}, fmt.Errorf("Grok Session 接口返回 %d", response.StatusCode) + } + return Parse(body) +} + +func Parse(body []byte) (provider.AccountIdentity, error) { + var value struct { + Status string `json:"status"` + Session struct { + UserID string `json:"userId"` + Email string `json:"email"` + OrganizationID string `json:"organizationId"` + } `json:"session"` + User struct { + ID string `json:"id"` + UserID string `json:"userId"` + Sub string `json:"sub"` + Email string `json:"email"` + TeamID string `json:"teamId"` + } `json:"user"` + ID string `json:"id"` + UserID string `json:"userId"` + Sub string `json:"sub"` + Email string `json:"email"` + TeamID string `json:"teamId"` + } + if err := json.Unmarshal(body, &value); err != nil { + return provider.AccountIdentity{}, fmt.Errorf("解析 Grok Session: %w", err) + } + // Reject unavailable sessions before accepting residual identity fields that may still be present. + status := strings.TrimSpace(value.Status) + if strings.EqualFold(status, "unauthenticated") { + return provider.AccountIdentity{}, provider.ErrUnauthorized + } + if strings.EqualFold(status, "blocked") { + return provider.AccountIdentity{}, fmt.Errorf("%w: session status blocked", provider.ErrUnauthorized) + } + identity := provider.AccountIdentity{ + UserID: firstNonEmpty(value.Session.UserID, value.User.ID, value.User.UserID, value.User.Sub, value.ID, value.UserID, value.Sub), + Email: firstNonEmpty(value.Session.Email, value.User.Email, value.Email), + TeamID: firstNonEmpty(value.Session.OrganizationID, value.User.TeamID, value.TeamID), + } + identity.UserID = strings.TrimSpace(identity.UserID) + identity.Email = strings.TrimSpace(identity.Email) + identity.TeamID = strings.TrimSpace(identity.TeamID) + if identity.UserID == "" && identity.Email == "" { + return provider.AccountIdentity{}, fmt.Errorf("Grok Session 缺少账号身份") + } + return identity, nil +} + +func browserHeaders(token, origin string, lease *infraegress.Lease) http.Header { + userAgent := strings.TrimSpace(lease.UserAgent) + if userAgent == "" { + userAgent = infraegress.DefaultUserAgent + } + value := http.Header{} + value.Set("Accept", "*/*") + value.Set("Accept-Encoding", "gzip, deflate, br, zstd") + value.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + value.Set("Cache-Control", "no-cache") + value.Set("Cookie", infraegress.BuildSSOCookie(token, lease.CFCookies)) + value.Set("Pragma", "no-cache") + value.Set("Priority", "u=1, i") + value.Set("Referer", origin+"/") + value.Set("Sec-Fetch-Dest", "empty") + value.Set("Sec-Fetch-Mode", "cors") + value.Set("Sec-Fetch-Site", "same-origin") + value.Set("User-Agent", userAgent) + browserheaders.ApplyChromiumClientHints(value, userAgent) + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/infra/provider/sessionidentity/session_test.go b/backend/internal/infra/provider/sessionidentity/session_test.go new file mode 100644 index 0000000000000000000000000000000000000000..78f11f2a32ffc3ec199fe91258a433167f703864 --- /dev/null +++ b/backend/internal/infra/provider/sessionidentity/session_test.go @@ -0,0 +1,62 @@ +package sessionidentity + +import ( + "errors" + "testing" + + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestParseBlockedSessionIsUnauthorized(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(`{"status":"blocked"}`)) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } +} + +func TestParseBlockedSessionWithIdentityFieldsIsUnauthorized(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(`{"status":"blocked","userId":"user-1","email":"a@example.com","session":{"userId":"user-1","email":"a@example.com"}}`)) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized even when identity fields are present", err) + } +} + +func TestParseUnauthenticatedSessionIsUnauthorized(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(`{"status":"unauthenticated"}`)) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } +} + +func TestParseUnauthenticatedSessionWithIdentityFieldsIsUnauthorized(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(`{"status":"unauthenticated","userId":"user-1","email":"a@example.com"}`)) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized even when identity fields are present", err) + } +} + +func TestParseMissingIdentityWithoutStatusStaysGeneric(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(`{}`)) + if err == nil || errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want generic missing-identity error", err) + } +} + +func TestParseAuthenticatedSession(t *testing.T) { + t.Parallel() + identity, err := Parse([]byte(`{ + "status":"authenticated", + "session":{"userId":"user-1","email":"a@example.com","organizationId":"org-1"} + }`)) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "user-1" || identity.Email != "a@example.com" || identity.TeamID != "org-1" { + t.Fatalf("identity = %#v", identity) + } +} diff --git a/backend/internal/infra/provider/web/account_identity.go b/backend/internal/infra/provider/web/account_identity.go new file mode 100644 index 0000000000000000000000000000000000000000..882f3ef5d480b38736e64626b76ccb0fb430ba38 --- /dev/null +++ b/backend/internal/infra/provider/web/account_identity.go @@ -0,0 +1,25 @@ +package web + +import ( + "context" + "fmt" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/sessionidentity" +) + +// SyncAccountIdentity 从 Grok Web 会话读取非敏感身份元数据。 +// 请求复用账号的 Web 出口、浏览器指纹、Cookie 与 Resin 身份。 +func (a *Adapter) SyncAccountIdentity(ctx context.Context, credential account.Credential) (provider.AccountIdentity, error) { + if credential.Provider != account.ProviderWeb || credential.AuthType != account.AuthTypeSSO { + return provider.AccountIdentity{}, fmt.Errorf("仅 Grok Web SSO 账号支持身份同步") + } + return sessionidentity.Fetch(ctx, a.config().BaseURL, credential, a.egress, a.cipher) +} + +func parseAccountIdentity(body []byte) (provider.AccountIdentity, error) { + return sessionidentity.Parse(body) +} + +var _ provider.AccountIdentityAdapter = (*Adapter)(nil) diff --git a/backend/internal/infra/provider/web/account_identity_test.go b/backend/internal/infra/provider/web/account_identity_test.go new file mode 100644 index 0000000000000000000000000000000000000000..89e0ff1a361c02ec2d1da4563582e360e852f35f --- /dev/null +++ b/backend/internal/infra/provider/web/account_identity_test.go @@ -0,0 +1,73 @@ +package web + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestSyncAccountIdentityUsesWebBrowserIdentity(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/auth/session" || request.Method != http.MethodGet { + http.NotFound(writer, request) + return + } + if request.Header.Get("User-Agent") != infraegress.DefaultUserAgent { + t.Errorf("user agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("Cookie") != "sso=test-sso; sso-rw=test-sso; cf_clearance=clear" { + t.Errorf("cookie = %q", request.Header.Get("Cookie")) + } + if request.Header.Get("Accept") != "*/*" || request.Header.Get("Referer") != "http://"+request.Host+"/" || request.Header.Get("Sec-Fetch-Site") != "same-origin" { + t.Errorf("browser headers = %#v", request.Header) + } + if request.Header.Get("Sec-Ch-Ua") == "" || request.Header.Get("Sec-Ch-Ua-Platform") != `"macOS"` { + t.Errorf("client hints = %#v", request.Header) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"user":{"id":"user-1","email":"User@Example.com","teamId":"team-1"}}`)) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + token, _ := cipher.Encrypt("test-sso") + cookies, _ := cipher.Encrypt("cf_clearance=clear") + adapter := NewAdapter(Config{BaseURL: server.URL}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + identity, err := adapter.SyncAccountIdentity(context.Background(), account.Credential{ + ID: 1, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + EncryptedAccessToken: token, EncryptedCloudflareCookie: cookies, + }) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "user-1" || identity.Email != "User@Example.com" || identity.TeamID != "team-1" { + t.Fatalf("identity = %#v", identity) + } +} + +func TestParseAccountIdentityRejectsMissingIdentity(t *testing.T) { + t.Parallel() + if _, err := parseAccountIdentity([]byte(`{"user":{"name":"anonymous"}}`)); err == nil { + t.Fatal("expected missing identity error") + } +} + +func TestParseAccountIdentityAcceptsAuthenticatedSessionEnvelope(t *testing.T) { + t.Parallel() + identity, err := parseAccountIdentity([]byte(`{"status":"authenticated","session":{"userId":"user-1","email":"user@example.com","organizationId":"org-1"}}`)) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "user-1" || identity.Email != "user@example.com" || identity.TeamID != "org-1" { + t.Fatalf("identity = %#v", identity) + } +} diff --git a/backend/internal/infra/provider/web/account_settings.go b/backend/internal/infra/provider/web/account_settings.go new file mode 100644 index 0000000000000000000000000000000000000000..a0939f1fab3a59a867beb119eb27fc671bc80154 --- /dev/null +++ b/backend/internal/infra/provider/web/account_settings.go @@ -0,0 +1,270 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/browserheaders" +) + +const ( + officialAccountsBaseURL = "https://accounts.x.ai" + webAccountSettingTimeout = 15 * time.Second + webAccountSettingBodyLimit = 64 << 10 + webAccountSettingErrorPreview = 240 + webBirthDateLockedMarker = "[WKE=account:birth-date-change-limit-reached]" +) + +var ( + acceptTermsBody = []byte{0x00, 0x00, 0x00, 0x00, 0x02, 0x10, 0x01} + enableNSFWBody = []byte{ + 0x00, 0x00, 0x00, 0x00, 0x20, + 0x0a, 0x02, 0x10, 0x01, + 0x12, 0x1a, 0x0a, 0x18, + 'a', 'l', 'w', 'a', 'y', 's', '_', 's', 'h', 'o', 'w', '_', 'n', 's', 'f', 'w', '_', 'c', 'o', 'n', 't', 'e', 'n', 't', + } +) + +// AcceptTerms 接受当前 Grok Web SSO 账号的上游服务协议。 +func (a *Adapter) AcceptTerms(ctx context.Context, credential account.Credential) error { + accountBaseURL := strings.TrimRight(a.accountsBaseURL, "/") + if accountBaseURL == "" { + accountBaseURL = officialAccountsBaseURL + } + webBaseURL := strings.TrimRight(a.config().BaseURL, "/") + if webBaseURL == "" { + return fmt.Errorf("Grok Web BaseURL 不能为空") + } + productBody, err := json.Marshal(struct { + TOSVersion int `json:"tosVersion"` + }{TOSVersion: account.CurrentWebTermsVersion}) + if err != nil { + return err + } + return a.runWebAccountSettings(ctx, credential, + webAccountSettingRequest{ + endpoint: accountBaseURL + "/auth_mgmt.AuthManagement/SetTosAcceptedVersion", + body: acceptTermsBody, + contentType: "application/grpc-web+proto", + origin: accountBaseURL, + referer: accountBaseURL + "/accept-tos", + grpcWeb: true, + connectES: true, + withoutCF: true, + }, + webAccountSettingRequest{ + endpoint: webBaseURL + "/rest/auth/set-tos-accepted", + body: productBody, + contentType: "application/json", + origin: webBaseURL, + referer: webBaseURL + "/", + statsig: true, + clientHints: true, + }, + ) +} + +// SetBirthDate 设置 Grok Web 账号生日。上游接收 RFC3339 字符串;日期由应用层生成。 +func (a *Adapter) SetBirthDate(ctx context.Context, credential account.Credential, birthDate time.Time) error { + cfg := a.config() + data, err := json.Marshal(map[string]string{ + "birthDate": birthDate.UTC().Format("2006-01-02") + "T16:00:00.000Z", + }) + if err != nil { + return err + } + baseURL := strings.TrimRight(cfg.BaseURL, "/") + err = a.runWebAccountSetting(ctx, credential, webAccountSettingRequest{ + endpoint: baseURL + "/rest/auth/set-birth-date", + body: data, + contentType: "application/json", + origin: baseURL, + referer: baseURL + "/", + statsig: true, + }) + var upstreamErr *webAccountSettingError + if errors.As(err, &upstreamErr) && upstreamErr.birthDateAlreadySet { + return provider.ErrBirthDateAlreadySet + } + return err +} + +// EnableNSFW 将上游内容偏好设置为 always_show_nsfw_content。 +// 生日只负责年龄资料,NSFW 仍需独立调用本接口。 +func (a *Adapter) EnableNSFW(ctx context.Context, credential account.Credential) error { + cfg := a.config() + baseURL := strings.TrimRight(cfg.BaseURL, "/") + return a.runWebAccountSetting(ctx, credential, webAccountSettingRequest{ + endpoint: baseURL + "/auth_mgmt.AuthManagement/UpdateUserFeatureControls", + body: enableNSFWBody, + contentType: "application/grpc-web+proto", + origin: baseURL, + referer: baseURL + "/", + grpcWeb: true, + statsig: true, + }) +} + +type webAccountSettingRequest struct { + endpoint string + body []byte + contentType string + origin string + referer string + grpcWeb bool + connectES bool + statsig bool + clientHints bool + withoutCF bool +} + +func (a *Adapter) runWebAccountSetting(ctx context.Context, credential account.Credential, input webAccountSettingRequest) error { + return a.runWebAccountSettings(ctx, credential, input) +} + +func (a *Adapter) runWebAccountSettings(ctx context.Context, credential account.Credential, inputs ...webAccountSettingRequest) error { + if credential.Provider != account.ProviderWeb || credential.AuthType != account.AuthTypeSSO { + return fmt.Errorf("仅 Grok Web SSO 账号支持资料设置") + } + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return err + } + if strings.TrimSpace(token) == "" { + return provider.ErrUnauthorized + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return err + } + defer lease.Release() + for _, input := range inputs { + if err := a.executeWebAccountSetting(ctx, token, lease, input); err != nil { + return err + } + } + return nil +} + +func (a *Adapter) executeWebAccountSetting(ctx context.Context, token string, lease *infraegress.Lease, input webAccountSettingRequest) error { + for attempt := 0; attempt < 2; attempt++ { + requestCtx, cancel := context.WithTimeout(ctx, webAccountSettingTimeout) + request, requestErr := http.NewRequestWithContext(requestCtx, http.MethodPost, input.endpoint, bytes.NewReader(input.body)) + if requestErr != nil { + cancel() + return requestErr + } + request.Header = buildHeaders(token, lease, input.contentType) + if input.withoutCF { + request.Header.Set("Cookie", infraegress.BuildSSOCookie(token, "")) + } + applyAppHeaders(request.Header, input.origin, input.referer) + if input.clientHints { + browserheaders.ApplyChromiumClientHints(request.Header, lease.UserAgent) + } + if input.grpcWeb { + request.Header.Set("x-grpc-web", "1") + } + if input.connectES { + request.Header.Set("x-user-agent", "connect-es/2.1.1") + } + if input.statsig { + a.applySignedStatsig(requestCtx, request, token, lease) + } + + response, requestErr := lease.Do(request) + if requestErr != nil { + cancel() + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, requestErr) + return requestErr + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, webAccountSettingBodyLimit+1)) + _ = response.Body.Close() + cancel() + if readErr != nil { + return readErr + } + if len(body) > webAccountSettingBodyLimit { + return fmt.Errorf("Grok Web 账号设置响应超过安全上限") + } + if response.StatusCode == http.StatusForbidden && input.statsig && attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, input.endpoint) { + continue + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + if response.StatusCode == http.StatusUnauthorized { + return provider.ErrUnauthorized + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return newWebAccountSettingError(response.StatusCode, body) + } + if input.grpcWeb { + if err := validateAccountSettingGRPCStatus(response, body); err != nil { + return err + } + } + return nil + } + return fmt.Errorf("Grok Web Statsig 刷新后仍被拒绝") +} + +func validateAccountSettingGRPCStatus(response *http.Response, body []byte) error { + _, bodyStatus, err := parseGRPCWebFrames(body) + if err != nil { + return fmt.Errorf("解析 Grok Web 账号设置响应: %w", err) + } + for _, status := range []string{ + response.Header.Get("grpc-status"), + response.Trailer.Get("grpc-status"), + bodyStatus, + } { + status = strings.TrimSpace(status) + if status != "" && status != "0" { + return fmt.Errorf("Grok Web 账号设置 gRPC 状态 %s", status) + } + } + return nil +} + +type webAccountSettingError struct { + status int + body string + birthDateAlreadySet bool +} + +func newWebAccountSettingError(status int, body []byte) *webAccountSettingError { + return &webAccountSettingError{ + status: status, + body: accountSettingPreview(body), + birthDateAlreadySet: status == http.StatusTooManyRequests && bytes.Contains(body, []byte(webBirthDateLockedMarker)), + } +} + +func (e *webAccountSettingError) Error() string { + if e.body == "" { + return fmt.Sprintf("Grok Web 账号设置上游返回 %d", e.status) + } + return fmt.Sprintf("Grok Web 账号设置上游返回 %d: %s", e.status, e.body) +} + +func (e *webAccountSettingError) HTTPStatusCode() int { return e.status } + +func accountSettingPreview(body []byte) string { + value := strings.Join(strings.Fields(string(body)), " ") + if len(value) <= webAccountSettingErrorPreview { + return value + } + return value[:webAccountSettingErrorPreview] +} + +var _ provider.WebAccountSettingsAdapter = (*Adapter)(nil) diff --git a/backend/internal/infra/provider/web/account_settings_test.go b/backend/internal/infra/provider/web/account_settings_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bd1b00ca4392a4df1b00dc5931c0880792c37811 --- /dev/null +++ b/backend/internal/infra/provider/web/account_settings_test.go @@ -0,0 +1,240 @@ +package web + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestWebAccountSettingsMatchCapturedProtocol(t *testing.T) { + t.Parallel() + expectedNSFW, err := hex.DecodeString("00000000200a021001121a0a18616c776179735f73686f775f6e7366775f636f6e74656e74") + if err != nil || !bytes.Equal(enableNSFWBody, expectedNSFW) { + t.Fatalf("NSFW frame = %x", enableNSFWBody) + } + var accountTermsSeen, productTermsSeen, birthSeen, nsfwSeen atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + switch request.URL.Path { + case "/auth_mgmt.AuthManagement/SetTosAcceptedVersion": + accountTermsSeen.Store(true) + if !bytes.Equal(body, acceptTermsBody) || request.Header.Get("Content-Type") != "application/grpc-web+proto" { + t.Errorf("terms request body=%x content-type=%q", body, request.Header.Get("Content-Type")) + } + if cookie := request.Header.Get("Cookie"); cookie != "sso=test-sso; sso-rw=test-sso" { + t.Errorf("terms cookie = %q", cookie) + } + if request.Header.Get("x-grpc-web") != "1" || request.Header.Get("x-user-agent") != "connect-es/2.1.1" { + t.Errorf("terms grpc headers = %#v", request.Header) + } + case "/rest/auth/set-tos-accepted": + productTermsSeen.Store(true) + var payload map[string]int + if json.Unmarshal(body, &payload) != nil || payload["tosVersion"] != account.CurrentWebTermsVersion { + t.Errorf("product terms payload = %s", body) + } + if request.Header.Get("Content-Type") != "application/json" || request.Header.Get("x-statsig-id") == "" { + t.Errorf("product terms headers = %#v", request.Header) + } + if request.Header.Get("Cookie") != "sso=test-sso; sso-rw=test-sso; cf_clearance=clear" || request.Header.Get("Sec-Ch-Ua") == "" { + t.Errorf("product terms browser identity = %#v", request.Header) + } + case "/rest/auth/set-birth-date": + birthSeen.Store(true) + var payload map[string]string + if json.Unmarshal(body, &payload) != nil || payload["birthDate"] != "2000-01-02T16:00:00.000Z" { + t.Errorf("birth payload = %s", body) + } + if request.Header.Get("x-statsig-id") == "" || request.Header.Get("Cookie") != "sso=test-sso; sso-rw=test-sso; cf_clearance=clear" { + t.Errorf("birth headers = %#v", request.Header) + } + case "/auth_mgmt.AuthManagement/UpdateUserFeatureControls": + nsfwSeen.Store(true) + if !bytes.Equal(body, enableNSFWBody) || request.Header.Get("x-statsig-id") == "" || request.Header.Get("x-user-agent") != "" { + t.Errorf("nsfw body=%x headers=%#v", body, request.Header) + } + default: + http.NotFound(writer, request) + return + } + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedToken, _ := cipher.Encrypt("test-sso") + encryptedCookies, _ := cipher.Encrypt("cf_clearance=clear") + statsig := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{'s'}, 70)) + adapter := NewAdapter(Config{BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: statsig}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + adapter.accountsBaseURL = server.URL + credential := account.Credential{ + ID: 1, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + EncryptedAccessToken: encryptedToken, EncryptedCloudflareCookie: encryptedCookies, + } + + if err := adapter.AcceptTerms(context.Background(), credential); err != nil { + t.Fatal(err) + } + if err := adapter.SetBirthDate(context.Background(), credential, time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if err := adapter.EnableNSFW(context.Background(), credential); err != nil { + t.Fatal(err) + } + if !accountTermsSeen.Load() || !productTermsSeen.Load() || !birthSeen.Load() || !nsfwSeen.Load() { + t.Fatalf("seen accountTerms=%v productTerms=%v birth=%v nsfw=%v", accountTermsSeen.Load(), productTermsSeen.Load(), birthSeen.Load(), nsfwSeen.Load()) + } +} + +func TestWebAccountSettingsMapUnauthorized(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedToken, _ := cipher.Encrypt("expired-sso") + adapter := NewAdapter(Config{BaseURL: server.URL}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + adapter.accountsBaseURL = server.URL + err = adapter.AcceptTerms(context.Background(), account.Credential{ + ID: 2, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, EncryptedAccessToken: encryptedToken, + }) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } +} + +func TestAcceptTermsRequiresBothUpstreamSteps(t *testing.T) { + t.Parallel() + var accountCalls, productCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/auth_mgmt.AuthManagement/SetTosAcceptedVersion": + accountCalls.Add(1) + writer.WriteHeader(http.StatusOK) + case "/rest/auth/set-tos-accepted": + productCalls.Add(1) + writer.WriteHeader(http.StatusBadGateway) + _, _ = writer.Write([]byte(`{"error":"product terms unavailable"}`)) + default: + http.NotFound(writer, request) + } + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedToken, _ := cipher.Encrypt("test-sso") + statsig := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{'s'}, 70)) + adapter := NewAdapter(Config{BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: statsig}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + adapter.accountsBaseURL = server.URL + err = adapter.AcceptTerms(context.Background(), account.Credential{ + ID: 3, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, EncryptedAccessToken: encryptedToken, + }) + if err == nil || accountCalls.Load() != 1 || productCalls.Load() != 1 { + t.Fatalf("err=%v accountCalls=%d productCalls=%d", err, accountCalls.Load(), productCalls.Load()) + } +} + +func TestWebAccountBirthDateMapsOnlyAlreadySetResponse(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + alreadySet bool + }{ + { + name: "birth date locked", + body: `{"code":8,"message":"Birth date is locked once set. Contact support if you need to update it. [WKE=account:birth-date-change-limit-reached]","details":[]}`, + alreadySet: true, + }, + { + name: "ordinary rate limit", + body: `{"code":8,"message":"Too many requests","details":[]}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = writer.Write([]byte(test.body)) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedToken, _ := cipher.Encrypt("test-sso") + statsig := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{'s'}, 70)) + adapter := NewAdapter(Config{BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: statsig}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + + err = adapter.SetBirthDate(context.Background(), account.Credential{ + ID: 4, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, EncryptedAccessToken: encryptedToken, + }, time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC)) + if got := errors.Is(err, provider.ErrBirthDateAlreadySet); got != test.alreadySet { + t.Fatalf("already set = %v, want %v; err = %v", got, test.alreadySet, err) + } + if !test.alreadySet { + status, ok := provider.ErrorHTTPStatus(err) + if !ok || status != http.StatusTooManyRequests { + t.Fatalf("status = %d, ok = %v; err = %v", status, ok, err) + } + } + }) + } +} + +func TestWebAccountSettingsRejectsBodyTrailerFailure(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(grpcWebTrailerFrame("7")) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encryptedToken, _ := cipher.Encrypt("test-sso") + statsig := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{'s'}, 70)) + adapter := NewAdapter(Config{BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: statsig}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + + err = adapter.EnableNSFW(context.Background(), account.Credential{ + ID: 3, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, EncryptedAccessToken: encryptedToken, + }) + if err == nil || !strings.Contains(err.Error(), "gRPC 状态 7") { + t.Fatalf("err = %v", err) + } +} + +func grpcWebTrailerFrame(status string) []byte { + payload := []byte("grpc-status: " + status + "\r\n") + frame := make([]byte, 5+len(payload)) + frame[0] = 0x80 + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + copy(frame[5:], payload) + return frame +} diff --git a/backend/internal/infra/provider/web/adapter.go b/backend/internal/infra/provider/web/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..3aa9c4da21d07059ac5935cff89b97f31cc9f9aa --- /dev/null +++ b/backend/internal/infra/provider/web/adapter.go @@ -0,0 +1,151 @@ +package web + +import ( + "context" + "log/slog" + "sync" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +type Config struct { + BaseURL string + StatsigMode string + StatsigManualValue string + StatsigSignerURL string + QuotaTimeoutSeconds int + ChatTimeoutSeconds int + ImageTimeoutSeconds int + VideoTimeoutSeconds int + MaxInputImageBytes int64 + AllowNSFW bool +} + +type Adapter struct { + mu sync.RWMutex + cfg Config + accountsBaseURL string + egress *infraegress.Manager + cipher *security.Cipher + states repository.ResponseRepository + assets provider.ImageAssetStore + statsig *statsigSigner + logger *slog.Logger +} + +func NewAdapter(cfg Config, egress *infraegress.Manager, cipher *security.Cipher, states repository.ResponseRepository, assets provider.ImageAssetStore) *Adapter { + cfg = normalizedConfig(cfg) + return &Adapter{cfg: cfg, accountsBaseURL: officialAccountsBaseURL, egress: egress, cipher: cipher, states: states, assets: assets, statsig: newStatsigSigner(), logger: slog.Default()} +} + +func (a *Adapter) SetLogger(logger *slog.Logger) { + if logger != nil { + a.logger = logger + } +} + +func (a *Adapter) log() *slog.Logger { + if a.logger != nil { + return a.logger + } + return slog.Default() +} + +func normalizedConfig(cfg Config) Config { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://grok.com" + } + if cfg.StatsigMode == "" { + cfg.StatsigMode = "url" + } + if cfg.StatsigSignerURL == "" { + cfg.StatsigSignerURL = defaultStatsigSignerURL + } + if cfg.QuotaTimeoutSeconds <= 0 { + cfg.QuotaTimeoutSeconds = 25 + } + if cfg.ChatTimeoutSeconds <= 0 { + cfg.ChatTimeoutSeconds = 120 + } + if cfg.ImageTimeoutSeconds <= 0 { + cfg.ImageTimeoutSeconds = 180 + } + if cfg.VideoTimeoutSeconds <= 0 { + cfg.VideoTimeoutSeconds = 900 + } + if cfg.MaxInputImageBytes <= 0 { + cfg.MaxInputImageBytes = 32 << 20 + } + return cfg +} + +func (a *Adapter) UpdateConfig(cfg Config) { + cfg = normalizedConfig(cfg) + a.mu.Lock() + changed := a.cfg.StatsigMode != cfg.StatsigMode || a.cfg.StatsigManualValue != cfg.StatsigManualValue || a.cfg.StatsigSignerURL != cfg.StatsigSignerURL || a.cfg.BaseURL != cfg.BaseURL + a.cfg = cfg + a.mu.Unlock() + if changed && a.statsig != nil { + a.statsig.Clear() + } +} + +func (a *Adapter) config() Config { + a.mu.RLock() + defer a.mu.RUnlock() + return a.cfg +} + +func (a *Adapter) Provider() account.Provider { return account.ProviderWeb } + +func (a *Adapter) QuotaMode(upstreamModel string) string { + if spec, ok := Resolve(upstreamModel); ok { + return spec.Mode + } + return "" +} + +func (a *Adapter) TierOrder(upstreamModel string) []account.WebTier { + spec, ok := Resolve(upstreamModel) + if !ok { + return nil + } + switch spec.MinimumTier { + case account.WebTierHeavy: + return []account.WebTier{account.WebTierHeavy} + case account.WebTierSuper: + return []account.WebTier{account.WebTierSuper, account.WebTierHeavy} + default: + return []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy} + } +} + +func (a *Adapter) PricingModel(upstreamModel string) string { + spec, ok := Resolve(upstreamModel) + if ok { + if spec.Capability == modeldomain.CapabilityChat { + return "grok-4.5" + } + return spec.PublicID + } + return upstreamModel +} + +func (a *Adapter) ListModels(_ context.Context, credential account.Credential) ([]string, error) { + tier := credential.WebTier + if tier == "" || tier == account.WebTierAuto || tier == account.WebTierBasic { + tier = account.WebTierBasic + } + values := make([]string, 0, len(catalog)) + for _, spec := range catalog { + if TierSupports(tier, spec.MinimumTier) { + values = append(values, spec.UpstreamModel) + } + } + return values, nil +} diff --git a/backend/internal/infra/provider/web/attachments.go b/backend/internal/infra/provider/web/attachments.go new file mode 100644 index 0000000000000000000000000000000000000000..b94c34918bfdcc6f5f8e3266112a2669af99e108 --- /dev/null +++ b/backend/internal/infra/provider/web/attachments.go @@ -0,0 +1,471 @@ +package web + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "path" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const ( + maxChatAttachments = 8 + maxChatAttachmentTotal = 64 << 20 + maxRemoteAttachmentURLLen = 8192 +) + +var blockedRemoteImagePrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("2001:db8::/32"), +} + +var ( + errInvalidChatAttachment = errors.New("对话附件无效") + errInvalidChatImage = errors.New("对话图片无效") + errInvalidChatFile = errors.New("对话文件无效") +) + +type uploadedFile struct { + ID string + URI string +} + +type remoteImageTarget struct { + originalURL *url.URL + fetchURL *url.URL + serverName string + hostHeader string +} + +type remoteImageResolver interface { + LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) +} + +// prepareChatAttachments 在同一账号和出口租约内解析、下载并上传对话附件。 +func (a *Adapter) prepareChatAttachments(ctx context.Context, cfg Config, lease *egress.Lease, token string, inputs []chatAttachmentInput) ([]string, error) { + if len(inputs) == 0 { + return nil, nil + } + if len(inputs) > maxChatAttachments { + return nil, fmt.Errorf("%w: 单次对话最多支持 %d 个附件", errInvalidChatAttachment, maxChatAttachments) + } + pending := make([]provider.ImageInput, 0, len(inputs)) + seen := make(map[string]struct{}, len(inputs)) + total := int64(0) + for _, input := range inputs { + input.Source = strings.TrimSpace(input.Source) + key := fmt.Sprintf("%t\x00%s\x00%s", input.Image, input.Filename, input.Source) + if _, exists := seen[key]; exists { + continue + } + var file provider.ImageInput + var err error + if input.Image { + file, err = a.loadChatImage(ctx, lease, input.Source, cfg.MaxInputImageBytes) + } else { + file, err = a.loadChatFile(ctx, lease, input.Source, input.Filename, cfg.MaxInputImageBytes) + } + if err != nil { + return nil, err + } + size := int64(len(file.Data)) + if size > maxChatAttachmentTotal || total > maxChatAttachmentTotal-size { + return nil, fmt.Errorf("%w: 总大小不能超过 64 MiB", errInvalidChatAttachment) + } + total += size + seen[key] = struct{}{} + pending = append(pending, file) + } + attachments := make([]string, 0, len(pending)) + for _, file := range pending { + uploaded, err := a.uploadFileV2Direct(ctx, cfg, lease, token, file, cfg.BaseURL+"/", "", "chat_attachment_upload") + if err != nil { + return nil, err + } + if uploaded.ID == "" { + return nil, fmt.Errorf("上传附件成功但上游未返回 fileMetadataId") + } + attachments = append(attachments, uploaded.ID) + } + return attachments, nil +} + +func (a *Adapter) loadChatImage(ctx context.Context, lease *egress.Lease, input string, maxBytes int64) (provider.ImageInput, error) { + if strings.HasPrefix(strings.ToLower(input), "data:") { + return parseChatImageDataURI(input, maxBytes) + } + target, err := validateRemoteImageURL(ctx, input) + if err != nil { + return provider.ImageInput{}, err + } + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, target.fetchURL.String(), nil) + if err != nil { + return provider.ImageInput{}, err + } + request.Host = target.hostHeader + // 外部图片地址不接收 SSO 或 Cloudflare Cookie,避免把上游凭据泄漏给第三方。 + request.Header = remoteImageHeaders(lease.UserAgent) + response, err := lease.DoPinnedHTTPS(request, target.serverName) + if err != nil { + return provider.ImageInput{}, fmt.Errorf("下载对话图片: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return provider.ImageInput{}, fmt.Errorf("%w: 下载地址返回 %d", errInvalidChatImage, response.StatusCode) + } + if response.ContentLength > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: 图片超过 %d MiB", errInvalidChatImage, maxBytes>>20) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) + if err != nil || int64(len(raw)) > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: 下载失败或图片超过 %d MiB", errInvalidChatImage, maxBytes>>20) + } + mimeType, err := validatedImageMIME(raw, response.Header.Get("Content-Type")) + if err != nil { + return provider.ImageInput{}, err + } + return provider.ImageInput{Filename: imageFilename(target.originalURL, mimeType), MIMEType: mimeType, Data: raw}, nil +} + +func (a *Adapter) loadChatFile(ctx context.Context, lease *egress.Lease, input, filename string, maxBytes int64) (provider.ImageInput, error) { + if strings.HasPrefix(strings.ToLower(input), "data:") { + return parseChatFileDataURI(input, filename, maxBytes) + } + target, err := validateRemoteAttachmentURL(ctx, input, errInvalidChatFile) + if err != nil { + return provider.ImageInput{}, err + } + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, target.fetchURL.String(), nil) + if err != nil { + return provider.ImageInput{}, err + } + request.Host = target.hostHeader + request.Header = remoteFileHeaders(lease.UserAgent) + response, err := lease.DoPinnedHTTPS(request, target.serverName) + if err != nil { + return provider.ImageInput{}, fmt.Errorf("下载对话文件: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return provider.ImageInput{}, fmt.Errorf("%w: 下载地址返回 %d", errInvalidChatFile, response.StatusCode) + } + if response.ContentLength > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: 文件超过 %d MiB", errInvalidChatFile, maxBytes>>20) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) + if err != nil || int64(len(raw)) > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: 下载失败或文件超过 %d MiB", errInvalidChatFile, maxBytes>>20) + } + mimeType, err := validatedChatFileMIME(raw, response.Header.Get("Content-Type"), firstNonEmpty(filename, path.Base(target.originalURL.Path))) + if err != nil { + return provider.ImageInput{}, err + } + return provider.ImageInput{Filename: chatFileName(filename, target.originalURL, mimeType), MIMEType: mimeType, Data: raw}, nil +} + +func remoteImageHeaders(userAgent string) http.Header { + value := http.Header{} + value.Set("Accept", "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1") + value.Set("User-Agent", userAgent) + return value +} + +func remoteFileHeaders(userAgent string) http.Header { + value := http.Header{} + value.Set("Accept", "application/pdf,text/*,application/json,application/xml,application/rtf,application/msword,application/zip,image/*,*/*;q=0.1") + value.Set("User-Agent", userAgent) + return value +} + +func parseChatImageDataURI(value string, maxBytes int64) (provider.ImageInput, error) { + header, encoded, ok := strings.Cut(value, ",") + if !ok || !strings.HasPrefix(strings.ToLower(header), "data:image/") || !strings.Contains(strings.ToLower(header), ";base64") { + return provider.ImageInput{}, fmt.Errorf("%w: data URI 必须是 Base64 图片", errInvalidChatImage) + } + encoded = strings.Join(strings.Fields(encoded), "") + if encoded == "" || int64(base64.StdEncoding.DecodedLen(len(encoded))) > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: 图片为空或超过 %d MiB", errInvalidChatImage, maxBytes>>20) + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + raw, err = base64.RawStdEncoding.DecodeString(encoded) + } + if err != nil || len(raw) == 0 || int64(len(raw)) > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: Base64 无效或图片超过 %d MiB", errInvalidChatImage, maxBytes>>20) + } + declared := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.ToLower(header), "data:"), ";base64")) + mimeType, err := validatedImageMIME(raw, declared) + if err != nil { + return provider.ImageInput{}, err + } + return provider.ImageInput{Filename: "image" + imageExtension(mimeType), MIMEType: mimeType, Data: raw}, nil +} + +func parseChatFileDataURI(value, filename string, maxBytes int64) (provider.ImageInput, error) { + header, encoded, ok := strings.Cut(value, ",") + if !ok || !strings.HasPrefix(strings.ToLower(header), "data:") || !strings.Contains(strings.ToLower(header), ";base64") { + return provider.ImageInput{}, fmt.Errorf("%w: file_data 必须是 Base64 data URI", errInvalidChatFile) + } + encoded = strings.Join(strings.Fields(encoded), "") + if encoded == "" || int64(base64.StdEncoding.DecodedLen(len(encoded))) > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: 文件为空或超过 %d MiB", errInvalidChatFile, maxBytes>>20) + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + raw, err = base64.RawStdEncoding.DecodeString(encoded) + } + if err != nil || len(raw) == 0 || int64(len(raw)) > maxBytes { + return provider.ImageInput{}, fmt.Errorf("%w: Base64 无效或文件超过 %d MiB", errInvalidChatFile, maxBytes>>20) + } + declared := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.ToLower(header), "data:"), ";base64")) + mimeType, err := validatedChatFileMIME(raw, declared, filename) + if err != nil { + return provider.ImageInput{}, err + } + return provider.ImageInput{Filename: chatFileName(filename, nil, mimeType), MIMEType: mimeType, Data: raw}, nil +} + +func validatedImageMIME(data []byte, declared string) (string, error) { + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) + declared = strings.ToLower(strings.TrimSpace(strings.Split(declared, ";")[0])) + if !supportedChatImageMIME(detected) { + return "", fmt.Errorf("%w: 不支持该图片格式", errInvalidChatImage) + } + if declared != "" && declared != "application/octet-stream" && declared != detected { + return "", fmt.Errorf("%w: Content-Type 与实际内容不一致", errInvalidChatImage) + } + return detected, nil +} + +func supportedChatImageMIME(value string) bool { + switch value { + case "image/jpeg", "image/png", "image/webp", "image/gif": + return true + default: + return false + } +} + +func validatedChatFileMIME(data []byte, declared, filename string) (string, error) { + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) + declared = strings.ToLower(strings.TrimSpace(strings.Split(declared, ";")[0])) + if hinted := chatFileMIMEFromExtension(path.Ext(filename)); hinted != "" && (declared == "" || declared == "application/octet-stream" || declared == "application/zip") { + declared = hinted + } + if supportedChatImageMIME(declared) || supportedChatImageMIME(detected) { + return validatedImageMIME(data, declared) + } + if supportedChatFileMIME(declared) { + return declared, nil + } + if supportedChatFileMIME(detected) { + return detected, nil + } + return "", fmt.Errorf("%w: 不支持该文件格式", errInvalidChatFile) +} + +func chatFileMIMEFromExtension(extension string) string { + switch strings.ToLower(extension) { + case ".pdf": + return "application/pdf" + case ".json": + return "application/json" + case ".xml": + return "application/xml" + case ".rtf": + return "application/rtf" + case ".doc": + return "application/msword" + case ".docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case ".ppt": + return "application/vnd.ms-powerpoint" + case ".pptx": + return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case ".xls": + return "application/vnd.ms-excel" + case ".xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case ".csv": + return "text/csv" + case ".md", ".markdown": + return "text/markdown" + case ".html", ".htm": + return "text/html" + case ".txt", ".log": + return "text/plain" + default: + return "" + } +} + +func supportedChatFileMIME(value string) bool { + switch value { + case "application/pdf", "application/json", "application/xml", "application/rtf", "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", "application/vnd.ms-excel", + "text/plain", "text/csv", "text/markdown", "text/html", "text/xml", "text/rtf": + return true + default: + return false + } +} + +func validateRemoteImageURL(ctx context.Context, raw string) (*remoteImageTarget, error) { + return validateRemoteAttachmentURL(ctx, raw, errInvalidChatImage) +} + +func validateRemoteImageURLWithResolver(ctx context.Context, raw string, resolver remoteImageResolver) (*remoteImageTarget, error) { + return validateRemoteAttachmentURLWithResolver(ctx, raw, resolver, errInvalidChatImage) +} + +func validateRemoteAttachmentURL(ctx context.Context, raw string, invalid error) (*remoteImageTarget, error) { + return validateRemoteAttachmentURLWithResolver(ctx, raw, net.DefaultResolver, invalid) +} + +func validateRemoteAttachmentURLWithResolver(ctx context.Context, raw string, resolver remoteImageResolver, invalid error) (*remoteImageTarget, error) { + if len(raw) == 0 || len(raw) > maxRemoteAttachmentURLLen { + return nil, fmt.Errorf("%w: URL 为空或过长", invalid) + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || (parsed.Port() != "" && parsed.Port() != "443") { + return nil, fmt.Errorf("%w: URL 必须是无用户信息的 HTTPS 地址", invalid) + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") { + return nil, fmt.Errorf("%w: URL 指向受限主机", invalid) + } + if address, err := netip.ParseAddr(host); err == nil { + address = address.Unmap() + if !publicRemoteImageAddress(address) { + return nil, fmt.Errorf("%w: URL 指向非公网地址", invalid) + } + return newRemoteImageTarget(parsed, host, address), nil + } + resolveCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + addresses, err := resolver.LookupNetIP(resolveCtx, "ip", host) + if err != nil || len(addresses) == 0 { + return nil, fmt.Errorf("%w: 无法解析附件主机", invalid) + } + for _, address := range addresses { + if !publicRemoteImageAddress(address.Unmap()) { + return nil, fmt.Errorf("%w: URL 解析到非公网地址", invalid) + } + } + return newRemoteImageTarget(parsed, host, addresses[0].Unmap()), nil +} + +func newRemoteImageTarget(original *url.URL, serverName string, address netip.Addr) *remoteImageTarget { + fetchURL := *original + fetchURL.Host = net.JoinHostPort(address.String(), "443") + fetchURL.Fragment = "" + return &remoteImageTarget{originalURL: original, fetchURL: &fetchURL, serverName: serverName, hostHeader: original.Host} +} + +func publicRemoteImageAddress(address netip.Addr) bool { + if !address.IsValid() || !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsMulticast() || address.IsUnspecified() { + return false + } + for _, prefix := range blockedRemoteImagePrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} + +func imageFilename(value *url.URL, mimeType string) string { + name := path.Base(value.Path) + if name == "." || name == "/" || name == "" || len(name) > 160 || strings.IndexFunc(name, func(character rune) bool { return character < 0x20 || character == 0x7f }) >= 0 { + return "image" + imageExtension(mimeType) + } + if path.Ext(name) == "" { + name += imageExtension(mimeType) + } + return name +} + +func chatFileName(preferred string, source *url.URL, mimeType string) string { + name := strings.TrimSpace(preferred) + if name == "" && source != nil { + name = path.Base(source.Path) + } + name = path.Base(strings.ReplaceAll(name, "\\", "/")) + if name == "." || name == "/" || name == "" || len(name) > 160 || strings.IndexFunc(name, func(character rune) bool { return character < 0x20 || character == 0x7f }) >= 0 { + name = "file" + chatFileExtension(mimeType) + } + if path.Ext(name) == "" { + name += chatFileExtension(mimeType) + } + return name +} + +func chatFileExtension(mimeType string) string { + switch mimeType { + case "application/pdf": + return ".pdf" + case "application/json": + return ".json" + case "application/xml", "text/xml": + return ".xml" + case "application/rtf", "text/rtf": + return ".rtf" + case "application/msword": + return ".doc" + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return ".docx" + case "application/vnd.ms-powerpoint": + return ".ppt" + case "application/vnd.openxmlformats-officedocument.presentationml.presentation": + return ".pptx" + case "application/vnd.ms-excel": + return ".xls" + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + return ".xlsx" + case "text/csv": + return ".csv" + case "text/markdown": + return ".md" + case "text/html": + return ".html" + case "text/plain": + return ".txt" + default: + return imageExtension(mimeType) + } +} + +func imageExtension(mimeType string) string { + switch mimeType { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + default: + return ".bin" + } +} diff --git a/backend/internal/infra/provider/web/attachments_test.go b/backend/internal/infra/provider/web/attachments_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1ed94590332f3d33b61f5acdca64085b2d071047 --- /dev/null +++ b/backend/internal/infra/provider/web/attachments_test.go @@ -0,0 +1,57 @@ +package web + +import ( + "context" + "net/netip" + "testing" +) + +type rebindingImageResolver struct { + calls int +} + +func (r *rebindingImageResolver) LookupNetIP(context.Context, string, string) ([]netip.Addr, error) { + r.calls++ + if r.calls == 1 { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil +} + +func TestRemoteImageTargetPinsFirstValidatedResolution(t *testing.T) { + resolver := &rebindingImageResolver{} + target, err := validateRemoteImageURLWithResolver(context.Background(), "https://images.example.test/photo.png?size=large", resolver) + if err != nil { + t.Fatal(err) + } + if resolver.calls != 1 { + t.Fatalf("resolver calls = %d, want 1", resolver.calls) + } + if target.originalURL.Host != "images.example.test" || target.fetchURL.Host != "93.184.216.34:443" { + t.Fatalf("target = %#v", target) + } + if target.hostHeader != "images.example.test" || target.serverName != "images.example.test" { + t.Fatalf("host=%q serverName=%q", target.hostHeader, target.serverName) + } + if target.fetchURL.Path != "/photo.png" || target.fetchURL.RawQuery != "size=large" { + t.Fatalf("fetch URL = %s", target.fetchURL) + } +} + +func TestRemoteImageTargetRejectsAnyPrivateResolution(t *testing.T) { + resolver := staticImageResolver{addresses: []netip.Addr{ + netip.MustParseAddr("93.184.216.34"), + netip.MustParseAddr("10.0.0.8"), + }} + if _, err := validateRemoteImageURLWithResolver(context.Background(), "https://images.example.test/photo.png", resolver); err == nil { + t.Fatal("mixed public and private DNS result was accepted") + } +} + +type staticImageResolver struct { + addresses []netip.Addr +} + +func (r staticImageResolver) LookupNetIP(context.Context, string, string) ([]netip.Addr, error) { + return append([]netip.Addr(nil), r.addresses...), nil +} diff --git a/backend/internal/infra/provider/web/catalog.go b/backend/internal/infra/provider/web/catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..ad68f54cc3cb9f9c849d8b9df2ab0c3d3eaf853a --- /dev/null +++ b/backend/internal/infra/provider/web/catalog.go @@ -0,0 +1,51 @@ +package web + +import ( + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +type ModelSpec struct { + PublicID string + UpstreamModel string + ProtocolModel string + Capability modeldomain.Capability + Mode string + MinimumTier account.WebTier +} + +var catalog = []ModelSpec{ + {PublicID: "grok-chat-fast", UpstreamModel: "grok-chat-fast", Capability: modeldomain.CapabilityChat, Mode: "fast", MinimumTier: account.WebTierBasic}, + {PublicID: "grok-chat-auto", UpstreamModel: "grok-chat-auto", Capability: modeldomain.CapabilityChat, Mode: "auto", MinimumTier: account.WebTierSuper}, + {PublicID: "grok-chat-expert", UpstreamModel: "grok-chat-expert", Capability: modeldomain.CapabilityChat, Mode: "expert", MinimumTier: account.WebTierSuper}, + {PublicID: "grok-chat-heavy", UpstreamModel: "grok-chat-heavy", Capability: modeldomain.CapabilityChat, Mode: "heavy", MinimumTier: account.WebTierHeavy}, + {PublicID: "grok-imagine-image", UpstreamModel: "grok-imagine-image", ProtocolModel: "imagine-lite", Capability: modeldomain.CapabilityImage, Mode: "fast", MinimumTier: account.WebTierBasic}, + {PublicID: "grok-imagine-image-quality", UpstreamModel: "grok-imagine-image-quality", ProtocolModel: "imagine", Capability: modeldomain.CapabilityImage, MinimumTier: account.WebTierSuper}, + {PublicID: "grok-imagine-image-edit", UpstreamModel: "imagine-image-edit", Capability: modeldomain.CapabilityImageEdit, MinimumTier: account.WebTierSuper}, + {PublicID: "grok-imagine-video", UpstreamModel: "grok-imagine-video", ProtocolModel: "imagine-video-gen", Capability: modeldomain.CapabilityVideo, MinimumTier: account.WebTierSuper}, +} + +func Catalog() []ModelSpec { return append([]ModelSpec(nil), catalog...) } + +func Routes() []modeldomain.Route { + values := make([]modeldomain.Route, 0, len(catalog)) + for _, spec := range catalog { + publicID, _ := modeldomain.NormalizePublicID(account.ProviderWeb, spec.PublicID) + values = append(values, modeldomain.Route{PublicID: publicID, Provider: account.ProviderWeb, UpstreamModel: spec.UpstreamModel, Capability: spec.Capability, Enabled: true}) + } + return values +} + +func Resolve(upstreamModel string) (ModelSpec, bool) { + for _, spec := range catalog { + if spec.UpstreamModel == upstreamModel { + return spec, true + } + } + return ModelSpec{}, false +} + +func TierSupports(actual, minimum account.WebTier) bool { + rank := map[account.WebTier]int{account.WebTierBasic: 1, account.WebTierSuper: 2, account.WebTierHeavy: 3} + return rank[actual] >= rank[minimum] +} diff --git a/backend/internal/infra/provider/web/chat.go b/backend/internal/infra/provider/web/chat.go new file mode 100644 index 0000000000000000000000000000000000000000..0ad3c51f5b177dab51eee1236ba4dddac31bce6e --- /dev/null +++ b/backend/internal/infra/provider/web/chat.go @@ -0,0 +1,2230 @@ +package web + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/provider/searchresult" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const webResponseTTL = 30 * 24 * time.Hour + +const maxDeferredSearchTextBytes = 8 << 20 + +const maxTrackedServerTools = 1024 + +var ( + errWebAntiBot = errors.New("Grok Web anti-bot rejection") + errWebUsageLimit = errors.New("Grok Web usage limit reached") +) + +var ( + grokRenderPattern = regexp.MustCompile(`(?s)]*>.*?`) + grokToolNamePattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) +) + +type openAIRequest struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Input json.RawMessage `json:"input"` + Instructions string `json:"instructions"` + PreviousResponseID string `json:"previous_response_id"` + Messages []chatMessage `json:"messages"` + Tools json.RawMessage `json:"tools"` + ToolChoice json.RawMessage `json:"tool_choice"` + ParallelToolCalls *bool `json:"parallel_tool_calls"` + ImageConfig *struct { + Count *int `json:"n"` + ResponseFormat string `json:"response_format"` + } `json:"image_config"` +} + +type chatMessage struct { + Type string `json:"type"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCalls json.RawMessage `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + Output json.RawMessage `json:"output"` +} + +type normalizedChatInput struct { + Prompt string + Attachments []chatAttachmentInput +} + +type chatAttachmentInput struct { + Source string + Filename string + Image bool +} + +type parsedChat struct { + ResponseID string + ConversationID string + ParentID string + Text strings.Builder + upstreamText strings.Builder + Reasoning strings.Builder + Images []string + SearchSources []map[string]any + Annotations []map[string]any + sourceKeys map[string]struct{} + serverToolKeys map[string]struct{} + webSearchKeys map[string]struct{} + cardCache map[string]map[string]any + moderatedImages map[string]struct{} + citationIndex map[string]int + lastCitation int + ServerTools int64 + WebSearchTools int64 + InputTokens int64 + ToolCalls []parsedToolCall + Tools []any + ToolChoice any + ParallelTools bool +} + +func (a *Adapter) ForwardResponse(ctx context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + if request.Method == http.MethodGet || request.Method == http.MethodDelete { + return a.handleResponseResource(ctx, request) + } + if request.Path == "/responses/compact" { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{ + "type": "invalid_request_error", "code": "unsupported_operation", + "message": "Grok Web 模型不支持 /responses/compact", + }}), nil + } + if request.Method != http.MethodPost { + return jsonProviderResponse(http.StatusMethodNotAllowed, map[string]any{"error": map[string]any{"message": "method not allowed"}}), nil + } + var conversationOptions conversation.ResponseOptions + if request.Operation == conversation.OperationMessages { + converted, options, err := conversation.ConvertRequestWithOptions(request.Body, request.Model, request.Operation) + if err != nil { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"type": "error", "error": map[string]any{"type": "invalid_request_error", "message": err.Error()}}), nil + } + request.Body = converted + conversationOptions = options + } + + var input openAIRequest + if err := json.Unmarshal(request.Body, &input); err != nil { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"message": "请求 JSON 无效", "type": "invalid_request_error"}}), nil + } + normalized, err := normalizeOpenAIInput(input, request.Operation) + if err != nil { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"message": err.Error(), "type": "invalid_request_error"}}), nil + } + tools, err := parseToolConfiguration(input.Tools, input.ToolChoice) + if err != nil { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"message": err.Error(), "type": "invalid_request_error", "code": "invalid_tools"}}), nil + } + parallelTools := true + if input.ParallelToolCalls != nil { + parallelTools = *input.ParallelToolCalls + } + spec, ok := Resolve(request.Model) + if ok && spec.ProtocolModel == "imagine-lite" && request.Operation == "chat" { + if len(tools.ResponseTools) > 0 { + return invalidImageRequest("grok-imagine-image 不支持 tools") + } + return a.forwardLiteChatCompletion(ctx, request, input, normalized, spec) + } + if !ok || spec.Capability != "chat" { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"message": "模型不支持文本对话", "type": "invalid_request_error"}}), nil + } + + normalized.Prompt = injectToolPrompt(normalized.Prompt, tools) + responseID := newWebID("resp") + streaming := input.Stream || request.Streaming + var parsed parsedChat + var previous *inferencedomain.WebResponseState + for attempt := 0; attempt < 2; attempt++ { + attemptCtx := ctx + if attempt > 0 { + attemptCtx = infraegress.WithPhysicalCallStage(ctx, "anti_bot_retry") + } + upstream, lease, currentPrevious, statsigTarget, openErr := a.openChat(attemptCtx, request.Credential, input.PreviousResponseID, spec, normalized) + if openErr != nil { + if errors.Is(openErr, errInvalidChatAttachment) || errors.Is(openErr, errInvalidChatImage) || errors.Is(openErr, errInvalidChatFile) { + code := "invalid_attachment_input" + if errors.Is(openErr, errInvalidChatImage) { + code = "invalid_image_input" + } + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{ + "message": openErr.Error(), "type": "invalid_request_error", "code": code, + }}), nil + } + return nil, openErr + } + previous = currentPrevious + if upstream.StatusCode < 200 || upstream.StatusCode >= 300 { + if upstream.StatusCode == http.StatusForbidden { + // Preserve definitive account-block signals before a Statsig retry can discard the first response. + body, readErr := io.ReadAll(io.LimitReader(upstream.Body, 4<<20)) + _ = upstream.Body.Close() + if readErr != nil { + lease.Release() + return nil, readErr + } + if provider.IsDefinitiveAccountBlockBody(body) { + return &provider.Response{ + StatusCode: upstream.StatusCode, Status: upstream.Status, Header: http.Header(upstream.Header), + UpstreamURL: responseUpstreamURL(upstream), + Body: &releaseBody{ReadCloser: io.NopCloser(bytes.NewReader(body)), release: func() { + lease.Release() + }}, + }, nil + } + lease.InvalidateClearance() + if attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, statsigTarget) { + lease.Release() + continue + } + return &provider.Response{ + StatusCode: upstream.StatusCode, Status: upstream.Status, Header: http.Header(upstream.Header), + UpstreamURL: responseUpstreamURL(upstream), + Body: &releaseBody{ReadCloser: io.NopCloser(bytes.NewReader(body)), release: func() { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, upstream.StatusCode, nil) + lease.Release() + }}, + }, nil + } + return &provider.Response{ + StatusCode: upstream.StatusCode, Status: upstream.Status, Header: http.Header(upstream.Header), + UpstreamURL: responseUpstreamURL(upstream), + Body: &releaseBody{ReadCloser: upstream.Body, release: func() { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, upstream.StatusCode, nil) + lease.Release() + }}, + }, nil + } + + if streaming { + prepared, preflightErr := preflightUpstream(upstream.Body) + if preflightErr == nil { + body := a.streamOpenAIResponse(ctx, prepared, lease, request.Credential, responseID, input.Model, request.Operation, normalized.Prompt, previous, tools, parallelTools, conversationOptions) + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: streamHeaders(), Body: body}, nil + } + if errors.Is(preflightErr, errWebAntiBot) && attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, statsigTarget) { + a.releaseStatsigRetry(upstream, lease) + continue + } + _ = upstream.Body.Close() + lease.Release() + if errors.Is(preflightErr, errWebAntiBot) { + a.feedbackAntiBot(ctx, lease, statsigTarget) + return antiBotProviderResponse(), nil + } + return nil, preflightErr + } + + currentParsed, consumeErr := consumeUpstream(upstream.Body, nil) + _ = upstream.Body.Close() + if errors.Is(consumeErr, errWebAntiBot) && attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, statsigTarget) { + lease.Release() + continue + } + lease.Release() + if consumeErr != nil { + if errors.Is(consumeErr, errWebAntiBot) { + a.feedbackAntiBot(ctx, lease, statsigTarget) + return antiBotProviderResponse(), nil + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, consumeErr) + return nil, consumeErr + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + parsed = currentParsed + break + } + parsed.InputTokens = estimateTokens(normalized.Prompt) + parsed.Tools = tools.ResponseTools + parsed.ToolChoice = tools.ResponseChoice + parsed.ParallelTools = parallelTools + applyParsedToolCalls(&parsed, tools) + if err := a.archiveChatImages(ctx, request.Credential, &parsed); err != nil { + return nil, err + } + payload := buildOpenAIResult(request.Operation, responseID, input.Model, parsed, false, conversationOptions) + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + if request.Operation == conversation.OperationResponses { + a.saveResponseState(context.WithoutCancel(ctx), request.Credential.ID, responseID, parsed, data) + } + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: jsonHeaders(), Body: io.NopCloser(bytes.NewReader(data))}, nil +} + +func (a *Adapter) releaseStatsigRetry(upstream *http.Response, lease *infraegress.Lease) { + _ = upstream.Body.Close() + lease.Release() +} + +func (a *Adapter) feedbackAntiBot(ctx context.Context, lease *infraegress.Lease, statsigTarget string) { + a.invalidateSignedStatsig(http.MethodPost, statsigTarget) + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusForbidden, nil) +} + +func preflightUpstream(source io.ReadCloser) (io.ReadCloser, error) { + reader := bufio.NewReaderSize(source, 64<<10) + var prefetched bytes.Buffer + for prefetched.Len() <= 1<<20 { + line, err := reader.ReadString('\n') + if line != "" { + prefetched.WriteString(line) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "data:") { + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + } + if strings.HasPrefix(trimmed, "{") { + var root map[string]any + if json.Unmarshal([]byte(trimmed), &root) == nil { + if errorValue, ok := root["error"].(map[string]any); ok { + return nil, webResponseError(errorValue) + } + if result, ok := root["result"].(map[string]any); ok && (result["conversation"] != nil || result["response"] != nil) { + return &readerCloser{Reader: io.MultiReader(bytes.NewReader(prefetched.Bytes()), reader), closer: source}, nil + } + } + } + } + if err != nil { + if errors.Is(err, io.EOF) && prefetched.Len() > 0 { + return &readerCloser{Reader: bytes.NewReader(prefetched.Bytes()), closer: source}, nil + } + return nil, err + } + } + return nil, fmt.Errorf("Grok Web 首个流事件超过安全检查上限") +} + +func (a *Adapter) openChat(ctx context.Context, credential account.Credential, previousResponseID string, spec ModelSpec, input normalizedChatInput) (*http.Response, *infraegress.Lease, *inferencedomain.WebResponseState, string, error) { + cfg := a.config() + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return nil, nil, nil, "", err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return nil, nil, nil, "", err + } + mode := spec.Mode + endpoint := cfg.BaseURL + "/rest/app-chat/conversations/new" + var previous *inferencedomain.WebResponseState + if previousResponseID != "" { + state, stateErr := a.states.GetWebState(ctx, previousResponseID, time.Now().UTC()) + if stateErr != nil { + lease.Release() + if errors.Is(stateErr, repository.ErrNotFound) { + return nil, nil, nil, "", fmt.Errorf("previous_response_id 不存在或已过期") + } + return nil, nil, nil, "", stateErr + } + if state.AccountID != credential.ID { + lease.Release() + return nil, nil, nil, "", fmt.Errorf("previous_response_id 绑定的账号不一致") + } + previous = &state + endpoint = cfg.BaseURL + "/rest/app-chat/conversations/" + url.PathEscape(state.ConversationID) + "/responses" + } + attachments, err := a.prepareChatAttachments(ctx, cfg, lease, token, input.Attachments) + if err != nil { + lease.Release() + return nil, nil, nil, "", err + } + payload := buildWebChatPayload(input.Prompt, mode, attachments) + if previous != nil { + payload["responseId"] = previous.UpstreamParentResponseID + } + data, _ := json.Marshal(payload) + requestCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.ChatTimeoutSeconds)*time.Second) + request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + cancel() + lease.Release() + return nil, nil, nil, "", err + } + request.Header = buildHeaders(token, lease, "application/json") + applyAppHeaders(request.Header, cfg.BaseURL, cfg.BaseURL+"/") + a.applySignedStatsig(requestCtx, request, token, lease) + response, err := lease.DoDeferredForbidden(request) + if err != nil { + cancel() + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + lease.Release() + return nil, nil, nil, "", err + } + response.Body = &cancelBody{ReadCloser: response.Body, cancel: cancel} + return response, lease, previous, endpoint, nil +} + +func (a *Adapter) handleResponseResource(ctx context.Context, request provider.ResponseResourceRequest) (*provider.Response, error) { + id := strings.TrimPrefix(request.Path, "/responses/") + if before, _, ok := strings.Cut(id, "?"); ok { + id = before + } + id, _ = url.PathUnescape(id) + if request.Method == http.MethodDelete { + if err := a.states.DeleteWebState(ctx, id); err != nil { + return jsonProviderResponse(http.StatusNotFound, map[string]any{"error": map[string]any{"message": "Response 不存在", "type": "invalid_request_error"}}), nil + } + return jsonProviderResponse(http.StatusOK, map[string]any{"id": id, "object": "response.deleted", "deleted": true}), nil + } + state, err := a.states.GetWebState(ctx, id, time.Now().UTC()) + if err != nil { + return jsonProviderResponse(http.StatusNotFound, map[string]any{"error": map[string]any{"message": "Response 不存在或已过期", "type": "invalid_request_error"}}), nil + } + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: jsonHeaders(), Body: io.NopCloser(strings.NewReader(state.ResponseJSON))}, nil +} + +func (a *Adapter) streamOpenAIResponse(ctx context.Context, source io.ReadCloser, lease *infraegress.Lease, credential account.Credential, responseID, model, operation, prompt string, previous *inferencedomain.WebResponseState, tools toolConfiguration, parallelTools bool, options conversation.ResponseOptions) io.ReadCloser { + reader, writer := io.Pipe() + go func() { + defer source.Close() + defer lease.Release() + parsed := &parsedChat{ + ResponseID: responseID, InputTokens: estimateTokens(prompt), Tools: tools.ResponseTools, + ToolChoice: tools.ResponseChoice, ParallelTools: parallelTools, + } + if previous != nil { + parsed.ConversationID = previous.ConversationID + } + var clientText strings.Builder + archivedImages := make(map[string]struct{}) + var sieve *toolStreamSieve + if len(tools.Functions) > 0 && tools.Choice != "none" { + sieve = newToolStreamSieve(tools.available) + } + messagesStream := newWebMessagesStream(writer, responseID, model, parsed.InputTokens, options) + visiblePhase := webVisibleStreamPhase{} + writeDelta := func(kind, delta string) error { + if !visiblePhase.Allow(kind, delta) { + return nil + } + return writeWebStreamDelta(writer, messagesStream, operation, responseID, model, kind, delta) + } + if operation != conversation.OperationMessages { + writeStreamStart(writer, operation, responseID, model, parsed.InputTokens) + } + err := consumeUpstreamInto(source, parsed, func(kind, delta string) error { + if len(parsed.ToolCalls) > 0 && kind != "reasoning" { + return nil + } + if kind == "image" { + rawURL := delta + item, imageErr := a.imageDataItem(ctx, credential, imagineImageValue{URL: delta}, "url") + if imageErr != nil { + return imageErr + } + delta = liteImageMarkdown(item) + if parsed.Text.Len() > 0 { + delta = "\n\n" + delta + } + parsed.Text.WriteString(delta) + archivedImages[rawURL] = struct{}{} + kind = "text" + } + if kind == "text" && sieve != nil { + result := sieve.Feed(delta) + if result.SafeText != "" { + clientText.WriteString(result.SafeText) + if err := writeDelta(kind, result.SafeText); err != nil { + return err + } + } + if result.Complete { + if len(result.Calls) == 0 { + clientText.WriteString(result.Raw) + return writeDelta(kind, result.Raw) + } + parsed.ToolCalls = result.Calls + return writeWebStreamToolCalls(writer, messagesStream, operation, responseID, model, result.Calls) + } + return nil + } + if kind == "text" { + clientText.WriteString(delta) + } + return writeDelta(kind, delta) + }) + if err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + _ = writer.CloseWithError(err) + return + } + if sieve != nil && len(parsed.ToolCalls) == 0 { + result := sieve.Flush() + if result.SafeText != "" { + clientText.WriteString(result.SafeText) + if err := writeDelta("text", result.SafeText); err != nil { + _ = writer.CloseWithError(err) + return + } + } + if len(result.Calls) > 0 { + parsed.ToolCalls = result.Calls + if err := writeWebStreamToolCalls(writer, messagesStream, operation, responseID, model, result.Calls); err != nil { + _ = writer.CloseWithError(err) + return + } + } + } + if len(parsed.ToolCalls) == 0 { + for _, rawURL := range parsed.Images { + if _, exists := archivedImages[rawURL]; exists { + continue + } + item, imageErr := a.imageDataItem(ctx, credential, imagineImageValue{URL: rawURL}, "url") + if imageErr != nil { + _ = writer.CloseWithError(imageErr) + return + } + delta := liteImageMarkdown(item) + if clientText.Len() > 0 { + delta = "\n\n" + delta + } + clientText.WriteString(delta) + if err := writeDelta("text", delta); err != nil { + _ = writer.CloseWithError(err) + return + } + } + } + parsed.Text.Reset() + parsed.Text.WriteString(clientText.String()) + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + payload := buildOpenAIResult(operation, responseID, model, *parsed, false, options) + data, _ := json.Marshal(payload) + if operation == conversation.OperationResponses { + a.saveResponseState(context.WithoutCancel(ctx), credential.ID, responseID, *parsed, data) + } + if operation == conversation.OperationMessages { + if finishErr := messagesStream.Finish(*parsed, payload); finishErr != nil { + _ = writer.CloseWithError(finishErr) + return + } + } else { + writeStreamDone(writer, operation, responseID, model, *parsed, payload) + } + _ = writer.Close() + }() + return reader +} + +func (a *Adapter) saveResponseState(ctx context.Context, accountID uint64, responseID string, parsed parsedChat, data []byte) { + if parsed.ConversationID == "" || parsed.ParentID == "" || a.states == nil { + return + } + now := time.Now().UTC() + _ = a.states.SaveWebState(ctx, inferencedomain.WebResponseState{ + ResponseID: responseID, AccountID: accountID, ConversationID: parsed.ConversationID, + UpstreamParentResponseID: parsed.ParentID, ResponseJSON: string(data), Status: "completed", + ExpiresAt: now.Add(webResponseTTL), CreatedAt: now, UpdatedAt: now, + }) +} + +func normalizeOpenAIInput(input openAIRequest, operation string) (normalizedChatInput, error) { + var messages []chatMessage + if operation == "chat" { + messages = input.Messages + } else { + if strings.TrimSpace(input.Instructions) != "" { + content, _ := json.Marshal(input.Instructions) + messages = append(messages, chatMessage{Role: "system", Content: content}) + } + trimmed := bytes.TrimSpace(input.Input) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return normalizedChatInput{}, errors.New("input 不能为空") + } + if trimmed[0] == '"' { + var text string + if json.Unmarshal(trimmed, &text) != nil { + return normalizedChatInput{}, errors.New("input 格式无效") + } + content, _ := json.Marshal(text) + messages = append(messages, chatMessage{Role: "user", Content: content}) + } else if err := json.Unmarshal(trimmed, &messages); err != nil { + return normalizedChatInput{}, errors.New("input 必须是字符串或消息数组") + } + } + if len(messages) == 0 { + return normalizedChatInput{}, errors.New("messages 不能为空") + } + var builder strings.Builder + attachments := make([]chatAttachmentInput, 0, 2) + for _, message := range messages { + typeName := strings.ToLower(strings.TrimSpace(message.Type)) + if typeName == "function_call" { + if !toolNamePattern.MatchString(strings.TrimSpace(message.Name)) { + return normalizedChatInput{}, errors.New("function_call.name 无效") + } + arguments := normalizeToolArguments(message.Arguments) + if !json.Valid([]byte(arguments)) { + return normalizedChatInput{}, errors.New("function_call.arguments 必须是有效 JSON") + } + builder.WriteString("[assistant]\n\n \n ") + builder.WriteString(message.Name) + builder.WriteString("\n ") + builder.WriteString(arguments) + builder.WriteString("\n \n\n\n") + continue + } + if typeName == "function_call_output" { + text, err := rawTextValue(message.Output) + if err != nil { + return normalizedChatInput{}, errors.New("function_call_output.output 必须是字符串或 JSON") + } + builder.WriteString("[tool result for ") + builder.WriteString(strings.TrimSpace(message.CallID)) + builder.WriteString("]\n") + builder.WriteString(text) + builder.WriteString("\n\n") + continue + } + text, messageAttachments, err := contentTextAndAttachments(message.Content) + if err != nil { + return normalizedChatInput{}, err + } + attachments = append(attachments, messageAttachments...) + if len(message.ToolCalls) > 0 { + xml := toolCallsToXML(message.ToolCalls) + if text != "" && xml != "" { + text += "\n" + xml + } else if xml != "" { + text = xml + } + } + if message.ToolCallID != "" { + text = "Tool result (" + message.ToolCallID + "): " + text + } + if strings.TrimSpace(text) == "" { + continue + } + builder.WriteString("[") + builder.WriteString(strings.ToLower(strings.TrimSpace(message.Role))) + builder.WriteString("]\n") + builder.WriteString(text) + builder.WriteString("\n\n") + } + value := strings.TrimSpace(builder.String()) + if value == "" && len(attachments) == 0 { + return normalizedChatInput{}, errors.New("消息中没有可发送的文本或附件") + } + if len(attachments) > maxChatAttachments { + return normalizedChatInput{}, fmt.Errorf("单次对话最多支持 %d 个附件", maxChatAttachments) + } + return normalizedChatInput{Prompt: value, Attachments: attachments}, nil +} + +func rawTextValue(raw json.RawMessage) (string, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return "", nil + } + var text string + if json.Unmarshal(trimmed, &text) == nil { + return text, nil + } + if !json.Valid(trimmed) { + return "", errors.New("invalid JSON") + } + return string(trimmed), nil +} + +func contentTextAndAttachments(raw json.RawMessage) (string, []chatAttachmentInput, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return "", nil, nil + } + if trimmed[0] == '"' { + var value string + if json.Unmarshal(trimmed, &value) != nil { + return "", nil, errors.New("消息 content 字符串无效") + } + return value, nil, nil + } + var parts []map[string]any + if json.Unmarshal(trimmed, &parts) != nil { + return "", nil, errors.New("消息 content 必须是字符串或内容数组") + } + values := make([]string, 0, len(parts)) + attachments := make([]chatAttachmentInput, 0, 2) + for _, part := range parts { + typeName, _ := part["type"].(string) + switch typeName { + case "text", "input_text", "output_text": + if text, _ := part["text"].(string); text != "" { + values = append(values, text) + } + case "image_url", "input_image", "image": + if value := extractImageURL(part); value != "" { + attachments = append(attachments, chatAttachmentInput{Source: value, Image: true}) + } else if fileID, _ := part["file_id"].(string); fileID != "" { + return "", nil, errors.New("Grok Web 对话暂不支持 input_image.file_id,请使用 image_url 或 Base64 data URI") + } else { + return "", nil, errors.New("图片内容缺少 image_url") + } + case "file", "input_file": + attachment, err := extractFileAttachment(part) + if err != nil { + return "", nil, err + } + attachments = append(attachments, attachment) + case "input_audio": + return "", nil, errors.New("Grok Web 对话暂不支持 input_audio 内容") + default: + return "", nil, fmt.Errorf("Grok Web 对话暂不支持 content.type=%q", typeName) + } + } + return strings.Join(values, "\n"), attachments, nil +} + +func extractFileAttachment(part map[string]any) (chatAttachmentInput, error) { + value := part + if nested, _ := part["file"].(map[string]any); nested != nil { + value = nested + } + if fileID, _ := value["file_id"].(string); strings.TrimSpace(fileID) != "" { + return chatAttachmentInput{}, errors.New("Grok Web 对话暂不支持 input_file.file_id,请使用 file_url 或 file_data") + } + fileURL, _ := value["file_url"].(string) + fileData, _ := value["file_data"].(string) + if strings.TrimSpace(fileURL) != "" && strings.TrimSpace(fileData) != "" { + return chatAttachmentInput{}, errors.New("input_file 不能同时提供 file_url 和 file_data") + } + source := strings.TrimSpace(fileURL) + if source == "" { + source = strings.TrimSpace(fileData) + } + if source == "" { + return chatAttachmentInput{}, errors.New("input_file 缺少 file_url 或 file_data") + } + filename, _ := value["filename"].(string) + return chatAttachmentInput{Source: source, Filename: strings.TrimSpace(filename)}, nil +} + +func extractImageURL(part map[string]any) string { + value := part["image_url"] + if text, ok := value.(string); ok { + return text + } + if object, ok := value.(map[string]any); ok { + text, _ := object["url"].(string) + return text + } + return "" +} + +func buildWebChatPayload(message, mode string, attachments []string) map[string]any { + if attachments == nil { + attachments = []string{} + } + return map[string]any{ + "collectionIds": []any{}, "disabledConnectorIds": []any{}, + "deviceEnvInfo": map[string]any{"darkModeEnabled": false, "devicePixelRatio": 2, "screenHeight": 1328, "screenWidth": 2056, "viewportHeight": 1083, "viewportWidth": 2056}, + "disableMemory": true, "disableSearch": false, "disableSelfHarmShortCircuit": false, + "disableTextFollowUps": false, "enableImageGeneration": true, "enableImageStreaming": true, + "enableSideBySide": true, "fileAttachments": attachments, "forceConcise": false, + "forceSideBySide": false, "imageAttachments": []any{}, "imageGenerationCount": 2, + "isAsyncChat": false, "message": message, "modeId": mode, "responseMetadata": map[string]any{}, + "returnImageBytes": false, "returnRawGrokInXaiRequest": false, + "sendFinalMetadata": true, "temporary": true, + } +} + +func consumeUpstream(source io.Reader, emit func(string, string) error) (parsedChat, error) { + parsed := parsedChat{} + err := consumeUpstreamInto(source, &parsed, emit) + return parsed, err +} + +func consumeUpstreamInto(source io.Reader, parsed *parsedChat, emit func(string, string) error) error { + return consumeJSONObjects(source, 8<<20, func(data []byte) error { + kind, delta, err := parseUpstreamFrame(data, parsed) + if err != nil { + return err + } + if delta != "" && emit != nil { + return emit(kind, delta) + } + return nil + }) +} + +func consumeJSONObjects(source io.Reader, maxObjectBytes int, consume func([]byte) error) error { + reader := bufio.NewReaderSize(source, 64<<10) + frame := make([]byte, 0, 64<<10) + depth := 0 + inString := false + escaped := false + for { + value, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + if depth != 0 { + return io.ErrUnexpectedEOF + } + return nil + } + return err + } + if depth == 0 { + if value != '{' { + continue + } + frame = frame[:0] + depth = 1 + inString = false + escaped = false + frame = append(frame, value) + continue + } + frame = append(frame, value) + if len(frame) > maxObjectBytes { + return fmt.Errorf("Grok Web 单个响应帧超过 %d MiB", maxObjectBytes>>20) + } + if inString { + if escaped { + escaped = false + } else if value == '\\' { + escaped = true + } else if value == '"' { + inString = false + } + continue + } + switch value { + case '"': + inString = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + if err := consume(frame); err != nil { + return err + } + } + } + } +} + +func parseUpstreamFrame(data []byte, parsed *parsedChat) (string, string, error) { + var root map[string]any + if json.Unmarshal(data, &root) != nil { + return "", "", nil + } + if errorValue, ok := root["error"].(map[string]any); ok { + return "", "", webResponseError(errorValue) + } + result, _ := root["result"].(map[string]any) + if conversation, _ := result["conversation"].(map[string]any); conversation != nil { + parsed.ConversationID, _ = conversation["conversationId"].(string) + return "", "", nil + } + response, _ := result["response"].(map[string]any) + if response == nil { + return "", "", nil + } + if errorValue, ok := response["error"].(map[string]any); ok { + return "", "", webResponseError(errorValue) + } + for _, key := range []string{"cardAttachment", "cardAttachments"} { + if rawURL := collectCardAttachment(parsed, response[key]); rawURL != "" { + rawURL = absoluteAssetURL(rawURL) + parsed.Images = appendUniqueString(parsed.Images, rawURL) + return "image", rawURL, nil + } + } + if userResponse, _ := response["userResponse"].(map[string]any); userResponse != nil { + if id, _ := userResponse["responseId"].(string); id != "" { + parsed.ParentID = id + } + } + collectSearchSources(parsed, response) + token, _ := response["token"].(string) + thinking, _ := response["isThinking"].(bool) + tag, _ := response["messageTag"].(string) + if tag == "tool_usage_card" { + collectServerTool(parsed, response) + // tool_usage_card 的 token 是 Grok 内部 XML 协议,不属于模型 reasoning。 + return "", "", nil + } + if token != "" && thinking { + parsed.Reasoning.WriteString(token) + return "reasoning", token, nil + } + if token != "" && !thinking && (tag == "final" || tag == "") { + parsed.upstreamText.WriteString(token) + cleaned := cleanChatToken(parsed, token) + parsed.Text.WriteString(cleaned) + return "text", cleaned, nil + } + if modelResponse, _ := response["modelResponse"].(map[string]any); modelResponse != nil { + return collectModelResponse(parsed, modelResponse) + } + if imageResponse, _ := response["streamingImageGenerationResponse"].(map[string]any); imageResponse != nil { + rawURL, _ := imageResponse["imageUrl"].(string) + if rawURL == "" { + rawURL, _ = imageResponse["url"].(string) + } + if rawURL != "" { + moderated, _ := imageResponse["moderated"].(bool) + if moderated { + markModeratedImage(parsed, rawURL) + return "", "", nil + } + completed, _ := imageResponse["isFinal"].(bool) + if completed || imageResponse["progress"] == float64(100) { + rawURL = absoluteAssetURL(rawURL) + parsed.Images = appendUniqueString(parsed.Images, rawURL) + return "image", rawURL, nil + } + } + } + return "", "", nil +} + +func collectModelResponse(parsed *parsedChat, modelResponse map[string]any) (string, string, error) { + if err := modelResponseStreamError(modelResponse); err != nil { + return "", "", err + } + if parsed.ParentID == "" { + parsed.ParentID, _ = modelResponse["parentResponseId"].(string) + } + collectSearchSources(parsed, modelResponse) + firstImage := collectModelResponseImages(parsed, modelResponse) + message, _ := modelResponse["message"].(string) + if delta := mergeModelResponseText(parsed, message); delta != "" { + return "text", delta, nil + } + if firstImage != "" { + return "image", firstImage, nil + } + return "", "", nil +} + +func mergeModelResponseText(parsed *parsedChat, message string) string { + if message == "" { + return "" + } + raw := parsed.upstreamText.String() + if raw == message || strings.HasPrefix(raw, message) { + return "" + } + if raw != "" && !strings.HasPrefix(message, raw) { + // 已输出内容与最终 envelope 不同,保留已输出结果,避免重复或回滚流式内容。 + return "" + } + delta := message[len(raw):] + parsed.upstreamText.WriteString(delta) + delta = cleanChatToken(parsed, delta) + parsed.Text.WriteString(delta) + return delta +} + +func modelResponseStreamError(modelResponse map[string]any) error { + values, _ := modelResponse["streamErrors"].([]any) + for _, raw := range values { + switch value := raw.(type) { + case string: + if message := strings.TrimSpace(value); message != "" { + return errors.New(message) + } + case map[string]any: + if nested, _ := value["error"].(map[string]any); nested != nil { + return webResponseError(nested) + } + if message := firstString(value, "message", "error", "detail"); message != "" { + return webResponseError(map[string]any{"message": message, "code": value["code"]}) + } + } + } + return nil +} + +func webResponseError(value map[string]any) error { + message, _ := value["message"].(string) + if message == "" { + message = "Grok Web stream error" + } + code, _ := numberAsInt(value["code"]) + if code == 7 || strings.Contains(strings.ToLower(message), "anti-bot") { + return fmt.Errorf("%w: %s", errWebAntiBot, message) + } + normalized := strings.ToLower(message) + if strings.Contains(normalized, "usage limit") || strings.Contains(normalized, "usage quota") { + return fmt.Errorf("%w: %s", errWebUsageLimit, message) + } + return errors.New(message) +} + +func antiBotProviderResponse() *provider.Response { + return jsonProviderResponse(http.StatusForbidden, map[string]any{"error": map[string]any{ + "message": "Grok Web 出口会话被上游反机器人规则拒绝,请检查代理、User-Agent 与 Cloudflare Cookie 是否来自同一浏览器会话", + "type": "upstream_error", "code": "anti_bot_rejected", + }}) +} + +func collectModelResponseImages(parsed *parsedChat, modelResponse map[string]any) string { + first := "" + appendImage := func(value string) { + if strings.TrimSpace(value) == "" { + return + } + value = absoluteAssetURL(value) + if _, moderated := parsed.moderatedImages[value]; moderated { + return + } + if containsString(parsed.Images, value) { + return + } + parsed.Images = append(parsed.Images, value) + if first == "" { + first = value + } + } + if urls, ok := modelResponse["generatedImageUrls"].([]any); ok { + for _, raw := range urls { + value, _ := raw.(string) + appendImage(value) + } + } + if cards, ok := modelResponse["cardAttachmentsJson"].([]any); ok { + for _, raw := range cards { + encoded, _ := raw.(string) + var card map[string]any + if encoded == "" || json.Unmarshal([]byte(encoded), &card) != nil { + continue + } + appendImage(imageURLFromCardData(card)) + } + } + return first +} + +func markModeratedImage(parsed *parsedChat, value string) { + if strings.TrimSpace(value) == "" { + return + } + if parsed.moderatedImages == nil { + parsed.moderatedImages = make(map[string]struct{}) + } + parsed.moderatedImages[absoluteAssetURL(value)] = struct{}{} +} + +func collectSearchSources(parsed *parsedChat, response map[string]any) { + if parsed.sourceKeys == nil { + parsed.sourceKeys = make(map[string]struct{}) + } + collectWebSearchResults(parsed, response["webSearchResults"]) + collectWebSearchResults(parsed, response["citedWebSearchResults"]) + collectXSearchResults(parsed, response["xSearchResults"]) + collectXSearchResults(parsed, response["xposts"]) + collectXSearchResults(parsed, response["citedXposts"]) +} + +func collectWebSearchResults(parsed *parsedChat, value any) { + if wrapped, _ := value.(map[string]any); wrapped != nil { + value = wrapped["results"] + } + values, _ := value.([]any) + for _, raw := range values { + item, _ := raw.(map[string]any) + rawURL, _ := item["url"].(string) + if rawURL == "" { + continue + } + title, _ := item["title"].(string) + appendSearchSource(parsed, rawURL, title, "web") + } +} + +func collectXSearchResults(parsed *parsedChat, value any) { + if wrapped, _ := value.(map[string]any); wrapped != nil { + value = wrapped["results"] + } + values, _ := value.([]any) + for _, raw := range values { + item, _ := raw.(map[string]any) + username, _ := item["username"].(string) + postID, _ := item["postId"].(string) + if username == "" || postID == "" { + continue + } + title, _ := item["text"].(string) + rawURL := "https://x.com/" + url.PathEscape(username) + "/status/" + url.PathEscape(postID) + appendSearchSource(parsed, rawURL, title, "x_post") + } +} + +func appendSearchSource(parsed *parsedChat, value, title, sourceType string) { + value, valid := searchresult.NormalizeURL(value) + if !valid { + return + } + if parsed.sourceKeys == nil { + parsed.sourceKeys = make(map[string]struct{}) + } + if _, exists := parsed.sourceKeys[value]; exists { + return + } + if len(parsed.SearchSources) >= searchresult.MaxResults { + return + } + parsed.sourceKeys[value] = struct{}{} + title = searchresult.NormalizeTitle(title, value) + parsed.SearchSources = append(parsed.SearchSources, map[string]any{"url": value, "title": title, "type": sourceType}) +} + +func collectServerTool(parsed *parsedChat, response map[string]any) { + if parsed.serverToolKeys == nil { + parsed.serverToolKeys = make(map[string]struct{}) + } + key := serverToolKey(response) + if _, exists := parsed.serverToolKeys[key]; !exists { + if len(parsed.serverToolKeys) >= maxTrackedServerTools { + return + } + parsed.serverToolKeys[key] = struct{}{} + parsed.ServerTools++ + } + if webServerToolName(response) != "web_search" { + return + } + if parsed.webSearchKeys == nil { + parsed.webSearchKeys = make(map[string]struct{}) + } + if _, exists := parsed.webSearchKeys[key]; exists || len(parsed.webSearchKeys) >= maxTrackedServerTools { + return + } + parsed.webSearchKeys[key] = struct{}{} + parsed.WebSearchTools++ +} + +func serverToolKey(response map[string]any) string { + key := firstString(response, "rolloutId", "responseId", "toolUsageCardId") + step, hasStep := numberAsInt(response["messageStepId"]) + if key != "" { + if hasStep { + key += fmt.Sprintf(":%d", step) + } + return key + } + if token, _ := response["token"].(string); token != "" { + sum := sha256.Sum256([]byte(token)) + return "token:" + hex.EncodeToString(sum[:8]) + } + if hasStep { + return fmt.Sprintf("step:%d", step) + } + return firstString(response, "messageTag") +} + +func webServerToolName(response map[string]any) string { + if name := strings.ToLower(strings.TrimSpace(firstString(response, "toolName", "tool_name"))); name != "" { + return name + } + if card, _ := response["toolUsageCard"].(map[string]any); card != nil { + if name := strings.ToLower(strings.TrimSpace(firstString(card, "toolName", "tool_name", "name"))); name != "" { + return name + } + for _, tool := range []struct { + field string + name string + }{ + {field: "webSearch", name: "web_search"}, + {field: "web_search", name: "web_search"}, + {field: "xSearch", name: "x_search"}, + {field: "x_search", name: "x_search"}, + {field: "browsePage", name: "browse_page"}, + {field: "browse_page", name: "browse_page"}, + {field: "searchImages", name: "search_images"}, + {field: "search_images", name: "search_images"}, + {field: "chatroomSend", name: "chatroom_send"}, + {field: "chatroom_send", name: "chatroom_send"}, + } { + if card[tool.field] != nil { + return tool.name + } + } + } + token, _ := response["token"].(string) + match := grokToolNamePattern.FindStringSubmatch(token) + if len(match) < 2 { + return "" + } + name := strings.TrimSpace(match[1]) + name = strings.TrimPrefix(name, "") + return strings.ToLower(strings.TrimSpace(name)) +} + +func applyParsedToolCalls(parsed *parsedChat, configuration toolConfiguration) { + if len(configuration.Functions) == 0 || configuration.Choice == "none" { + return + } + result := parseToolCalls(parsed.Text.String(), configuration.available) + if len(result.Calls) == 0 { + return + } + cleaned := removeToolSyntax(parsed.Text.String(), result) + parsed.Text.Reset() + parsed.Text.WriteString(cleaned) + parsed.ToolCalls = result.Calls +} + +func (a *Adapter) archiveChatImages(ctx context.Context, credential account.Credential, parsed *parsedChat) error { + for _, rawURL := range parsed.Images { + item, err := a.imageDataItem(ctx, credential, imagineImageValue{URL: rawURL}, "url") + if err != nil { + return err + } + if parsed.Text.Len() > 0 { + parsed.Text.WriteString("\n\n") + } + parsed.Text.WriteString(liteImageMarkdown(item)) + } + return nil +} + +func appendUniqueString(values []string, value string) []string { + if containsString(values, value) { + return values + } + return append(values, value) +} + +func containsString(values []string, value string) bool { + for _, existing := range values { + if existing == value { + return true + } + } + return false +} + +func collectCardAttachment(parsed *parsedChat, value any) string { + if values, ok := value.([]any); ok { + first := "" + for _, item := range values { + if rawURL := collectCardAttachment(parsed, item); first == "" && rawURL != "" { + first = rawURL + } + } + return first + } + data := cardAttachmentData(value) + if data == nil { + return "" + } + if id, _ := data["id"].(string); id != "" { + if parsed.cardCache == nil { + parsed.cardCache = make(map[string]map[string]any) + } + parsed.cardCache[id] = data + } + return imageURLFromCardData(data) +} + +func cardAttachmentData(value any) map[string]any { + card, ok := value.(map[string]any) + if !ok { + return nil + } + if raw, ok := card["jsonData"].(map[string]any); ok { + return raw + } + if raw, _ := card["jsonData"].(string); raw != "" { + var data map[string]any + if json.Unmarshal([]byte(raw), &data) == nil { + return data + } + } + if card["image_chunk"] != nil || card["imageChunk"] != nil { + return card + } + return nil +} + +func imageURLFromCardData(data map[string]any) string { + chunk, _ := data["image_chunk"].(map[string]any) + if chunk == nil { + chunk, _ = data["imageChunk"].(map[string]any) + } + if chunk == nil { + return "" + } + moderated, _ := chunk["moderated"].(bool) + progress, _ := numberAsInt(chunk["progress"]) + if moderated || progress < 100 { + return "" + } + imageURL, _ := chunk["imageUrl"].(string) + if imageURL == "" { + imageURL, _ = chunk["image_url"].(string) + } + return imageURL +} + +func cleanChatToken(parsed *parsedChat, token string) string { + if !strings.Contains(token, " 0 { + options = responseOptions[0] + } + inputTokens := parsed.InputTokens + outputTokens := estimateTokens(parsed.Text.String()) + estimateTokens(parsed.Reasoning.String()) + estimateToolCallTokens(parsed.ToolCalls) + if operation == "chat" { + message := map[string]any{"role": "assistant", "content": parsed.Text.String(), "reasoning_content": parsed.Reasoning.String()} + if len(parsed.Annotations) > 0 { + message["annotations"] = chatAnnotations(parsed.Annotations) + } + finishReason := "stop" + if len(parsed.ToolCalls) > 0 { + finishReason = "tool_calls" + if parsed.Text.Len() == 0 { + message["content"] = nil + } + message["tool_calls"] = chatToolCalls(parsed.ToolCalls) + } + value := map[string]any{ + "id": strings.Replace(responseID, "resp_", "chatcmpl_", 1), "object": "chat.completion", "created": created, "model": model, + "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finishReason}}, + "usage": map[string]any{"prompt_tokens": inputTokens, "completion_tokens": outputTokens, "total_tokens": inputTokens + outputTokens}, + } + if len(parsed.SearchSources) > 0 { + value["search_sources"] = parsed.SearchSources + } + return value + } + if operation == conversation.OperationMessages { + visibleText, stopSequence := applyWebStopSequences(parsed.Text.String(), options.StopSequences) + emitWebSearch := shouldEmitWebMessagesSearch(parsed, options) + // Zero initial capacity: tool/search counts come from untrusted upstream. + content := make([]any, 0) + if options.AnthropicThinking && parsed.Reasoning.Len() > 0 { + content = append(content, map[string]any{"type": "thinking", "thinking": parsed.Reasoning.String()}) + } + if emitWebSearch { + content = append(content, webMessagesSearchBlocks(newWebID("srvtoolu"), parsed, options)...) + } + if visibleText != "" || len(parsed.ToolCalls) == 0 { + content = append(content, map[string]any{"type": "text", "text": visibleText}) + } + for _, call := range parsed.ToolCalls { + var input any = map[string]any{} + if json.Unmarshal([]byte(call.Arguments), &input) != nil { + input = map[string]any{} + } + content = append(content, map[string]any{"type": "tool_use", "id": webAnthropicToolID(call.ID), "name": call.Name, "input": input}) + } + stopReason := "end_turn" + if len(parsed.ToolCalls) > 0 { + stopReason = "tool_use" + } else if stopSequence != "" { + stopReason = "stop_sequence" + } + usage := map[string]any{"input_tokens": inputTokens, "output_tokens": outputTokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0} + if emitWebSearch { + usage["server_tool_use"] = map[string]any{"web_search_requests": webMessagesSearchRequests(parsed)} + } + return map[string]any{ + "id": strings.Replace(responseID, "resp_", "msg_", 1), "type": "message", "role": "assistant", "model": model, + "content": content, "stop_reason": stopReason, "stop_sequence": nullableWebString(stopSequence), + "usage": usage, + } + } + output := make([]any, 0, 2) + if parsed.Reasoning.Len() > 0 { + output = append(output, map[string]any{"id": newWebID("rs"), "type": "reasoning", "status": "completed", "summary": []any{map[string]any{"type": "summary_text", "text": parsed.Reasoning.String()}}}) + } + if parsed.Text.Len() > 0 || len(parsed.ToolCalls) == 0 { + annotations := parsed.Annotations + if annotations == nil { + annotations = []map[string]any{} + } + message := map[string]any{"id": newWebID("msg"), "type": "message", "role": "assistant", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": parsed.Text.String(), "annotations": annotations, "logprobs": []any{}}}} + if len(parsed.SearchSources) > 0 { + message["search_sources"] = parsed.SearchSources + } + output = append(output, message) + } + for _, call := range parsed.ToolCalls { + output = append(output, map[string]any{ + "id": newWebID("fc"), "type": "function_call", "status": "completed", + "call_id": call.ID, "name": call.Name, "arguments": call.Arguments, + }) + } + tools := parsed.Tools + if tools == nil { + tools = []any{} + } + toolChoice := parsed.ToolChoice + if toolChoice == nil { + toolChoice = "auto" + } + return map[string]any{ + "id": responseID, "object": "response", "created_at": created, "completed_at": created, "status": "completed", "model": model, + "output": output, "parallel_tool_calls": parsed.ParallelTools, "tools": tools, "tool_choice": toolChoice, "store": true, + "usage": map[string]any{ + "input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": inputTokens + outputTokens, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": estimateTokens(parsed.Reasoning.String())}, + "num_sources_used": int64(len(parsed.SearchSources)), "num_server_side_tools_used": parsed.ServerTools, + }, + } +} + +func applyWebStopSequences(text string, sequences []string) (string, string) { + matchAt := -1 + matched := "" + for _, sequence := range sequences { + if sequence == "" { + continue + } + if index := strings.Index(text, sequence); index >= 0 && (matchAt < 0 || index < matchAt) { + matchAt = index + matched = sequence + } + } + if matchAt < 0 { + return text, "" + } + return text[:matchAt], matched +} + +func nullableWebString(value string) any { + if value == "" { + return nil + } + return value +} + +func webAnthropicToolID(value string) string { + if strings.HasPrefix(value, "toolu_") { + return value + } + return "toolu_" + value +} + +func webMessagesSearchBlocks(id string, parsed parsedChat, options conversation.ResponseOptions) []any { + use := map[string]any{ + "type": "server_tool_use", "id": id, "name": "web_search", + "input": map[string]any{"query": options.AnthropicWebSearchQuery}, + } + hits := make([]any, 0, searchresult.MaxResults) + seen := make(map[string]struct{}, searchresult.MaxResults) + for _, source := range parsed.SearchSources { + if len(hits) >= searchresult.MaxResults { + break + } + rawURL, _ := source["url"].(string) + rawURL, valid := searchresult.NormalizeURL(rawURL) + if !valid { + continue + } + if _, exists := seen[rawURL]; exists { + continue + } + seen[rawURL] = struct{}{} + title, _ := source["title"].(string) + title = searchresult.NormalizeTitle(title, rawURL) + hits = append(hits, map[string]any{"type": "web_search_result", "title": title, "url": rawURL}) + } + var content any = hits + if parsed.WebSearchTools == 0 && len(hits) == 0 { + content = map[string]any{"type": "web_search_tool_result_error", "error_code": "unavailable"} + } + result := map[string]any{"type": "web_search_tool_result", "tool_use_id": id, "content": content} + return []any{use, result} +} + +func shouldEmitWebMessagesSearch(parsed parsedChat, options conversation.ResponseOptions) bool { + return options.AnthropicWebSearch && (options.AnthropicWebSearchRequired || parsed.WebSearchTools > 0 || len(parsed.SearchSources) > 0) +} + +func webMessagesSearchRequests(parsed parsedChat) int64 { + if parsed.WebSearchTools > 0 { + return parsed.WebSearchTools + } + return 1 +} + +func chatToolCalls(calls []parsedToolCall) []any { + values := make([]any, 0, len(calls)) + for _, call := range calls { + values = append(values, map[string]any{ + "id": call.ID, "type": "function", + "function": map[string]any{"name": call.Name, "arguments": call.Arguments}, + }) + } + return values +} + +func chatAnnotations(annotations []map[string]any) []any { + values := make([]any, 0, len(annotations)) + for _, annotation := range annotations { + values = append(values, map[string]any{ + "type": "url_citation", + "url_citation": map[string]any{ + "url": annotation["url"], "title": annotation["title"], + "start_index": annotation["start_index"], "end_index": annotation["end_index"], + }, + }) + } + return values +} + +func estimateToolCallTokens(calls []parsedToolCall) int64 { + var total int64 + for _, call := range calls { + total += estimateTokens(call.Name) + estimateTokens(call.Arguments) + } + return total +} + +type webMessagesStream struct { + writer io.Writer + responseID string + model string + inputTokens int64 + options conversation.ResponseOptions + started bool + thinkingStarted bool + thinkingClosed bool + thinkingIndex int + textStarted bool + textClosed bool + textIndex int + nextIndex int + hasTools bool + webSearchID string + webSearchUse bool + pendingText strings.Builder + stopSequence string + stopFilter *webStopFilter +} + +func newWebMessagesStream(writer io.Writer, responseID, model string, inputTokens int64, options conversation.ResponseOptions) *webMessagesStream { + return &webMessagesStream{ + writer: writer, responseID: responseID, model: model, inputTokens: inputTokens, + options: options, stopFilter: newWebStopFilter(options.StopSequences), + } +} + +func (s *webMessagesStream) Start() error { + if s.started { + return nil + } + s.started = true + if err := writeSSE(s.writer, "message_start", map[string]any{ + "type": "message_start", "message": map[string]any{ + "id": strings.Replace(s.responseID, "resp_", "msg_", 1), "type": "message", "role": "assistant", "model": s.model, + "content": []any{}, "stop_reason": nil, "stop_sequence": nil, + "usage": map[string]any{"input_tokens": s.inputTokens, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, + }, + }); err != nil { + return err + } + if s.options.AnthropicWebSearchRequired && !s.options.AnthropicThinking { + return s.startWebSearch() + } + return nil +} + +func (s *webMessagesStream) Delta(kind, delta string) error { + if err := s.Start(); err != nil { + return err + } + if s.stopSequence != "" { + return nil + } + if kind == "reasoning" { + if !s.options.AnthropicThinking { + return nil + } + if !s.thinkingStarted { + s.thinkingStarted = true + s.thinkingIndex = s.nextIndex + s.nextIndex++ + if err := writeSSE(s.writer, "content_block_start", map[string]any{ + "type": "content_block_start", "index": s.thinkingIndex, + "content_block": map[string]any{"type": "thinking", "thinking": ""}, + }); err != nil { + return err + } + } + return writeSSE(s.writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", "index": s.thinkingIndex, + "delta": map[string]any{"type": "thinking_delta", "thinking": delta}, + }) + } + if kind != "text" { + return nil + } + if s.options.AnthropicWebSearch { + if err := s.closeThinking(); err != nil { + return err + } + if s.options.AnthropicWebSearchRequired { + if err := s.startWebSearch(); err != nil { + return err + } + } + return s.bufferSearchText(delta) + } + if err := s.startText(); err != nil { + return err + } + emit, matched := s.stopFilter.Push(delta) + if matched != "" { + s.stopSequence = matched + } + if emit == "" { + return nil + } + return s.writeTextDelta(emit) +} + +func (s *webMessagesStream) bufferSearchText(delta string) error { + pending := s.pendingText.Len() + if pending >= maxDeferredSearchTextBytes || len(delta) > maxDeferredSearchTextBytes-pending { + return fmt.Errorf("WebSearch 延迟文本缓冲超过 %d MiB", maxDeferredSearchTextBytes>>20) + } + s.pendingText.WriteString(delta) + return nil +} + +func (s *webMessagesStream) startText() error { + if s.textStarted && !s.textClosed { + return nil + } + if err := s.closeThinking(); err != nil { + return err + } + s.textStarted = true + s.textClosed = false + s.textIndex = s.nextIndex + s.nextIndex++ + return writeSSE(s.writer, "content_block_start", map[string]any{ + "type": "content_block_start", "index": s.textIndex, + "content_block": map[string]any{"type": "text", "text": ""}, + }) +} + +func (s *webMessagesStream) writeTextDelta(delta string) error { + if delta == "" { + return nil + } + return writeSSE(s.writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", "index": s.textIndex, + "delta": map[string]any{"type": "text_delta", "text": delta}, + }) +} + +func (s *webMessagesStream) Tools(calls []parsedToolCall) error { + if err := s.Start(); err != nil { + return err + } + if err := s.closeThinking(); err != nil { + return err + } + if err := s.closeText(); err != nil { + return err + } + for _, call := range calls { + index := s.nextIndex + s.nextIndex++ + id := webAnthropicToolID(call.ID) + if err := writeSSE(s.writer, "content_block_start", map[string]any{ + "type": "content_block_start", "index": index, + "content_block": map[string]any{"type": "tool_use", "id": id, "name": call.Name, "input": map[string]any{}}, + }); err != nil { + return err + } + if err := writeSSE(s.writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", "index": index, + "delta": map[string]any{"type": "input_json_delta", "partial_json": call.Arguments}, + }); err != nil { + return err + } + if err := writeSSE(s.writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}); err != nil { + return err + } + s.hasTools = true + } + return nil +} + +func (s *webMessagesStream) startWebSearch() error { + if s.webSearchUse { + return nil + } + s.webSearchUse = true + if s.webSearchID == "" { + s.webSearchID = newWebID("srvtoolu") + } + index := s.nextIndex + s.nextIndex++ + if err := writeSSE(s.writer, "content_block_start", map[string]any{ + "type": "content_block_start", "index": index, + "content_block": map[string]any{"type": "server_tool_use", "id": s.webSearchID, "name": "web_search", "input": map[string]any{}}, + }); err != nil { + return err + } + if query := s.options.AnthropicWebSearchQuery; query != "" { + encoded, _ := json.Marshal(map[string]string{"query": query}) + if err := writeSSE(s.writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", "index": index, + "delta": map[string]any{"type": "input_json_delta", "partial_json": string(encoded)}, + }); err != nil { + return err + } + } + return writeSSE(s.writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) +} + +func (s *webMessagesStream) finishWebSearch(parsed parsedChat) error { + if !shouldEmitWebMessagesSearch(parsed, s.options) { + return nil + } + if err := s.startWebSearch(); err != nil { + return err + } + blocks := webMessagesSearchBlocks(s.webSearchID, parsed, s.options) + result := blocks[1] + index := s.nextIndex + s.nextIndex++ + if err := writeSSE(s.writer, "content_block_start", map[string]any{ + "type": "content_block_start", "index": index, "content_block": result, + }); err != nil { + return err + } + return writeSSE(s.writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) +} + +func (s *webMessagesStream) Finish(parsed parsedChat, payload map[string]any) error { + if err := s.Start(); err != nil { + return err + } + if err := s.closeThinking(); err != nil { + return err + } + if err := s.finishWebSearch(parsed); err != nil { + return err + } + if s.options.AnthropicWebSearch && s.pendingText.Len() > 0 { + if err := s.startText(); err != nil { + return err + } + emit, matched := s.stopFilter.Push(s.pendingText.String()) + if matched != "" { + s.stopSequence = matched + } + if err := s.writeTextDelta(emit); err != nil { + return err + } + } + if s.stopSequence == "" { + if pending := s.stopFilter.Flush(); pending != "" { + if err := s.startText(); err != nil { + return err + } + if err := s.writeTextDelta(pending); err != nil { + return err + } + } + } + if err := s.closeText(); err != nil { + return err + } + stopReason := "end_turn" + if s.hasTools || len(parsed.ToolCalls) > 0 { + stopReason = "tool_use" + } else if s.stopSequence != "" { + stopReason = "stop_sequence" + } + usage, _ := payload["usage"].(map[string]any) + finalUsage := map[string]any{"output_tokens": usage["output_tokens"]} + if shouldEmitWebMessagesSearch(parsed, s.options) { + finalUsage["server_tool_use"] = map[string]any{"web_search_requests": webMessagesSearchRequests(parsed)} + } + if err := writeSSE(s.writer, "message_delta", map[string]any{ + "type": "message_delta", "delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nullableWebString(s.stopSequence)}, + "usage": finalUsage, + }); err != nil { + return err + } + return writeSSE(s.writer, "message_stop", map[string]any{"type": "message_stop"}) +} + +func (s *webMessagesStream) closeThinking() error { + if !s.thinkingStarted || s.thinkingClosed { + return nil + } + s.thinkingClosed = true + return writeSSE(s.writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": s.thinkingIndex}) +} + +func (s *webMessagesStream) closeText() error { + if !s.textStarted || s.textClosed { + return nil + } + s.textClosed = true + return writeSSE(s.writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": s.textIndex}) +} + +func writeWebStreamDelta(writer io.Writer, stream *webMessagesStream, operation, responseID, model, kind, delta string) error { + if operation == conversation.OperationMessages { + return stream.Delta(kind, delta) + } + return writeStreamDelta(writer, operation, responseID, model, kind, delta) +} + +func writeWebStreamToolCalls(writer io.Writer, stream *webMessagesStream, operation, responseID, model string, calls []parsedToolCall) error { + if operation == conversation.OperationMessages { + return stream.Tools(calls) + } + return writeStreamToolCalls(writer, operation, responseID, model, calls) +} + +type webStopFilter struct { + sequences []string + pending string + matched string +} + +func newWebStopFilter(sequences []string) *webStopFilter { + filtered := make([]string, 0, len(sequences)) + for _, sequence := range sequences { + if sequence != "" { + filtered = append(filtered, sequence) + } + } + return &webStopFilter{sequences: filtered} +} + +func (f *webStopFilter) Push(delta string) (string, string) { + if f == nil || len(f.sequences) == 0 { + return delta, "" + } + if f.matched != "" { + return "", f.matched + } + f.pending += delta + matchAt := -1 + matched := "" + for _, sequence := range f.sequences { + if index := strings.Index(f.pending, sequence); index >= 0 && (matchAt < 0 || index < matchAt) { + matchAt = index + matched = sequence + } + } + if matchAt >= 0 { + emit := f.pending[:matchAt] + f.pending = "" + f.matched = matched + return emit, matched + } + hold := 0 + for _, sequence := range f.sequences { + maxPrefix := min(len(sequence)-1, len(f.pending)) + for size := maxPrefix; size > hold; size-- { + if strings.HasSuffix(f.pending, sequence[:size]) { + hold = size + break + } + } + } + emitAt := len(f.pending) - hold + emit := f.pending[:emitAt] + f.pending = f.pending[emitAt:] + return emit, "" +} + +func (f *webStopFilter) Flush() string { + if f == nil || f.matched != "" { + return "" + } + value := f.pending + f.pending = "" + return value +} + +func writeStreamStart(writer io.Writer, operation, responseID, model string, inputTokens int64) { + if operation == "chat" { + chunk := map[string]any{"id": strings.Replace(responseID, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", "created": time.Now().Unix(), "model": model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}}} + writeSSE(writer, "", chunk) + return + } + if operation == conversation.OperationMessages { + writeSSE(writer, "message_start", map[string]any{ + "type": "message_start", "message": map[string]any{ + "id": strings.Replace(responseID, "resp_", "msg_", 1), "type": "message", "role": "assistant", "model": model, + "content": []any{}, "stop_reason": nil, "stop_sequence": nil, + "usage": map[string]any{"input_tokens": inputTokens, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, + }, + }) + writeSSE(writer, "content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}}) + return + } + writeSSE(writer, "response.created", map[string]any{"type": "response.created", "response": map[string]any{"id": responseID, "object": "response", "status": "in_progress", "model": model, "output": []any{}}}) +} + +type webVisibleStreamPhase struct { + textStarted bool +} + +// Allow keeps client-visible output monotonic when Grok Web emits additional +// reasoning after final text has already started. The complete reasoning is +// still retained in parsedChat for non-streaming output and usage accounting. +func (p *webVisibleStreamPhase) Allow(kind, delta string) bool { + if delta == "" { + return false + } + if kind == "reasoning" { + return !p.textStarted + } + if kind == "text" { + p.textStarted = true + } + return true +} + +func writeStreamDelta(writer io.Writer, operation, responseID, model, kind, delta string) error { + if operation == "chat" { + field := "content" + if kind == "reasoning" { + field = "reasoning_content" + } + chunk := map[string]any{"id": strings.Replace(responseID, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", "created": time.Now().Unix(), "model": model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{field: delta}, "finish_reason": nil}}} + return writeSSE(writer, "", chunk) + } + if operation == conversation.OperationMessages { + if kind == "reasoning" { + return nil + } + return writeSSE(writer, "content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta}}) + } + event := "response.output_text.delta" + if kind == "reasoning" { + event = "response.reasoning_summary_text.delta" + } + return writeSSE(writer, event, map[string]any{"type": event, "response_id": responseID, "delta": delta}) +} + +func writeStreamToolCalls(writer io.Writer, operation, responseID, model string, calls []parsedToolCall) error { + if operation == "chat" { + for index, call := range calls { + chunk := map[string]any{ + "id": strings.Replace(responseID, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", + "created": time.Now().Unix(), "model": model, + "choices": []any{map[string]any{"index": 0, "finish_reason": nil, "delta": map[string]any{ + "tool_calls": []any{map[string]any{ + "index": index, "id": call.ID, "type": "function", + "function": map[string]any{"name": call.Name, "arguments": call.Arguments}, + }}, + }}}, + } + if err := writeSSE(writer, "", chunk); err != nil { + return err + } + } + return nil + } + if operation == conversation.OperationMessages { + for index, call := range calls { + contentIndex := index + 1 + if err := writeSSE(writer, "content_block_start", map[string]any{ + "type": "content_block_start", "index": contentIndex, + "content_block": map[string]any{"type": "tool_use", "id": call.ID, "name": call.Name, "input": map[string]any{}}, + }); err != nil { + return err + } + if err := writeSSE(writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", "index": contentIndex, + "delta": map[string]any{"type": "input_json_delta", "partial_json": call.Arguments}, + }); err != nil { + return err + } + if err := writeSSE(writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": contentIndex}); err != nil { + return err + } + } + return nil + } + for index, call := range calls { + itemID := newWebID("fc") + item := map[string]any{"id": itemID, "type": "function_call", "status": "in_progress", "call_id": call.ID, "name": call.Name, "arguments": ""} + if err := writeSSE(writer, "response.output_item.added", map[string]any{ + "type": "response.output_item.added", "response_id": responseID, "output_index": index, "item": item, + }); err != nil { + return err + } + if err := writeSSE(writer, "response.function_call_arguments.delta", map[string]any{ + "type": "response.function_call_arguments.delta", "response_id": responseID, + "item_id": itemID, "output_index": index, "delta": call.Arguments, + }); err != nil { + return err + } + if err := writeSSE(writer, "response.function_call_arguments.done", map[string]any{ + "type": "response.function_call_arguments.done", "response_id": responseID, + "item_id": itemID, "output_index": index, "arguments": call.Arguments, + }); err != nil { + return err + } + item["status"] = "completed" + item["arguments"] = call.Arguments + if err := writeSSE(writer, "response.output_item.done", map[string]any{ + "type": "response.output_item.done", "response_id": responseID, "output_index": index, "item": item, + }); err != nil { + return err + } + } + return nil +} + +func writeStreamDone(writer io.Writer, operation, responseID, model string, parsed parsedChat, payload map[string]any) { + if operation == "chat" { + finishReason := "stop" + if len(parsed.ToolCalls) > 0 { + finishReason = "tool_calls" + } + chunk := map[string]any{"id": strings.Replace(responseID, "resp_", "chatcmpl_", 1), "object": "chat.completion.chunk", "created": time.Now().Unix(), "model": model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": finishReason}}, "usage": payload["usage"]} + if len(parsed.Annotations) > 0 { + chunk["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any)["annotations"] = chatAnnotations(parsed.Annotations) + } + if sources := payload["search_sources"]; sources != nil { + chunk["search_sources"] = sources + } + writeSSE(writer, "", chunk) + _, _ = io.WriteString(writer, "data: [DONE]\n\n") + return + } + if operation == conversation.OperationMessages { + writeSSE(writer, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) + stopReason := "end_turn" + if len(parsed.ToolCalls) > 0 { + stopReason = "tool_use" + } + usage, _ := payload["usage"].(map[string]any) + writeSSE(writer, "message_delta", map[string]any{ + "type": "message_delta", "delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nil}, + "usage": map[string]any{"output_tokens": usage["output_tokens"]}, + }) + writeSSE(writer, "message_stop", map[string]any{"type": "message_stop"}) + return + } + if parsed.Text.Len() > 0 { + writeSSE(writer, "response.output_text.done", map[string]any{"type": "response.output_text.done", "response_id": responseID, "text": parsed.Text.String()}) + } + writeSSE(writer, "response.completed", map[string]any{"type": "response.completed", "response": payload}) +} + +func writeSSE(writer io.Writer, event string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + if event != "" { + if _, err := fmt.Fprintf(writer, "event: %s\n", event); err != nil { + return err + } + } + _, err = fmt.Fprintf(writer, "data: %s\n\n", data) + return err +} + +func estimateTokens(value string) int64 { + count := utf8.RuneCountInString(value) + if count == 0 { + return 0 + } + return int64((count + 3) / 4) +} + +func newWebID(prefix string) string { + value := make([]byte, 16) + _, _ = rand.Read(value) + return prefix + "_" + hex.EncodeToString(value) +} + +func streamHeaders() http.Header { + value := http.Header{} + value.Set("Content-Type", "text/event-stream; charset=utf-8") + value.Set("Cache-Control", "no-cache") + value.Set("X-Accel-Buffering", "no") + return value +} + +func jsonHeaders() http.Header { + value := http.Header{} + value.Set("Content-Type", "application/json; charset=utf-8") + return value +} + +func jsonProviderResponse(status int, value any) *provider.Response { + data, _ := json.Marshal(value) + return &provider.Response{StatusCode: status, Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), Header: jsonHeaders(), Body: io.NopCloser(bytes.NewReader(data))} +} + +type releaseBody struct { + io.ReadCloser + release func() +} + +func (b *releaseBody) Close() error { + err := b.ReadCloser.Close() + if b.release != nil { + b.release() + b.release = nil + } + return err +} + +type cancelBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +type readerCloser struct { + io.Reader + closer io.Closer +} + +func (r *readerCloser) Close() error { return r.closer.Close() } + +func (b *cancelBody) Close() error { + err := b.ReadCloser.Close() + if b.cancel != nil { + b.cancel() + b.cancel = nil + } + return err +} diff --git a/backend/internal/infra/provider/web/definition.go b/backend/internal/infra/provider/web/definition.go new file mode 100644 index 0000000000000000000000000000000000000000..d1a523cce5b7933d0734b7b1f219fbfe81abfa8f --- /dev/null +++ b/backend/internal/infra/provider/web/definition.go @@ -0,0 +1,33 @@ +package web + +import ( + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +// Definition 集中声明 Grok Web 的稳定能力边界;Web 私有协议由本包内部维护。 +func (a *Adapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderWeb, + ModelNamespace: account.ProviderWeb.ModelNamespace(), + ModelCatalog: provider.ModelCatalogStatic, + ModelCapabilities: []modeldomain.Capability{ + modeldomain.CapabilityChat, + modeldomain.CapabilityImage, + modeldomain.CapabilityImageEdit, + modeldomain.CapabilityVideo, + }, + Quota: provider.QuotaRemoteWindow, + Credential: provider.CredentialSurface{ + AuthType: account.AuthTypeSSO, Import: true, + }, + Conversation: provider.ConversationSurface{ + Responses: true, ChatCompletions: true, Messages: true, StoredResponses: true, + }, + Media: provider.MediaSurface{ + ImageGeneration: true, ImageEdit: true, VideoGeneration: true, + }, + Inference: provider.InferencePolicy{Usage: provider.UsageEstimated, RetryForbiddenAsEgress: true}, + } +} diff --git a/backend/internal/infra/provider/web/headers.go b/backend/internal/infra/provider/web/headers.go new file mode 100644 index 0000000000000000000000000000000000000000..eac7f8834f695d6b6bc9d18b49a0678593c32e10 --- /dev/null +++ b/backend/internal/infra/provider/web/headers.go @@ -0,0 +1,47 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" +) + +func buildHeaders(token string, lease *infraegress.Lease, contentType string) http.Header { + if contentType == "" { + contentType = "application/json" + } + value := http.Header{} + value.Set("Content-Type", contentType) + value.Set("Accept", "*/*") + value.Set("Accept-Encoding", "gzip, deflate, br, zstd") + value.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + value.Set("User-Agent", lease.UserAgent) + value.Set("Cookie", infraegress.BuildSSOCookie(token, lease.CFCookies)) + value.Set("x-xai-request-id", newRequestUUID()) + return value +} + +// applyAppHeaders 补齐真实浏览器同源 fetch 会携带的稳定请求头,不伪造 Sentry 或 Client Hints。 +func applyAppHeaders(value http.Header, origin, referer string) { + value.Set("Origin", origin) + value.Set("Referer", referer) + value.Set("Cache-Control", "no-cache") + value.Set("Pragma", "no-cache") + value.Set("Priority", "u=1, i") + value.Set("Sec-Fetch-Dest", "empty") + value.Set("Sec-Fetch-Mode", "cors") + value.Set("Sec-Fetch-Site", "same-origin") +} + +func newRequestUUID() string { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return newWebID("req") + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value) + return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:] +} diff --git a/backend/internal/infra/provider/web/image.go b/backend/internal/infra/provider/web/image.go new file mode 100644 index 0000000000000000000000000000000000000000..313d9edcab0be66614e7c39534591f6771057524 --- /dev/null +++ b/backend/internal/infra/provider/web/image.go @@ -0,0 +1,1727 @@ +package web + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "sort" + "strings" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + "github.com/bogdanfinn/websocket" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +const ( + maxGeneratedImages = 10 + mediaOutputAttempts = 3 + imageDownloadTimeout = 60 * time.Second + imagineSelfUploadSource = "IMAGINE_SELF_UPLOAD_FILE_SOURCE" + directFileUploadResponseLimit = 2 << 20 +) + +var errLiteImageReady = errors.New("Lite 图片已完成") + +type imagineModelConfig struct { + Pro bool + NativeBatchSize int + MaxReturnCount int +} + +type imagineImageValue struct { + ID string + URL string + Blob string + Position int + Width int + Height int + position bool +} + +type imagineSlot struct { + image imagineImageValue + preview imagineImageValue + final bool + previewReady bool + previewEmitted bool + completed bool + moderated bool + emitted bool +} + +type imagineCollector struct { + slots map[string]*imagineSlot + terminalCount int +} + +func resolveImagineModel(model, resolution string, count int) (imagineModelConfig, bool) { + if model != "imagine" { + return imagineModelConfig{}, false + } + batchSize := 4 + if count > 8 { + batchSize = 12 + } else if count > 4 { + batchSize = 8 + } + return imagineModelConfig{Pro: resolution == "2k", NativeBatchSize: batchSize, MaxReturnCount: 10}, true +} + +func imagineUpstreamGenerationCount(streaming bool, count int, config imagineModelConfig) int { + if streaming { + return count + } + return config.NativeBatchSize +} + +func invalidImageRequest(message string) (*provider.Response, error) { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{ + "message": message, "type": "invalid_request_error", + }}), nil +} + +func imageGenerationUserError(message, param, code string) (*provider.Response, error) { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{ + "message": message, "type": "image_generation_user_error", "param": param, "code": code, + }}), nil +} + +func newImagineCollector() *imagineCollector { + return &imagineCollector{slots: make(map[string]*imagineSlot)} +} + +func (c *imagineCollector) Accept(message map[string]any) { + typeName, _ := message["type"].(string) + if typeName != "image" && typeName != "json" { + return + } + rawURL, _ := message["url"].(string) + imageID := firstString(message, "image_id", "job_id", "id") + if imageID == "" && rawURL != "" { + imageID = imageIDFromURL(rawURL) + } + if imageID == "" { + return + } + slot := c.slots[imageID] + if slot == nil { + slot = &imagineSlot{image: imagineImageValue{ID: imageID}} + c.slots[imageID] = slot + } + if typeName == "image" { + if position, ok := firstInt(message, "side_by_side_index", "order", "grid_index"); ok { + slot.image.Position = position + slot.image.position = true + } + width, _ := numberAsInt(message["width"]) + height, _ := numberAsInt(message["height"]) + progress, hasProgress := numberAsInt(message["percentage_complete"]) + if hasProgress && progress < 100 { + slot.preview = imagineImageValue{ + ID: imageID, URL: absoluteAssetURL(rawURL), Position: slot.image.Position, + Width: width, Height: height, position: slot.image.position, + } + slot.preview.Blob, _ = message["blob"].(string) + slot.previewReady = true + return + } + slot.image.URL = absoluteAssetURL(rawURL) + slot.image.Blob, _ = message["blob"].(string) + slot.image.Width = width + slot.image.Height = height + slot.final = true + return + } + status, _ := message["current_status"].(string) + if position, ok := numberAsInt(message["order"]); ok && !slot.image.position { + slot.image.Position = position + slot.image.position = true + } + if width, ok := numberAsInt(message["width"]); ok && slot.image.Width == 0 { + slot.image.Width = width + } + if height, ok := numberAsInt(message["height"]); ok && slot.image.Height == 0 { + slot.image.Height = height + } + if status != "completed" { + return + } + if rawURL != "" && !slot.final { + slot.image.URL = absoluteAssetURL(rawURL) + slot.image.Blob, _ = message["blob"].(string) + slot.final = true + } + if !slot.completed { + slot.completed = true + c.terminalCount++ + } + slot.moderated, _ = message["moderated"].(bool) +} + +func (c *imagineCollector) Done(expected int) bool { + if expected <= 0 || c.terminalCount < expected { + return false + } + for _, slot := range c.slots { + if slot.completed && !slot.moderated && (!slot.final || (slot.image.URL == "" && slot.image.Blob == "")) { + return false + } + } + return true +} + +func (c *imagineCollector) Images() []imagineImageValue { + values := make([]imagineImageValue, 0, len(c.slots)) + for _, slot := range c.slots { + if slot.completed && !slot.moderated && slot.final && (slot.image.URL != "" || slot.image.Blob != "") { + values = append(values, slot.image) + } + } + sortImagineImages(values) + return values +} + +func (c *imagineCollector) ReadyImages() []imagineImageValue { + values := make([]imagineImageValue, 0, len(c.slots)) + for _, slot := range c.slots { + if slot.completed && !slot.moderated && slot.final && !slot.emitted && (slot.image.URL != "" || slot.image.Blob != "") { + slot.emitted = true + values = append(values, slot.image) + } + } + sortImagineImages(values) + return values +} + +func (c *imagineCollector) ReadyPreviews() []imagineImageValue { + values := make([]imagineImageValue, 0) + for _, slot := range c.slots { + if slot.previewReady && !slot.previewEmitted { + slot.previewEmitted = true + values = append(values, slot.preview) + } + } + sortImagineImages(values) + return values +} + +func sortImagineImages(values []imagineImageValue) { + sort.SliceStable(values, func(i, j int) bool { + if values[i].position != values[j].position { + return values[i].position + } + if values[i].Position != values[j].Position { + return values[i].Position < values[j].Position + } + return values[i].ID < values[j].ID + }) +} + +func (c *imagineCollector) UsableCount() int { + count := 0 + for _, slot := range c.slots { + if slot.completed && !slot.moderated && slot.final && (slot.image.URL != "" || slot.image.Blob != "") { + count++ + } + } + return count +} + +func firstString(value map[string]any, keys ...string) string { + for _, key := range keys { + if result, _ := value[key].(string); result != "" { + return result + } + } + return "" +} + +func firstInt(value map[string]any, keys ...string) (int, bool) { + for _, key := range keys { + if result, ok := numberAsInt(value[key]); ok { + return result, true + } + } + return 0, false +} + +func numberAsInt(value any) (int, bool) { + switch number := value.(type) { + case float64: + return int(number), true + case int: + return number, true + case json.Number: + parsed, err := number.Int64() + return int(parsed), err == nil + default: + return 0, false + } +} + +func (a *Adapter) GenerateImage(ctx context.Context, request provider.ImageGenerationRequest) (*provider.Response, error) { + count := request.Count + if count <= 0 { + count = 1 + } + if request.Streaming && count != 1 { + return imageGenerationUserError("Streaming is only supported with n=1.", "input", "unsupported_parameter") + } + if request.PartialImages < 0 || request.PartialImages > 3 { + return invalidImageRequest("partial_images 必须在 0 到 3 之间") + } + if request.PartialImages > 0 && !request.Streaming { + return invalidImageRequest("partial_images 仅可在 stream=true 时使用") + } + format := strings.ToLower(strings.TrimSpace(request.ResponseFormat)) + if format == "" { + format = "url" + } + if format != "url" && format != "b64_json" { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"message": "response_format 必须是 url 或 b64_json", "type": "invalid_request_error"}}), nil + } + spec, modelKnown := Resolve(request.Model) + if !modelKnown || spec.Capability != "image" { + return invalidImageRequest("模型不支持图片生成") + } + protocolModel := spec.ProtocolModel + if protocolModel == "" { + protocolModel = spec.UpstreamModel + } + if protocolModel == "imagine-lite" { + if request.Streaming { + return invalidImageRequest("grok-imagine-image 不支持 stream") + } + if count > maxGeneratedImages { + return invalidImageRequest("n 不能超过 10") + } + return a.generateLiteImage(ctx, request, count, format) + } + ratio, err := resolveImageAspectRatio(request.AspectRatio, request.Size) + if err != nil { + return invalidImageRequest(err.Error()) + } + resolution := strings.ToLower(strings.TrimSpace(request.Resolution)) + if resolution == "" { + resolution = "1k" + } + if resolution != "1k" && resolution != "2k" { + return invalidImageRequest("resolution 必须是 1k 或 2k") + } + modelConfig, ok := resolveImagineModel(protocolModel, resolution, count) + if !ok { + return invalidImageRequest("模型不支持图片生成") + } + if count > modelConfig.MaxReturnCount { + return invalidImageRequest(fmt.Sprintf("resolution=%s 时 n 不能超过 %d", resolution, modelConfig.MaxReturnCount)) + } + return a.generateWSImage(ctx, request, count, format, ratio, resolution, modelConfig) +} + +func (a *Adapter) generateLiteImage(ctx context.Context, request provider.ImageGenerationRequest, count int, format string) (*provider.Response, error) { + spec, _ := Resolve(request.Model) + urls := make([]string, 0, count) + for len(urls) < count { + value, err := a.generateLiteImageURL(ctx, request.Credential, spec, request.Prompt) + if err != nil { + var upstreamErr *liteUpstreamError + if errors.As(err, &upstreamErr) && len(urls) == 0 { + return upstreamErr.Response(), nil + } + if len(urls) > 0 { + return jsonProviderResponse(http.StatusBadGateway, map[string]any{"error": map[string]any{ + "message": fmt.Sprintf("Lite 图片仅完成 %d/%d 张: %v", len(urls), count, err), + "type": "server_error", "code": "image_generation_incomplete", + }}), nil + } + return nil, err + } + urls = append(urls, value) + } + response, err := a.imageResponse(ctx, request.Credential, urls, nil, count, format) + if response != nil { + response.QuotaUnits = count + } + return response, err +} + +type liteUpstreamError struct { + StatusCode int + Status string + Body []byte +} + +func (e *liteUpstreamError) Error() string { + return fmt.Sprintf("Lite 图片上游返回 %d", e.StatusCode) +} + +func (e *liteUpstreamError) Response() *provider.Response { + return &provider.Response{StatusCode: e.StatusCode, Status: e.Status, Header: jsonHeaders(), Body: io.NopCloser(bytes.NewReader(e.Body))} +} + +func (a *Adapter) generateLiteImageURL(ctx context.Context, credential account.Credential, spec ModelSpec, prompt string) (string, error) { + for attempt := 0; attempt < 2; attempt++ { + upstream, lease, _, statsigTarget, err := a.openChat(ctx, credential, "", spec, normalizedChatInput{Prompt: "Drawing: " + prompt}) + if err != nil { + return "", err + } + if upstream.StatusCode < 200 || upstream.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(upstream.Body, 1<<20)) + _ = upstream.Body.Close() + if upstream.StatusCode == http.StatusForbidden { + if attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, statsigTarget) { + lease.Release() + continue + } + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, upstream.StatusCode, nil) + lease.Release() + return "", &liteUpstreamError{StatusCode: upstream.StatusCode, Status: upstream.Status, Body: body} + } + firstImage := "" + capture := &boundedCapture{limit: 8 << 20} + parsed, consumeErr := consumeUpstream(io.TeeReader(upstream.Body, capture), func(kind, delta string) error { + if kind != "image" || strings.TrimSpace(delta) == "" { + return nil + } + firstImage = delta + return errLiteImageReady + }) + _ = upstream.Body.Close() + if consumeErr != nil && !errors.Is(consumeErr, errLiteImageReady) { + if errors.Is(consumeErr, errWebUsageLimit) { + lease.Release() + response := jsonProviderResponse(http.StatusTooManyRequests, map[string]any{"error": map[string]any{ + "message": "Grok Imagine 速率限制中,请稍后重试", + "type": "rate_limit_error", + "code": "usage_limit_reached", + }}) + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + return "", &liteUpstreamError{StatusCode: http.StatusTooManyRequests, Status: "429 Too Many Requests", Body: body} + } + status := 0 + if errors.Is(consumeErr, errWebAntiBot) { + status = http.StatusForbidden + if attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, statsigTarget) { + lease.Release() + continue + } + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, status, consumeErr) + lease.Release() + if status == http.StatusForbidden { + response := antiBotProviderResponse() + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + return "", &liteUpstreamError{StatusCode: status, Status: "403 Forbidden", Body: body} + } + return "", consumeErr + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + lease.Release() + if firstImage != "" { + return firstImage, nil + } + if len(parsed.Images) == 0 { + parsed.Images = extractMarkdownImages(parsed.Text.String()) + } + if len(parsed.Images) == 0 { + parsed.Images = extractCapturedImageURLs(capture.Bytes()) + } + if len(parsed.Images) == 0 { + diagnostics := inspectLiteCapture(capture.Bytes()) + a.log().Warn("web_lite_image_not_found", + "account_id", credential.ID, + "captured_bytes", len(capture.Bytes()), + "frames", diagnostics.Frames, + "response_fields", diagnostics.ResponseFields, + "message_tags", diagnostics.MessageTags, + "image_chunks", diagnostics.ImageChunks, + "image_urls", diagnostics.ImageURLs, + "image_fields", diagnostics.ImageFields, + "max_progress", diagnostics.MaxProgress, + "soft_stop", diagnostics.SoftStop, + "upstream_error_code", diagnostics.ErrorCode, + "upstream_error", diagnostics.ErrorMessage, + ) + return "", fmt.Errorf("Grok Web Lite 响应结束但未解析到最终图片") + } + // Lite 上游固定生成两张,但每次查询只计一次 Fast 额度;按旧协议取首张并为 n 重复查询。 + return parsed.Images[0], nil + } + return "", fmt.Errorf("Grok Web Lite 图片签名刷新失败") +} + +func (a *Adapter) forwardLiteChatCompletion(ctx context.Context, request provider.ResponseResourceRequest, input openAIRequest, normalized normalizedChatInput, spec ModelSpec) (*provider.Response, error) { + if len(normalized.Attachments) > 0 { + return invalidImageRequest("grok-imagine-image 只支持纯文本生图;附件请使用对应的图片编辑或对话模型") + } + count := 1 + format := "url" + if input.ImageConfig != nil { + if input.ImageConfig.Count != nil { + count = *input.ImageConfig.Count + } + if strings.TrimSpace(input.ImageConfig.ResponseFormat) != "" { + format = strings.ToLower(strings.TrimSpace(input.ImageConfig.ResponseFormat)) + } + } + if count < 1 || count > maxGeneratedImages { + return invalidImageRequest("image_config.n 必须在 1 到 10 之间") + } + if format != "url" && format != "b64_json" { + return invalidImageRequest("image_config.response_format 必须是 url 或 b64_json") + } + responseID := newWebID("resp") + streaming := input.Stream || request.Streaming + if streaming { + reader, writer := io.Pipe() + streamCtx, cancel := context.WithCancel(ctx) + go a.streamLiteChatImages(streamCtx, writer, request.Credential, spec, responseID, input.Model, normalized.Prompt, count, format) + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: streamHeaders(), Body: &cancelBody{ReadCloser: reader, cancel: cancel}, QuotaUnits: count}, nil + } + parsed := parsedChat{ResponseID: responseID, InputTokens: estimateTokens(normalized.Prompt)} + for range count { + rawURL, err := a.generateLiteImageURL(ctx, request.Credential, spec, normalized.Prompt) + if err != nil { + var upstreamErr *liteUpstreamError + if errors.As(err, &upstreamErr) && parsed.Text.Len() == 0 { + return upstreamErr.Response(), nil + } + return nil, err + } + item, err := a.imageDataItem(ctx, request.Credential, imagineImageValue{URL: rawURL}, format) + if err != nil { + return nil, err + } + if parsed.Text.Len() > 0 { + parsed.Text.WriteString("\n\n") + } + parsed.Text.WriteString(liteImageMarkdown(item)) + } + payload := buildOpenAIResult("chat", responseID, input.Model, parsed, false) + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: jsonHeaders(), Body: io.NopCloser(bytes.NewReader(data)), QuotaUnits: count}, nil +} + +func (a *Adapter) streamLiteChatImages(ctx context.Context, writer *io.PipeWriter, credential account.Credential, spec ModelSpec, responseID, model, prompt string, count int, format string) { + parsed := parsedChat{ResponseID: responseID, InputTokens: estimateTokens(prompt)} + writeStreamStart(writer, "chat", responseID, model, parsed.InputTokens) + for range count { + rawURL, err := a.generateLiteImageURL(ctx, credential, spec, prompt) + if err != nil { + _ = writer.CloseWithError(err) + return + } + item, err := a.imageDataItem(ctx, credential, imagineImageValue{URL: rawURL}, format) + if err != nil { + _ = writer.CloseWithError(err) + return + } + delta := liteImageMarkdown(item) + if parsed.Text.Len() > 0 { + delta = "\n\n" + delta + } + parsed.Text.WriteString(delta) + if err := writeStreamDelta(writer, "chat", responseID, model, "text", delta); err != nil { + _ = writer.CloseWithError(err) + return + } + } + payload := buildOpenAIResult("chat", responseID, model, parsed, false) + writeStreamDone(writer, "chat", responseID, model, parsed, payload) + _ = writer.Close() +} + +func liteImageMarkdown(item map[string]any) string { + if value, _ := item["url"].(string); value != "" { + return "![image](" + value + ")" + } + if value, _ := item["b64_json"].(string); value != "" { + mimeType, _ := item["mime_type"].(string) + if mimeType == "" { + mimeType = "image/jpeg" + } + return "![image](data:" + mimeType + ";base64," + value + ")" + } + return "" +} + +func (a *Adapter) generateWSImage(ctx context.Context, request provider.ImageGenerationRequest, count int, format, ratio, resolution string, modelConfig imagineModelConfig) (*provider.Response, error) { + cfg := a.config() + token, err := a.cipher.Decrypt(request.Credential.EncryptedAccessToken) + if err != nil { + return nil, err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, request.Credential) + if err != nil { + return nil, err + } + leaseOwned := true + defer func() { + if leaseOwned { + lease.Release() + } + }() + wsURL, err := imagineURL(cfg.BaseURL) + if err != nil { + return nil, err + } + headers := fhttp.Header{} + headers.Set("Origin", cfg.BaseURL) + headers.Set("User-Agent", lease.UserAgent) + headers.Set("Cookie", egress.BuildSSOCookie(token, lease.CFCookies)) + headers.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + headers.Set("Cache-Control", "no-cache") + headers.Set("Pragma", "no-cache") + connection, response, err := lease.DialWebSocket(ctx, wsURL, headers, 30*time.Second) + if err != nil { + status := 0 + if response != nil { + status = response.StatusCode + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, status, err) + return nil, fmt.Errorf("连接 Imagine WebSocket: %w", err) + } + connectionOwned := true + defer func() { + if connectionOwned { + _ = connection.Close() + } + }() + connection.SetReadLimit(64 << 20) + deadline := time.Now().Add(time.Duration(cfg.ImageTimeoutSeconds) * time.Second) + _ = connection.SetReadDeadline(deadline) + _ = connection.SetWriteDeadline(deadline) + if err := connection.WriteJSON(imagineResetMessage()); err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + return nil, err + } + upstreamCount := imagineUpstreamGenerationCount(request.Streaming, count, modelConfig) + if err := connection.WriteJSON(imagineRequestMessage(newWebID("img"), request.Prompt, ratio, cfg.AllowNSFW, modelConfig.Pro, upstreamCount)); err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + return nil, err + } + if request.Streaming { + reader, writer := io.Pipe() + streamCtx, cancel := context.WithCancel(ctx) + leaseOwned = false + connectionOwned = false + go a.streamImagineImages(streamCtx, writer, connection, lease, request.Credential, count, request.PartialImages, modelConfig) + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: streamHeaders(), Body: &cancelBody{ReadCloser: reader, cancel: cancel}, QuotaUnits: count}, nil + } + + collector := newImagineCollector() + for collector.UsableCount() < count && !collector.Done(modelConfig.NativeBatchSize) { + messageType, data, readErr := connection.ReadMessage() + if readErr != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, readErr) + return nil, fmt.Errorf("读取 Imagine WebSocket: %w", readErr) + } + if messageType != websocket.TextMessage { + continue + } + var message map[string]any + if json.Unmarshal(data, &message) != nil { + continue + } + if message["type"] == "error" { + upstreamErr := fmt.Errorf("Imagine WebSocket 返回错误") + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, upstreamErr) + return nil, upstreamErr + } + collector.Accept(message) + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + images := collector.Images() + if len(images) == 0 { + return nil, fmt.Errorf("Imagine WebSocket 完成但没有可用图片") + } + if len(images) < count { + return jsonProviderResponse(http.StatusBadGateway, map[string]any{"error": map[string]any{ + "message": fmt.Sprintf("上游仅返回 %d/%d 张可用图片", len(images), count), + "type": "server_error", "code": "image_generation_incomplete", + }}), nil + } + urls := make([]string, 0, len(images)) + blobs := make([]string, 0, len(images)) + for _, image := range images { + urls = append(urls, image.URL) + blobs = append(blobs, image.Blob) + } + result, err := a.imageResponse(ctx, request.Credential, urls, blobs, count, format) + if result != nil { + result.QuotaUnits = count + } + return result, err +} + +func (a *Adapter) EditImage(ctx context.Context, request provider.ImageEditRequest) (*provider.Response, error) { + if len(request.ImageURLs) == 0 || len(request.ImageURLs) > 8 { + return jsonProviderResponse(http.StatusBadRequest, map[string]any{"error": map[string]any{"message": "image 数量必须在 1 到 8 之间", "type": "invalid_request_error"}}), nil + } + count := request.Count + if count <= 0 { + count = 1 + } + if count != 1 { + return invalidImageRequest("Grok Web 图片编辑当前仅支持 n=1") + } + if request.PartialImages < 0 || request.PartialImages > 3 { + return invalidImageRequest("partial_images 必须在 0 到 3 之间") + } + if request.PartialImages > 0 && !request.Streaming { + return invalidImageRequest("partial_images 仅可在 stream=true 时使用") + } + resolution := strings.ToLower(strings.TrimSpace(request.Resolution)) + if resolution == "" { + resolution = "1k" + } + if resolution != "1k" { + return invalidImageRequest("Grok Web 图片编辑当前仅支持 resolution=1k") + } + format := strings.ToLower(strings.TrimSpace(request.ResponseFormat)) + if format == "" { + format = "url" + } + if format != "url" && format != "b64_json" { + return invalidImageRequest("response_format 必须是 url 或 b64_json") + } + ratio, err := resolveImageEditAspectRatio(request.AspectRatio, request.Size) + if err != nil { + return invalidImageRequest(err.Error()) + } + cfg := a.config() + token, err := a.cipher.Decrypt(request.Credential.EncryptedAccessToken) + if err != nil { + return nil, err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, request.Credential) + if err != nil { + return nil, err + } + leaseOwned := true + defer func() { + if leaseOwned { + lease.Release() + } + }() + images := make([]provider.ImageInput, 0, len(request.ImageURLs)) + for _, rawURL := range request.ImageURLs { + image, loadErr := a.loadChatImage(ctx, lease, rawURL, cfg.MaxInputImageBytes) + if loadErr != nil { + return invalidImageRequest(loadErr.Error()) + } + images = append(images, image) + } + refs := make([]string, 0, len(images)) + parentID := "" + for _, image := range images { + uploaded, uploadErr := a.uploadFileV2Direct(ctx, cfg, lease, token, image, cfg.BaseURL+"/imagine", imagineSelfUploadSource, "image_edit_upload") + if uploadErr != nil { + return nil, uploadErr + } + if uploaded.URI == "" { + return nil, fmt.Errorf("上传图片成功但上游未返回 fileUri") + } + refs = append(refs, uploaded.URI) + postID, postErr := a.createMediaPost(ctx, cfg, lease, token, "MEDIA_POST_TYPE_IMAGE", uploaded.URI, "", "image_edit_media_post") + if postErr != nil { + return nil, postErr + } + if parentID == "" { + parentID = postID + } + } + payload := buildImageEditPayload(request.Prompt, refs, parentID, ratio) + response, err := a.postJSONWithReferer(ctx, cfg, lease, token, cfg.BaseURL+"/rest/app-chat/conversations/new", payload, time.Duration(cfg.ImageTimeoutSeconds)*time.Second, cfg.BaseURL+"/imagine/post/"+parentID) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + _ = response.Body.Close() + return &provider.Response{StatusCode: response.StatusCode, Status: response.Status, Header: jsonHeaders(), Body: io.NopCloser(bytes.NewReader(body))}, nil + } + if request.Streaming { + reader, writer := io.Pipe() + streamCtx, cancel := context.WithCancel(ctx) + leaseOwned = false + go a.streamImageEdit(streamCtx, writer, response.Body, lease, request.Credential, request.PartialImages, request.Size, ratio) + return &provider.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: streamHeaders(), Body: &cancelBody{ReadCloser: reader, cancel: cancel}, QuotaUnits: 1}, nil + } + defer response.Body.Close() + capture := &boundedCapture{limit: 8 << 20} + parsed, consumeErr := consumeUpstream(io.TeeReader(response.Body, capture), nil) + if consumeErr != nil { + return nil, consumeErr + } + urls := imageEditResultURLs(&parsed, capture.Bytes()) + if len(urls) == 0 { + return jsonProviderResponse(http.StatusBadGateway, map[string]any{"error": map[string]any{ + "message": "上游未返回可用的编辑图片", + "type": "server_error", "code": "image_edit_incomplete", + }}), nil + } + result, err := a.imageResponse(ctx, request.Credential, urls, nil, 1, format) + if result != nil { + result.QuotaUnits = 1 + } + return result, err +} + +func buildImageEditPayload(prompt string, refs []string, parentID, aspectRatio string) map[string]any { + config := map[string]any{"imageReferences": refs, "parentPostId": parentID} + if aspectRatio != "" { + config["aspectRatio"] = aspectRatio + } + return map[string]any{ + "temporary": true, "modelName": "imagine-image-edit", "message": prompt, + "enableImageGeneration": true, "returnImageBytes": false, "returnRawGrokInXaiRequest": false, + "enableImageStreaming": true, "imageGenerationCount": 2, "forceConcise": false, + "enableSideBySide": true, "sendFinalMetadata": true, "isReasoning": false, + "disableTextFollowUps": true, "disableMemory": false, "forceSideBySide": false, + "responseMetadata": map[string]any{"modelConfigOverride": map[string]any{"modelMap": map[string]any{ + "imageEditModel": "imagine", "imageEditModelConfig": config, + }}}, + } +} + +func resolveImageEditAspectRatio(aspectRatio, size string) (string, error) { + if strings.TrimSpace(aspectRatio) == "" && strings.TrimSpace(size) == "" { + return "", nil + } + return resolveImageAspectRatio(aspectRatio, size) +} + +type imageEditStreamFrame struct { + URL string + Progress int + Moderated bool +} + +func parseImageEditStreamFrame(data []byte) (imageEditStreamFrame, bool) { + var root map[string]any + if json.Unmarshal(data, &root) != nil { + return imageEditStreamFrame{}, false + } + result, _ := root["result"].(map[string]any) + response, _ := result["response"].(map[string]any) + imageResponse, _ := response["streamingImageGenerationResponse"].(map[string]any) + if imageResponse == nil { + return imageEditStreamFrame{}, false + } + rawURL := firstString(imageResponse, "imageUrl", "url") + if rawURL == "" { + return imageEditStreamFrame{}, false + } + progress, hasProgress := numberAsInt(imageResponse["progress"]) + if !hasProgress { + if final, _ := imageResponse["isFinal"].(bool); !final { + return imageEditStreamFrame{}, false + } + progress = 100 + } + moderated, _ := imageResponse["moderated"].(bool) + return imageEditStreamFrame{URL: absoluteAssetURL(rawURL), Progress: progress, Moderated: moderated}, true +} + +func (a *Adapter) streamImageEdit( + ctx context.Context, + writer *io.PipeWriter, + source io.ReadCloser, + lease *egress.Lease, + credential account.Credential, + partialImages int, + size string, + aspectRatio string, +) { + defer lease.Release() + defer source.Close() + createdAt := time.Now().Unix() + parsed := parsedChat{} + capture := &boundedCapture{limit: 8 << 20} + seenPartials := make(map[string]struct{}, partialImages) + partialIndex := 0 + consumeErr := consumeJSONObjects(io.TeeReader(source, capture), 8<<20, func(data []byte) error { + if _, _, err := parseUpstreamFrame(data, &parsed); err != nil { + return err + } + frame, ok := parseImageEditStreamFrame(data) + if !ok || frame.Moderated || frame.Progress >= 100 || partialIndex >= partialImages { + return nil + } + if _, exists := seenPartials[frame.URL]; exists { + return nil + } + raw, err := a.imageBytes(ctx, credential, imagineImageValue{URL: frame.URL}) + if err != nil { + // partial_images 是尽力而为;预览下载失败不应阻断最终编辑结果。 + return nil + } + if err := writeSSE(writer, "image_edit.partial_image", openAIImageEditStreamEvent( + "image_edit.partial_image", raw, createdAt, imageEditEventSize(size, aspectRatio), partialIndex, + )); err != nil { + return err + } + seenPartials[frame.URL] = struct{}{} + partialIndex++ + return nil + }) + if consumeErr != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, consumeErr) + _ = writer.CloseWithError(consumeErr) + return + } + urls := imageEditResultURLs(&parsed, capture.Bytes()) + if len(urls) == 0 { + err := fmt.Errorf("上游未返回可用的编辑图片") + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + _ = writer.CloseWithError(err) + return + } + raw, err := a.imageBytes(ctx, credential, imagineImageValue{URL: urls[0]}) + if err != nil { + _ = writer.CloseWithError(provider.NewMediaPostProcessingError(provider.MediaPostProcessingDownload, err)) + return + } + if err := a.saveStreamImage(ctx, raw); err != nil { + _ = writer.CloseWithError(err) + return + } + if err := writeSSE(writer, "image_edit.completed", openAIImageEditStreamEvent( + "image_edit.completed", raw, createdAt, imageEditEventSize(size, aspectRatio), 0, + )); err != nil { + _ = writer.CloseWithError(err) + return + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + _ = writer.Close() +} + +func openAIImageEditStreamEvent(eventType string, raw []byte, createdAt int64, size string, partialIndex int) map[string]any { + value := map[string]any{ + "type": eventType, "b64_json": base64.StdEncoding.EncodeToString(raw), + "created_at": createdAt, "size": size, "quality": "auto", + "background": "auto", "output_format": imageOutputFormat(raw), + } + if eventType == "image_edit.partial_image" { + value["partial_image_index"] = partialIndex + } else { + value["usage"] = map[string]any{ + "total_tokens": 0, "input_tokens": 0, "output_tokens": 0, + "input_tokens_details": map[string]any{"text_tokens": 0, "image_tokens": 0}, + } + } + return value +} + +func imageEditEventSize(size, aspectRatio string) string { + switch value := strings.ToLower(strings.TrimSpace(size)); value { + case "1024x1024", "1024x1536", "1536x1024", "auto": + return value + } + switch strings.ToLower(strings.TrimSpace(aspectRatio)) { + case "1:1": + return "1024x1024" + case "2:3": + return "1024x1536" + case "3:2": + return "1536x1024" + default: + return "auto" + } +} + +func imageEditResultURLs(parsed *parsedChat, captured []byte) []string { + values := append([]string(nil), parsed.Images...) + if len(values) == 0 { + values = extractCapturedImageURLs(captured) + } + if len(values) == 0 { + values = extractMarkdownImages(parsed.Text.String()) + } + result := make([]string, 0, len(values)) + for _, value := range values { + value = absoluteAssetURL(value) + if _, moderated := parsed.moderatedImages[value]; moderated || containsString(result, value) { + continue + } + result = append(result, value) + } + return result +} + +type boundedCapture struct { + data []byte + limit int +} + +func (w *boundedCapture) Write(value []byte) (int, error) { + remaining := w.limit - len(w.data) + if remaining > 0 { + w.data = append(w.data, value[:min(remaining, len(value))]...) + } + return len(value), nil +} + +func (w *boundedCapture) Bytes() []byte { return w.data } + +func extractCapturedImageURLs(data []byte) []string { + results := make([]string, 0, 2) + _ = consumeJSONObjects(bytes.NewReader(data), 8<<20, func(frame []byte) error { + var value any + if json.Unmarshal(frame, &value) == nil { + collectCapturedImageURLs(value, &results) + } + return nil + }) + return results +} + +type liteCaptureDiagnostics struct { + Frames int + ResponseFields []string + MessageTags []string + ImageChunks int + ImageURLs int + ImageFields []string + MaxProgress int + SoftStop bool + ErrorCode string + ErrorMessage string +} + +func inspectLiteCapture(data []byte) liteCaptureDiagnostics { + result := liteCaptureDiagnostics{} + fields := make(map[string]struct{}) + tags := make(map[string]struct{}) + imageFields := make(map[string]struct{}) + _ = consumeJSONObjects(bytes.NewReader(data), 8<<20, func(frame []byte) error { + result.Frames++ + var root map[string]any + if json.Unmarshal(frame, &root) != nil { + return nil + } + value, _ := root["result"].(map[string]any) + response, _ := value["response"].(map[string]any) + for key := range response { + fields[key] = struct{}{} + } + if tag, _ := response["messageTag"].(string); tag != "" { + tags[tag] = struct{}{} + } + if stopped, _ := response["isSoftStop"].(bool); stopped { + result.SoftStop = true + } + if responseError, ok := response["error"].(map[string]any); ok { + result.ErrorCode = fmt.Sprint(responseError["code"]) + result.ErrorMessage = firstString(responseError, "message", "error") + if len(result.ErrorMessage) > 200 { + result.ErrorMessage = result.ErrorMessage[:200] + } + } + inspectLiteCaptureValue(response, &result, imageFields) + return nil + }) + result.ResponseFields = sortedSetValues(fields) + result.MessageTags = sortedSetValues(tags) + result.ImageFields = sortedSetValues(imageFields) + return result +} + +func inspectLiteCaptureValue(value any, result *liteCaptureDiagnostics, imageFields map[string]struct{}) { + switch current := value.(type) { + case map[string]any: + for key, nested := range current { + if key == "jsonData" { + if encoded, _ := nested.(string); encoded != "" { + var decoded any + if json.Unmarshal([]byte(encoded), &decoded) == nil { + inspectLiteCaptureValue(decoded, result, imageFields) + } + } + } + if key == "image_chunk" || key == "imageChunk" { + if chunk, ok := nested.(map[string]any); ok { + result.ImageChunks++ + for field := range chunk { + imageFields[field] = struct{}{} + } + if firstString(chunk, "imageUrl", "image_url", "url") != "" { + result.ImageURLs++ + } + if progress, ok := numberAsInt(chunk["progress"]); ok && progress > result.MaxProgress { + result.MaxProgress = progress + } + } + } + inspectLiteCaptureValue(nested, result, imageFields) + } + case []any: + for _, nested := range current { + inspectLiteCaptureValue(nested, result, imageFields) + } + } +} + +func sortedSetValues(values map[string]struct{}) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func collectCapturedImageURLs(value any, results *[]string) { + switch current := value.(type) { + case map[string]any: + if rawURL := imageURLFromCardData(current); rawURL != "" { + appendCapturedImageURL(results, rawURL) + } + moderated, _ := current["moderated"].(bool) + progress, hasProgress := numberAsInt(current["progress"]) + if !moderated && hasProgress && progress >= 100 { + appendCapturedImageURL(results, firstString(current, "imageUrl", "image_url", "url")) + } + for _, nested := range current { + collectCapturedImageURLs(nested, results) + } + case []any: + for _, nested := range current { + collectCapturedImageURLs(nested, results) + } + case string: + trimmed := strings.TrimSpace(current) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + var nested any + if json.Unmarshal([]byte(trimmed), &nested) == nil { + collectCapturedImageURLs(nested, results) + return + } + } + appendCapturedImageURL(results, trimmed) + } +} + +func appendCapturedImageURL(results *[]string, value string) { + value = strings.TrimSpace(value) + if !strings.Contains(value, "/generated/") || strings.Contains(value, "-part-") || strings.ContainsAny(value, "{}[]\"") { + return + } + if !strings.HasPrefix(value, "https://") && !strings.HasPrefix(value, "users/") && !strings.HasPrefix(value, "/users/") { + return + } + value = absoluteAssetURL(value) + if !containsString(*results, value) { + *results = append(*results, value) + } +} + +func (a *Adapter) uploadFileV2Direct(ctx context.Context, cfg Config, lease *egress.Lease, token string, file provider.ImageInput, referer, fileSource, stage string) (uploadedFile, error) { + body, contentType, err := buildDirectFileUploadBody(file, fileSource) + if err != nil { + return uploadedFile{}, err + } + requestCtx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.BaseURL+"/http/upload-file-v2/direct", bytes.NewReader(body)) + if err != nil { + return uploadedFile{}, err + } + request.Header = buildHeaders(token, lease, contentType) + request.Header.Del("x-xai-request-id") + applyAppHeaders(request.Header, cfg.BaseURL, referer) + response, err := lease.Do(request) + if err != nil { + return uploadedFile{}, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, webMediaDiagnosticBodyLimit+1)) + if readErr != nil { + return uploadedFile{}, fmt.Errorf("读取 V2 上传文件错误响应: %w", readErr) + } + truncated := len(responseBody) > webMediaDiagnosticBodyLimit + if truncated { + responseBody = responseBody[:webMediaDiagnosticBodyLimit] + } + if response.StatusCode == http.StatusForbidden { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + } + upstreamErr := newWebMediaUpstreamError(response.StatusCode, responseBody, truncated) + a.logWebMediaUpstreamRejection(stage, response, upstreamErr) + return uploadedFile{}, upstreamErr + } + uploaded, err := decodeDirectFileUploadResponse(io.LimitReader(response.Body, directFileUploadResponseLimit)) + if err != nil { + return uploadedFile{}, err + } + return uploaded, nil +} + +func buildDirectFileUploadBody(file provider.ImageInput, fileSource string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, browserMultipartFilename(file.Filename))) + header.Set("Content-Type", file.MIMEType) + part, err := writer.CreatePart(header) + if err != nil { + return nil, "", err + } + if _, err := part.Write(file.Data); err != nil { + return nil, "", err + } + if fileSource != "" { + if err := writer.WriteField("file_source", fileSource); err != nil { + return nil, "", err + } + } + if err := writer.Close(); err != nil { + return nil, "", err + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func browserMultipartFilename(value string) string { + value = strings.Map(func(character rune) rune { + switch { + case character == '\r' || character == '\n': + return -1 + case character < 0x20 || character == 0x7f: + return '_' + default: + return character + } + }, value) + if strings.TrimSpace(value) == "" { + value = "upload.bin" + } + return strings.NewReplacer("\\", "\\\\", `"`, `\"`).Replace(value) +} + +func decodeDirectFileUploadResponse(source io.Reader) (uploadedFile, error) { + var value struct { + FileMetadata struct { + ID string `json:"fileMetadataId"` + FileID string `json:"fileId"` + FileURI string `json:"fileUri"` + } `json:"fileMetadata"` + } + if err := json.NewDecoder(source).Decode(&value); err != nil { + return uploadedFile{}, fmt.Errorf("V2 上传文件响应无效: %w", err) + } + if value.FileMetadata.ID == "" { + value.FileMetadata.ID = value.FileMetadata.FileID + } + fileURI := "" + if value.FileMetadata.FileURI != "" { + fileURI = absoluteAssetURL(value.FileMetadata.FileURI) + } + if value.FileMetadata.ID == "" && fileURI == "" { + return uploadedFile{}, fmt.Errorf("V2 上传文件成功但上游未返回完整文件标识") + } + return uploadedFile{ID: value.FileMetadata.ID, URI: fileURI}, nil +} + +func (a *Adapter) createMediaPost(ctx context.Context, cfg Config, lease *egress.Lease, token, mediaType, mediaURL, prompt, stage string) (string, error) { + payload := map[string]any{"mediaType": mediaType} + if mediaURL != "" { + payload["mediaUrl"] = mediaURL + } + if prompt != "" { + payload["prompt"] = prompt + } + response, err := a.postJSON(ctx, cfg, lease, token, cfg.BaseURL+"/rest/media/post/create", payload, time.Minute) + if err != nil { + return "", err + } + defer response.Body.Close() + return parseMediaPostResponseWithDiagnostics(response, func(upstreamErr *webMediaUpstreamError) { + a.logWebMediaUpstreamRejection(stage, response, upstreamErr) + }) +} + +func parseMediaPostResponse(response *http.Response) (string, error) { + return parseMediaPostResponseWithDiagnostics(response, nil) +} + +func parseMediaPostResponseWithDiagnostics(response *http.Response, onUpstreamError func(*webMediaUpstreamError)) (string, error) { + const responseLimit = 2 << 20 + readLimit := responseLimit + if response.StatusCode < 200 || response.StatusCode >= 300 { + readLimit = webMediaDiagnosticBodyLimit + } + body, err := io.ReadAll(io.LimitReader(response.Body, int64(readLimit)+1)) + if err != nil { + return "", fmt.Errorf("读取媒体 Post 响应: %w", err) + } + truncated := len(body) > readLimit + if truncated { + body = body[:readLimit] + } + if truncated && response.StatusCode >= 200 && response.StatusCode < 300 { + return "", fmt.Errorf("创建媒体 Post 响应超过安全上限") + } + if response.StatusCode == http.StatusUnauthorized { + return "", provider.ErrUnauthorized + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + upstreamErr := newWebMediaUpstreamError(response.StatusCode, body, truncated) + if onUpstreamError != nil { + onUpstreamError(upstreamErr) + } + return "", upstreamErr + } + var value struct { + Post struct { + ID string `json:"id"` + } `json:"post"` + } + if json.Unmarshal(body, &value) != nil || strings.TrimSpace(value.Post.ID) == "" { + return "", fmt.Errorf("创建媒体 Post 响应无效") + } + return strings.TrimSpace(value.Post.ID), nil +} + +func (a *Adapter) postJSON(ctx context.Context, cfg Config, lease *egress.Lease, token, endpoint string, payload any, timeout time.Duration) (*http.Response, error) { + return a.postJSONWithReferer(ctx, cfg, lease, token, endpoint, payload, timeout, cfg.BaseURL+"/imagine") +} + +func (a *Adapter) postJSONWithReferer(ctx context.Context, cfg Config, lease *egress.Lease, token, endpoint string, payload any, timeout time.Duration, referer string) (*http.Response, error) { + data, _ := json.Marshal(payload) + for attempt := 0; attempt < 2; attempt++ { + requestCtx, cancel := context.WithTimeout(ctx, timeout) + request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + cancel() + return nil, err + } + request.Header = buildHeaders(token, lease, "application/json") + applyAppHeaders(request.Header, cfg.BaseURL, referer) + a.applySignedStatsig(requestCtx, request, token, lease) + response, err := lease.Do(request) + if err != nil { + cancel() + return nil, err + } + if response.StatusCode == http.StatusForbidden { + if attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, endpoint) { + _ = response.Body.Close() + cancel() + continue + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusForbidden, nil) + } + response.Body = &cancelBody{ReadCloser: response.Body, cancel: cancel} + return response, nil + } + return nil, fmt.Errorf("Grok Web Statsig 刷新失败") +} + +func (a *Adapter) imageResponse(ctx context.Context, credential account.Credential, urls, blobs []string, count int, format string) (*provider.Response, error) { + data := make([]any, 0, min(count, len(urls))) + for index := 0; index < count && index < len(urls); index++ { + blob := "" + if index < len(blobs) { + blob = blobs[index] + } + item, err := a.imageDataItem(ctx, credential, imagineImageValue{URL: urls[index], Blob: blob}, format) + if err != nil { + return nil, err + } + data = append(data, item) + } + return jsonProviderResponse(http.StatusOK, map[string]any{"created": time.Now().Unix(), "data": data}), nil +} + +func (a *Adapter) imageDataItem(ctx context.Context, credential account.Credential, image imagineImageValue, format string) (map[string]any, error) { + if a.assets == nil { + return nil, provider.NewMediaPostProcessingError(provider.MediaPostProcessingStorage, fmt.Errorf("图片媒体存储未配置")) + } + raw, err := a.imageBytes(ctx, credential, image) + if err != nil { + return nil, provider.NewMediaPostProcessingError(provider.MediaPostProcessingDownload, err) + } + asset, err := a.saveImageWithRetry(ctx, raw) + if err != nil { + return nil, provider.NewMediaPostProcessingError(provider.MediaPostProcessingStorage, err) + } + if format != "b64_json" { + return map[string]any{"url": a.assets.PublicImageURL(asset.ID), "mime_type": asset.MIMEType, "revised_prompt": ""}, nil + } + return map[string]any{"b64_json": base64.StdEncoding.EncodeToString(raw), "mime_type": asset.MIMEType, "revised_prompt": ""}, nil +} + +// saveImageWithRetry 只重试当前生成结果的本地持久化,不重新请求上游生成。 +func (a *Adapter) saveImageWithRetry(ctx context.Context, raw []byte) (mediadomain.Asset, error) { + var lastErr error + for attempt := 0; attempt < mediaOutputAttempts; attempt++ { + asset, err := a.assets.SaveImage(ctx, raw) + if err == nil { + return asset, nil + } + lastErr = err + if ctx.Err() != nil || attempt+1 >= mediaOutputAttempts { + break + } + if err := waitMediaOutputRetry(ctx, attempt); err != nil { + return mediadomain.Asset{}, err + } + } + return mediadomain.Asset{}, lastErr +} + +func (a *Adapter) imageBytes(ctx context.Context, credential account.Credential, image imagineImageValue) ([]byte, error) { + if strings.TrimSpace(image.Blob) != "" { + raw, err := decodeImageBlob(image.Blob) + if err == nil { + return raw, nil + } + if strings.TrimSpace(image.URL) == "" { + return nil, err + } + } + return a.downloadImage(ctx, credential, image.URL) +} + +func (a *Adapter) streamImagineImages(ctx context.Context, writer *io.PipeWriter, connection *websocket.Conn, lease *egress.Lease, credential account.Credential, count, partialImages int, modelConfig imagineModelConfig) { + defer lease.Release() + defer connection.Close() + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = connection.Close() + case <-done: + } + }() + collector := newImagineCollector() + emitted := 0 + partialIndex := 0 + for emitted < count { + messageType, data, readErr := connection.ReadMessage() + if readErr != nil { + if ctx.Err() != nil { + _ = writer.CloseWithError(ctx.Err()) + return + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, readErr) + _ = writer.CloseWithError(readErr) + return + } + if messageType != websocket.TextMessage { + continue + } + var message map[string]any + if json.Unmarshal(data, &message) != nil { + continue + } + if message["type"] == "error" { + upstreamErr := fmt.Errorf("Imagine WebSocket 返回错误") + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, upstreamErr) + _ = writer.CloseWithError(upstreamErr) + return + } + collector.Accept(message) + if partialImages > 0 { + for _, image := range collector.ReadyPreviews() { + if partialIndex >= partialImages { + continue + } + raw, err := a.imageBytes(ctx, credential, image) + if err != nil { + if ctx.Err() != nil { + _ = writer.CloseWithError(ctx.Err()) + return + } + continue + } + if err := writeSSE(writer, "image_generation.partial_image", openAIImageStreamEvent("image_generation.partial_image", image, raw, partialIndex)); err != nil { + _ = writer.CloseWithError(err) + return + } + partialIndex++ + } + } + for _, image := range collector.ReadyImages() { + if emitted >= count { + break + } + raw, err := a.imageBytes(ctx, credential, image) + if err != nil { + _ = writer.CloseWithError(err) + return + } + if err := a.saveStreamImage(ctx, raw); err != nil { + _ = writer.CloseWithError(err) + return + } + if err := writeSSE(writer, "image_generation.completed", openAIImageStreamEvent("image_generation.completed", image, raw, 0)); err != nil { + _ = writer.CloseWithError(err) + return + } + emitted++ + } + if collector.Done(modelConfig.NativeBatchSize) && emitted < count { + incompleteErr := fmt.Errorf("上游仅返回 %d/%d 张可用图片", emitted, count) + _ = writer.CloseWithError(incompleteErr) + return + } + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + _ = writer.Close() +} + +func openAIImageStreamEvent(eventType string, image imagineImageValue, raw []byte, partialIndex int) map[string]any { + width, height := image.Width, image.Height + size := "auto" + if width > 0 && height > 0 { + size = fmt.Sprintf("%dx%d", width, height) + } + value := map[string]any{ + "type": eventType, "b64_json": base64.StdEncoding.EncodeToString(raw), + "created_at": time.Now().Unix(), "size": size, "quality": "auto", + "background": "auto", "output_format": imageOutputFormat(raw), + } + if eventType == "image_generation.partial_image" { + value["partial_image_index"] = partialIndex + } + return value +} + +func imageOutputFormat(raw []byte) string { + mimeType := http.DetectContentType(raw) + switch mimeType { + case "image/png": + return "png" + case "image/webp": + return "webp" + default: + return "jpeg" + } +} + +func (a *Adapter) saveStreamImage(ctx context.Context, raw []byte) error { + if a.assets == nil { + return provider.NewMediaPostProcessingError(provider.MediaPostProcessingStorage, fmt.Errorf("图片媒体存储未配置")) + } + if _, err := a.saveImageWithRetry(ctx, raw); err != nil { + return provider.NewMediaPostProcessingError(provider.MediaPostProcessingStorage, err) + } + return nil +} + +func (a *Adapter) downloadImage(ctx context.Context, credential account.Credential, rawURL string) ([]byte, error) { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "https" || !trustedImageAssetHost(parsed.Hostname()) || parsed.User != nil { + return nil, fmt.Errorf("图片内容 URL 不受信任") + } + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return nil, err + } + downloadCtx, cancel := context.WithTimeout(ctx, imageDownloadTimeout) + defer cancel() + var lastErr error + for attempt := 0; attempt < mediaOutputAttempts; attempt++ { + raw, retryable, attemptErr := a.downloadImageAttempt(downloadCtx, credential, token, parsed.String()) + if attemptErr == nil { + return raw, nil + } + lastErr = attemptErr + if !retryable || downloadCtx.Err() != nil || attempt+1 >= mediaOutputAttempts { + break + } + if err := waitMediaOutputRetry(downloadCtx, attempt); err != nil { + return nil, err + } + } + return nil, lastErr +} + +// downloadImageAttempt 每次沿用同一账号,只允许出口管理器重新选择资源节点。 +func (a *Adapter) downloadImageAttempt(ctx context.Context, credential account.Credential, token, rawURL string) ([]byte, bool, error) { + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWebAsset, credential) + if err != nil { + return nil, true, err + } + defer lease.Release() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, false, err + } + request.Header = buildHeaders(token, lease, "") + request.Header.Del("Content-Type") + response, err := lease.Do(request) + if err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + return nil, ctx.Err() == nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + retryable := response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusRequestTimeout || response.StatusCode == http.StatusTooEarly || response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 + return nil, retryable, fmt.Errorf("下载图片返回 %d", response.StatusCode) + } + contentType := strings.ToLower(strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])) + if contentType != "" && !strings.HasPrefix(contentType, "image/") { + return nil, false, fmt.Errorf("上游图片 Content-Type 无效") + } + raw, err := io.ReadAll(io.LimitReader(response.Body, (32<<20)+1)) + if err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + return nil, ctx.Err() == nil, fmt.Errorf("读取图片内容: %w", err) + } + if len(raw) > 32<<20 { + return nil, false, fmt.Errorf("图片下载超过 32 MiB") + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + return raw, false, nil +} + +func waitMediaOutputRetry(ctx context.Context, attempt int) error { + delays := [...]time.Duration{200 * time.Millisecond, 750 * time.Millisecond} + delay := delays[min(attempt, len(delays)-1)] + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func decodeImageBlob(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "data:") { + comma := strings.IndexByte(value, ',') + if comma < 0 || !strings.Contains(strings.ToLower(value[:comma]), ";base64") { + return nil, fmt.Errorf("图片 blob data URI 无效") + } + value = value[comma+1:] + } + if value == "" || base64.StdEncoding.DecodedLen(len(value)) > 32<<20 { + return nil, fmt.Errorf("图片 blob 为空或超过 32 MiB") + } + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + raw, err = base64.RawStdEncoding.DecodeString(value) + } + if err != nil || len(raw) == 0 || len(raw) > 32<<20 { + return nil, fmt.Errorf("图片 blob Base64 无效") + } + return raw, nil +} + +func trustedImageAssetHost(host string) bool { + return strings.EqualFold(host, "assets.grok.com") || strings.EqualFold(host, "imagine-public.x.ai") || strings.EqualFold(host, "imgen.x.ai") +} + +func imagineURL(baseURL string) (string, error) { + value, err := url.Parse(baseURL) + if err != nil { + return "", err + } + value.Scheme = "wss" + value.Path = "/ws/imagine/listen" + value.RawQuery = "" + return value.String(), nil +} + +func imagineResetMessage() map[string]any { + return map[string]any{"type": "conversation.item.create", "timestamp": time.Now().UnixMilli(), "item": map[string]any{"type": "message", "content": []any{map[string]any{"type": "reset"}}}} +} + +func imagineRequestMessage(id, prompt, ratio string, nsfw, pro bool, generations int) map[string]any { + return map[string]any{"type": "conversation.item.create", "timestamp": time.Now().UnixMilli(), "item": map[string]any{"type": "message", "content": []any{map[string]any{"requestId": id, "text": prompt, "type": "input_text", "properties": map[string]any{"section_count": 0, "is_kids_mode": false, "enable_nsfw": nsfw, "skip_upsampler": false, "enable_side_by_side": true, "is_initial": false, "aspect_ratio": ratio, "enable_pro": pro, "num_generations": generations}}}}} +} + +func resolveImageAspectRatio(aspectRatio, size string) (string, error) { + values := map[string]string{ + "auto": "auto", "1:1": "1:1", "16:9": "16:9", "9:16": "9:16", "4:3": "4:3", "3:4": "3:4", + "3:2": "3:2", "2:3": "2:3", "2:1": "2:1", "1:2": "1:2", "19.5:9": "19.5:9", "9:19.5": "9:19.5", "20:9": "20:9", "9:20": "9:20", + "1280x720": "16:9", "720x1280": "9:16", "1792x1024": "3:2", "1536x1024": "3:2", "1024x1792": "2:3", "1024x1536": "2:3", "1024x1024": "1:1", + } + value := strings.ToLower(strings.TrimSpace(aspectRatio)) + if value == "" { + value = strings.ToLower(strings.TrimSpace(size)) + } + if value == "" { + return "auto", nil + } + if resolved := values[value]; resolved != "" { + return resolved, nil + } + return "", fmt.Errorf("aspect_ratio 不受支持") +} + +func resolveAspectRatio(size string) string { + if strings.TrimSpace(size) == "" { + return "1:1" + } + value, err := resolveImageAspectRatio("", size) + if err != nil { + return "1:1" + } + return value +} + +func imageIDFromURL(value string) string { + parts := strings.Split(strings.Trim(value, "/"), "/") + if len(parts) == 0 { + return value + } + name := parts[len(parts)-1] + if index := strings.IndexByte(name, '.'); index > 0 { + return name[:index] + } + return name +} + +func absoluteAssetURL(value string) string { + if strings.HasPrefix(value, "https://") { + return value + } + return "https://assets.grok.com/" + strings.TrimPrefix(value, "/") +} + +func extractMarkdownImages(value string) []string { + results := make([]string, 0, 2) + for { + start := strings.Index(value, "![image](") + if start < 0 { + break + } + value = value[start+len("![image]("):] + end := strings.IndexByte(value, ')') + if end < 0 { + break + } + results = append(results, value[:end]) + value = value[end+1:] + } + return results +} diff --git a/backend/internal/infra/provider/web/import.go b/backend/internal/infra/provider/web/import.go new file mode 100644 index 0000000000000000000000000000000000000000..081134d91a3768598c8ede1fc69d464dd659a60d --- /dev/null +++ b/backend/internal/infra/provider/web/import.go @@ -0,0 +1,156 @@ +package web + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const ( + maxImportAccounts = 10000 + maxSSOTokenBytes = 16 << 10 +) + +type importDocument struct { + Provider string `json:"provider"` + Accounts []importEntry `json:"accounts"` +} + +type importEntry struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserID string `json:"user_id,omitempty"` + SSOToken string `json:"sso_token"` + Token string `json:"token"` + Tier string `json:"tier"` + CloudflareCookies string `json:"cloudflare_cookies"` + NSFWEnabledAt *time.Time `json:"nsfw_enabled_at,omitempty"` + TOSAcceptedAt *time.Time `json:"tos_accepted_at,omitempty"` + TOSVersion int `json:"tos_version,omitempty"` + BirthDateSetAt *time.Time `json:"birth_date_set_at,omitempty"` +} + +func (a *Adapter) ParseImportedCredentials(data []byte) ([]provider.CredentialSeed, error) { + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return nil, fmt.Errorf("账号文件中没有 Grok Web 账号") + } + if !strings.HasPrefix(trimmed, "{") { + return parsePlainTextCredentials(trimmed) + } + entries, err := provider.DecodeCredentialJSONEntries[importEntry](data, string(account.ProviderWeb), maxImportAccounts) + if err != nil { + return nil, fmt.Errorf("解析 Grok Web 账号 JSON: %w", err) + } + if len(entries) == 0 { + return nil, fmt.Errorf("账号文件中没有 Grok Web 账号") + } + seen := make(map[string]struct{}, len(entries)) + result := make([]provider.CredentialSeed, 0, len(entries)) + for index, entry := range entries { + token := sanitizeSSOToken(firstNonEmpty(entry.SSOToken, entry.Token)) + if token == "" { + return nil, fmt.Errorf("第 %d 个账号缺少 sso_token", index+1) + } + if len(token) > maxSSOTokenBytes { + return nil, fmt.Errorf("第 %d 个账号的 sso_token 超过 16 KiB", index+1) + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + tier := account.WebTier(strings.ToLower(strings.TrimSpace(entry.Tier))) + if tier == "" { + tier = account.WebTierAuto + } + if tier != account.WebTierAuto && tier != account.WebTierBasic && tier != account.WebTierSuper && tier != account.WebTierHeavy { + return nil, fmt.Errorf("第 %d 个账号 tier 无效", index+1) + } + name := strings.TrimSpace(entry.Name) + if name == "" { + name = fmt.Sprintf("Grok Web %s", security.HashToken(token)[:8]) + } + result = append(result, provider.CredentialSeed{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: tier, + Name: name, Email: strings.TrimSpace(entry.Email), UserID: strings.TrimSpace(entry.UserID), + SourceKey: "sso:" + security.HashToken(token), AccessToken: token, CloudflareCookies: entry.CloudflareCookies, + WebNSFWEnabledAt: entry.NSFWEnabledAt, WebTermsAcceptedAt: entry.TOSAcceptedAt, + WebTermsAcceptedVersion: entry.TOSVersion, WebBirthDateSetAt: entry.BirthDateSetAt, + }) + } + return result, nil +} + +func parsePlainTextCredentials(value string) ([]provider.CredentialSeed, error) { + lines := strings.Split(value, "\n") + seen := make(map[string]struct{}, len(lines)) + result := make([]provider.CredentialSeed, 0, len(lines)) + for index, line := range lines { + token := sanitizeSSOToken(line) + if token == "" { + continue + } + if len(token) > maxSSOTokenBytes { + return nil, fmt.Errorf("第 %d 行的 sso token 超过 16 KiB", index+1) + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + result = append(result, provider.CredentialSeed{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, WebTier: account.WebTierAuto, + Name: "Grok Web " + security.HashToken(token)[:8], SourceKey: "sso:" + security.HashToken(token), AccessToken: token, + }) + if len(result) > maxImportAccounts { + return nil, provider.ErrCredentialLimit + } + } + if len(result) == 0 { + return nil, fmt.Errorf("文本中没有有效的 sso token") + } + return result, nil +} + +func (a *Adapter) MarshalCredentials(values []provider.CredentialSeed) ([]byte, error) { + document := importDocument{Provider: string(account.ProviderWeb), Accounts: make([]importEntry, 0, len(values))} + for _, value := range values { + document.Accounts = append(document.Accounts, importEntry{ + Name: value.Name, Email: value.Email, UserID: value.UserID, SSOToken: value.AccessToken, + Tier: string(value.WebTier), CloudflareCookies: value.CloudflareCookies, + NSFWEnabledAt: value.WebNSFWEnabledAt, TOSAcceptedAt: value.WebTermsAcceptedAt, + TOSVersion: value.WebTermsAcceptedVersion, BirthDateSetAt: value.WebBirthDateSetAt, + }) + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func sanitizeSSOToken(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "sso=") { + value = strings.TrimSpace(value[len("sso="):]) + } + if token, _, found := strings.Cut(value, ";"); found { + value = token + } + return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(value)) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/infra/provider/web/import_test.go b/backend/internal/infra/provider/web/import_test.go new file mode 100644 index 0000000000000000000000000000000000000000..13c5bdcb23d3096ab45e9b12df134c7a5606448f --- /dev/null +++ b/backend/internal/infra/provider/web/import_test.go @@ -0,0 +1,77 @@ +package web + +import ( + "strings" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestParseImportedCredentialsAcceptsOneSSOTokenPerLine(t *testing.T) { + adapter := &Adapter{} + values, err := adapter.ParseImportedCredentials([]byte("token-one\nsso=token-two; other=drop\n\ntoken-one\n")) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 { + t.Fatalf("credentials = %#v", values) + } + if values[0].AccessToken != "token-one" || values[1].AccessToken != "token-two" { + t.Fatalf("tokens = %q, %q", values[0].AccessToken, values[1].AccessToken) + } + for _, value := range values { + if value.Provider != account.ProviderWeb || value.AuthType != account.AuthTypeSSO || value.WebTier != account.WebTierAuto { + t.Fatalf("credential = %#v", value) + } + } +} + +func TestParseImportedCredentialsRejectsOversizedPlainToken(t *testing.T) { + adapter := &Adapter{} + _, err := adapter.ParseImportedCredentials([]byte(strings.Repeat("x", maxSSOTokenBytes+1))) + if err == nil { + t.Fatal("expected oversized token error") + } +} + +func TestWebCredentialJSONUsesCurrentDocumentShape(t *testing.T) { + adapter := &Adapter{} + values, err := adapter.ParseImportedCredentials([]byte(`{"provider":"grok_web","accounts":[{"name":"primary","sso_token":"token-one","tier":"super","cloudflare_cookies":"cf_clearance=abc; sso=drop"}]}`)) + if err != nil || len(values) != 1 || values[0].WebTier != account.WebTierSuper { + t.Fatalf("credentials = %#v, err = %v", values, err) + } + if values[0].CloudflareCookies != "cf_clearance=abc; sso=drop" { + t.Fatalf("cloudflare cookies = %q", values[0].CloudflareCookies) + } + data, err := adapter.MarshalCredentials(values) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"version"`) { + t.Fatalf("export contains version metadata: %s", data) + } + if _, err := adapter.ParseImportedCredentials([]byte(`{"basic":["token-one"]}`)); err == nil { + t.Fatal("legacy tier pools were accepted") + } +} + +func TestWebCredentialJSONLinesPreserveMetadata(t *testing.T) { + adapter := &Adapter{} + data := []byte("\xef\xbb\xbf{\"name\":\"first\",\"sso_token\":\"token-one\",\"tier\":\"super\",\"email\":\"one@example.com\"}\r\n\r\n" + + "{\"name\":\"second\",\"token\":\"token-two\",\"user_id\":\"user-two\"}\r\n") + values, err := adapter.ParseImportedCredentials(data) + if err != nil { + t.Fatal(err) + } + if len(values) != 2 || values[0].AccessToken != "token-one" || values[0].WebTier != account.WebTierSuper || values[0].Email != "one@example.com" || values[1].AccessToken != "token-two" || values[1].UserID != "user-two" { + t.Fatalf("credentials = %#v", values) + } +} + +func TestWebCredentialJSONLinesRejectMalformedLine(t *testing.T) { + adapter := &Adapter{} + _, err := adapter.ParseImportedCredentials([]byte("{\"sso_token\":\"token-one\"}\ninvalid-secret\n")) + if err == nil || !strings.Contains(err.Error(), "第 2 行") || strings.Contains(err.Error(), "invalid-secret") { + t.Fatalf("error = %v", err) + } +} diff --git a/backend/internal/infra/provider/web/protocol_test.go b/backend/internal/infra/provider/web/protocol_test.go new file mode 100644 index 0000000000000000000000000000000000000000..24917f00fad02a9eddf01e36739bb26ec8bb7a7a --- /dev/null +++ b/backend/internal/infra/provider/web/protocol_test.go @@ -0,0 +1,1304 @@ +package web + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +func TestCatalogMatchesSupportedSurface(t *testing.T) { + values := Catalog() + if len(values) != 8 { + t.Fatalf("catalog size = %d", len(values)) + } + publicIDs := make(map[string]struct{}, len(values)) + upstreamIDs := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, exists := publicIDs[value.PublicID]; exists { + t.Fatalf("duplicate public model: %s", value.PublicID) + } + if _, exists := upstreamIDs[value.UpstreamModel]; exists { + t.Fatalf("duplicate route upstream model: %s", value.UpstreamModel) + } + publicIDs[value.PublicID] = struct{}{} + upstreamIDs[value.UpstreamModel] = struct{}{} + } + for _, required := range []string{"grok-chat-fast", "grok-chat-auto", "grok-chat-expert", "grok-chat-heavy", "grok-imagine-image", "grok-imagine-image-quality", "grok-imagine-image-edit", "grok-imagine-video"} { + if _, exists := publicIDs[required]; !exists { + t.Fatalf("missing supported model: %s", required) + } + } + for _, removed := range []string{"grok-imagine-image-lite", "grok-imagine-image-speed", "grok-imagine-image-pro"} { + if _, exists := publicIDs[removed]; exists { + t.Fatalf("obsolete image model remains: %s", removed) + } + } +} + +func TestParseMediaPostResponsePreservesStatusAndPostID(t *testing.T) { + postID, err := parseMediaPostResponse(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"post":{"id":"post_1","videos":[{"id":"post_1"}]}}`)), + }) + if err != nil || postID != "post_1" { + t.Fatalf("postID=%q err=%v", postID, err) + } + _, err = parseMediaPostResponse(&http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"error":"challenge"}`)), + }) + var upstreamErr *webMediaUpstreamError + if !errors.As(err, &upstreamErr) || upstreamErr.status != http.StatusForbidden || !strings.Contains(err.Error(), "challenge") { + t.Fatalf("error = %#v", err) + } +} + +func TestWebChatPricingUsesGrok45(t *testing.T) { + registry := provider.NewRegistry(&Adapter{}) + for _, upstreamModel := range []string{"grok-chat-fast", "grok-chat-auto", "grok-chat-expert", "grok-chat-heavy"} { + if got := registry.PricingModel(account.ProviderWeb, upstreamModel); got != "grok-4.5" { + t.Fatalf("pricing model for %s = %q", upstreamModel, got) + } + } + mediaModels := map[string]string{ + "grok-imagine-image": "grok-imagine-image", "grok-imagine-image-quality": "grok-imagine-image-quality", + "imagine-image-edit": "grok-imagine-image-edit", "grok-imagine-video": "grok-imagine-video", + } + for upstreamModel, expected := range mediaModels { + if got := registry.PricingModel(account.ProviderWeb, upstreamModel); got != expected { + t.Fatalf("media pricing model for %s = %q", upstreamModel, got) + } + } +} + +func TestBuildWebChatPayloadMatchesCurrentConversationProtocol(t *testing.T) { + payload := buildWebChatPayload("你好", "auto", []string{"file_1"}) + if payload["modeId"] != "auto" || payload["temporary"] != true || payload["disableMemory"] != true { + t.Fatalf("payload protocol fields = %#v", payload) + } + attachments, ok := payload["fileAttachments"].([]string) + if !ok || !slices.Equal(attachments, []string{"file_1"}) { + t.Fatalf("fileAttachments = %#v", payload["fileAttachments"]) + } + if _, ok := payload["disabledConnectorIds"]; !ok { + t.Fatal("payload missing disabledConnectorIds") + } + device, ok := payload["deviceEnvInfo"].(map[string]any) + if !ok || device["screenWidth"] != 2056 || device["screenHeight"] != 1328 || device["viewportWidth"] != 2056 || device["viewportHeight"] != 1083 { + t.Fatalf("deviceEnvInfo = %#v", payload["deviceEnvInfo"]) + } + for _, obsolete := range []string{"connectors", "searchAllConnectors", "toolOverrides"} { + if _, ok := payload[obsolete]; ok { + t.Fatalf("payload contains obsolete field %q", obsolete) + } + } + encoded := string(MarshalJSONBytes(payload)) + assertForbiddenFieldsAbsent(t, encoded) +} + +func TestNormalizeOpenAIInputSeparatesTextAndImages(t *testing.T) { + dataURI := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + content, _ := json.Marshal([]any{ + map[string]any{"type": "text", "text": "描述这张图"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": dataURI}}, + }) + value, err := normalizeOpenAIInput(openAIRequest{Messages: []chatMessage{{Role: "user", Content: content}}}, "chat") + if err != nil { + t.Fatal(err) + } + if value.Prompt != "[user]\n描述这张图" || !slices.Equal(value.Attachments, []chatAttachmentInput{{Source: dataURI, Image: true}}) { + t.Fatalf("normalized input = %#v", value) + } +} + +func TestNormalizeResponsesInputImage(t *testing.T) { + dataURI := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + input, _ := json.Marshal([]any{map[string]any{ + "type": "message", "role": "user", "content": []any{ + map[string]any{"type": "input_text", "text": "what is this"}, + map[string]any{"type": "input_image", "image_url": dataURI}, + }, + }}) + value, err := normalizeOpenAIInput(openAIRequest{Input: input}, "responses") + if err != nil { + t.Fatal(err) + } + if value.Prompt != "[user]\nwhat is this" || !slices.Equal(value.Attachments, []chatAttachmentInput{{Source: dataURI, Image: true}}) { + t.Fatalf("normalized responses input = %#v", value) + } +} + +func TestNormalizeResponsesInputFile(t *testing.T) { + input, _ := json.Marshal([]any{map[string]any{ + "type": "message", "role": "user", "content": []any{ + map[string]any{"type": "input_file", "file_url": "https://example.com/a.pdf", "filename": "report.pdf"}, + }, + }}) + value, err := normalizeOpenAIInput(openAIRequest{Input: input}, "responses") + if err != nil || !slices.Equal(value.Attachments, []chatAttachmentInput{{Source: "https://example.com/a.pdf", Filename: "report.pdf"}}) { + t.Fatalf("normalized=%#v error=%v", value, err) + } +} + +func TestNormalizeChatNestedFileData(t *testing.T) { + content, _ := json.Marshal([]any{map[string]any{ + "type": "file", "file": map[string]any{ + "filename": "notes.txt", "file_data": "data:text/plain;base64,aGVsbG8=", + }, + }}) + value, err := normalizeOpenAIInput(openAIRequest{Messages: []chatMessage{{Role: "user", Content: content}}}, "chat") + if err != nil || !slices.Equal(value.Attachments, []chatAttachmentInput{{Source: "data:text/plain;base64,aGVsbG8=", Filename: "notes.txt"}}) { + t.Fatalf("normalized=%#v error=%v", value, err) + } + if _, err := parseChatFileDataURI(value.Attachments[0].Source, value.Attachments[0].Filename, 1<<20); err != nil { + t.Fatal(err) + } + badContent, _ := json.Marshal([]any{map[string]any{"type": "input_file", "file_id": "file_external"}}) + if _, err := normalizeOpenAIInput(openAIRequest{Messages: []chatMessage{{Role: "user", Content: badContent}}}, "chat"); err == nil || !strings.Contains(err.Error(), "file_id") { + t.Fatalf("file_id error=%v", err) + } +} + +func TestParseChatImageDataURIValidatesContent(t *testing.T) { + value := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + image, err := parseChatImageDataURI(value, 1<<20) + if err != nil { + t.Fatal(err) + } + if image.MIMEType != "image/png" || image.Filename != "image.png" || len(image.Data) == 0 { + t.Fatalf("image = %#v", image) + } + if _, err := parseChatImageDataURI("data:image/png;base64,bm90IGFuIGltYWdl", 1<<20); err == nil { + t.Fatal("non-image data URI was accepted") + } +} + +func TestParseChatFileDataURIValidatesAndSanitizesFile(t *testing.T) { + pdf := "data:application/pdf;base64,JVBERi0xLjQKJSVFT0Y=" + file, err := parseChatFileDataURI(pdf, "../report.pdf", 1<<20) + if err != nil || file.MIMEType != "application/pdf" || file.Filename != "report.pdf" || len(file.Data) == 0 { + t.Fatalf("file=%#v err=%v", file, err) + } + if _, err := parseChatFileDataURI("data:application/octet-stream;base64,AAEC", "payload.bin", 1<<20); err == nil { + t.Fatal("unsupported binary file was accepted") + } +} + +func TestRemoteChatImageURLBlocksPrivateNetworks(t *testing.T) { + for _, value := range []string{"http://example.com/image.png", "https://127.0.0.1/image.png", "https://169.254.169.254/latest/meta-data", "https://[::1]/image.png", "https://[::ffff:127.0.0.1]/image.png"} { + if _, err := validateRemoteImageURL(context.Background(), value); err == nil { + t.Fatalf("unsafe image URL accepted: %s", value) + } + } + if value, err := validateRemoteImageURL(context.Background(), "https://8.8.8.8/image.png"); err != nil || value.originalURL.Hostname() != "8.8.8.8" || value.fetchURL.Hostname() != "8.8.8.8" { + t.Fatalf("public image URL rejected: value=%v err=%v", value, err) + } +} + +func TestRemoteChatImageHeadersNeverLeakCredentials(t *testing.T) { + headers := remoteImageHeaders("test-agent") + if headers.Get("User-Agent") != "test-agent" || headers.Get("Cookie") != "" || headers.Get("Authorization") != "" { + t.Fatalf("remote image headers = %#v", headers) + } + fileHeaders := remoteFileHeaders("test-agent") + if fileHeaders.Get("User-Agent") != "test-agent" || fileHeaders.Get("Cookie") != "" || fileHeaders.Get("Authorization") != "" { + t.Fatalf("remote file headers = %#v", fileHeaders) + } +} + +func TestChatImageUploadFeedsFileMetadataIntoConversation(t *testing.T) { + dataURI := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + var uploadUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/http/upload-file-v2/direct": + uploadUserAgent = request.Header.Get("User-Agent") + if !strings.Contains(request.Header.Get("Cookie"), "sso=test-sso") { + t.Errorf("upload cookie = %q", request.Header.Get("Cookie")) + } + if err := request.ParseMultipartForm(2 << 20); err != nil { + t.Errorf("multipart: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + file, header, err := request.FormFile("file") + if err != nil { + t.Errorf("file part: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + defer file.Close() + content, _ := io.ReadAll(file) + if header.Filename != "image.png" || header.Header.Get("Content-Type") != "image/png" || len(content) == 0 || request.FormValue("file_source") != "" { + t.Errorf("upload filename=%q content-type=%q bytes=%d source=%q", header.Filename, header.Header.Get("Content-Type"), len(content), request.FormValue("file_source")) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"uploadId":"upload_1","fileMetadata":{"fileMetadataId":"file_meta_1","fileUri":"users/test/file_meta_1/content"}}`) + case "/rest/app-chat/upload-file": + t.Error("不应调用旧版 Base64 上传接口") + writer.WriteHeader(http.StatusInternalServerError) + case "/rest/app-chat/conversations/new": + if request.Header.Get("User-Agent") != uploadUserAgent { + t.Errorf("chat user-agent %q differs from upload %q", request.Header.Get("User-Agent"), uploadUserAgent) + } + var payload map[string]any + if json.NewDecoder(request.Body).Decode(&payload) != nil { + t.Error("chat payload is invalid JSON") + } + attachments, _ := payload["fileAttachments"].([]any) + if len(attachments) != 1 || attachments[0] != "file_meta_1" { + t.Errorf("fileAttachments = %#v", payload["fileAttachments"]) + } + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, "data: {\"result\":{\"conversation\":{\"conversationId\":\"conv_1\"}}}\n") + _, _ = io.WriteString(writer, "data: {\"result\":{\"response\":{\"userResponse\":{\"responseId\":\"parent_1\"}}}}\n") + _, _ = io.WriteString(writer, "data: {\"result\":{\"response\":{\"token\":\"seen\",\"isThinking\":false,\"messageTag\":\"final\"}}}\n") + _, _ = io.WriteString(writer, "data: [DONE]\n") + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: server.URL}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + content, _ := json.Marshal([]any{ + map[string]any{"type": "text", "text": "inspect"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": dataURI}}, + }) + body, _ := json.Marshal(map[string]any{ + "model": "grok-chat-fast", "messages": []any{map[string]any{"role": "user", "content": json.RawMessage(content)}}, + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, EncryptedAccessToken: encrypted}, Method: http.MethodPost, + Path: "/responses", Body: body, Model: "grok-chat-fast", Operation: "chat", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + result, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode != http.StatusOK || !bytes.Contains(result, []byte(`"content":"seen"`)) { + t.Fatalf("status=%d body=%s err=%v", response.StatusCode, result, err) + } +} + +func TestForwardMessagesWebSearchEndToEnd(t *testing.T) { + for _, streaming := range []bool{false, true} { + name := "non_stream" + if streaming { + name = "stream" + } + t.Run(name, func(t *testing.T) { + var upstreamMessage string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/rest/app-chat/conversations/new" { + http.NotFound(writer, request) + return + } + var payload map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("upstream payload: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + upstreamMessage, _ = payload["message"].(string) + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, "data: {\"result\":{\"conversation\":{\"conversationId\":\"conv_1\"}}}\n") + _, _ = io.WriteString(writer, "data: {\"result\":{\"response\":{\"rolloutId\":\"search_1\",\"messageStepId\":1,\"messageTag\":\"tool_usage_card\"}}}\n") + _, _ = io.WriteString(writer, "data: {\"result\":{\"response\":{\"token\":\"Here you go.\",\"isThinking\":false,\"messageTag\":\"final\",\"webSearchResults\":{\"results\":[{\"url\":\"https://doc.rust-lang.org\",\"title\":\"The Rust Book\"}]}}}}\n") + _, _ = io.WriteString(writer, "data: [DONE]\n") + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{BaseURL: server.URL, StatsigMode: "manual"}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + body, _ := json.Marshal(map[string]any{ + "model": "public", "max_tokens": 256, "stream": streaming, + "messages": []any{map[string]any{"role": "user", "content": "Perform a web search for the query: rust tutorials"}}, + "tools": []any{map[string]any{"type": "web_search_20250305", "name": "web_search", "max_uses": 8}}, + "tool_choice": map[string]any{"type": "tool", "name": "web_search"}, + }) + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 1, EncryptedAccessToken: encrypted}, Method: http.MethodPost, + Path: "/responses", Body: body, Model: "grok-chat-fast", Operation: conversation.OperationMessages, + Streaming: streaming, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + result, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(upstreamMessage, "rust tutorials") || strings.Contains(upstreamMessage, "You MUST call at least one available tool") { + t.Fatalf("upstream message = %q", upstreamMessage) + } + + if streaming { + text := string(result) + useAt := strings.Index(text, `"type":"server_tool_use"`) + resultAt := strings.Index(text, `"type":"web_search_tool_result"`) + textAt := strings.Index(text, `"content_block":{"text":"","type":"text"}`) + if response.StatusCode != http.StatusOK || useAt < 0 || resultAt < 0 || textAt < 0 || !(useAt < resultAt && resultAt < textAt) { + t.Fatalf("stream status=%d body=%s", response.StatusCode, text) + } + if !strings.Contains(text, "rust tutorials") || !strings.Contains(text, `"web_search_requests":1`) || !strings.Contains(text, "The Rust Book") { + t.Fatalf("stream missing query, usage, or hit: %s", text) + } + return + } + + var payload map[string]any + if err := json.Unmarshal(result, &payload); err != nil { + t.Fatal(err) + } + content := payload["content"].([]any) + if response.StatusCode != http.StatusOK || len(content) != 3 || content[0].(map[string]any)["type"] != "server_tool_use" || content[1].(map[string]any)["type"] != "web_search_tool_result" || content[2].(map[string]any)["text"] != "Here you go." { + t.Fatalf("non-stream status=%d payload=%#v", response.StatusCode, payload) + } + use := content[0].(map[string]any) + if use["input"].(map[string]any)["query"] != "rust tutorials" || content[1].(map[string]any)["tool_use_id"] != use["id"] { + t.Fatalf("web search linkage = %#v", content) + } + hits := content[1].(map[string]any)["content"].([]any) + if len(hits) != 1 || hits[0].(map[string]any)["title"] != "The Rust Book" || payload["stop_reason"] != "end_turn" { + t.Fatalf("web search result = %#v", payload) + } + usage := payload["usage"].(map[string]any)["server_tool_use"].(map[string]any) + if usage["web_search_requests"] != float64(1) { + t.Fatalf("web search usage = %#v", usage) + } + }) + } +} + +type egressRepositoryStub struct{} + +func (egressRepositoryStub) ListEgressNodes(context.Context, egressdomain.Scope, repository.SortQuery) ([]egressdomain.Node, error) { + return nil, nil +} + +func (egressRepositoryStub) GetEgressNode(context.Context, uint64) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("not found") +} + +func (egressRepositoryStub) CreateEgressNode(context.Context, egressdomain.Node) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("unsupported") +} + +func (egressRepositoryStub) UpdateEgressNode(context.Context, egressdomain.Node) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("unsupported") +} + +func (egressRepositoryStub) DeleteEgressNode(context.Context, uint64) error { + return errors.New("unsupported") +} + +func TestLiteChatRejectsInvalidImageConfigBeforeUpstream(t *testing.T) { + adapter := &Adapter{} + response, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ + Method: http.MethodPost, Model: "grok-imagine-image", Operation: "chat", + Body: []byte(`{"model":"grok-imagine-image","messages":[{"role":"user","content":"draw"}],"image_config":{"n":0}}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d", response.StatusCode) + } +} + +func TestParseLiteImageCardAttachment(t *testing.T) { + parsed := &parsedChat{} + frame := map[string]any{"result": map[string]any{"response": map[string]any{ + "cardAttachment": map[string]any{"jsonData": `{"id":"card_1","image_chunk":{"progress":100,"imageUrl":"generated/image.jpg","moderated":false}}`}, + }}} + data, _ := json.Marshal(frame) + kind, delta, err := parseUpstreamFrame(data, parsed) + if err != nil || kind != "image" || delta != "https://assets.grok.com/generated/image.jpg" || !slices.Equal(parsed.Images, []string{delta}) { + t.Fatalf("kind=%q delta=%q err=%v", kind, delta, err) + } +} + +func TestParseLiteNestedUsageLimit(t *testing.T) { + parsed := &parsedChat{} + frame := []byte(`{"result":{"response":{"error":{"message":"You've reached your usage limit. Please try again later."},"cardAttachment":{"jsonData":"{\"image_chunk\":{\"progress\":100,\"systemErrCode\":\"rate_limit\"}}"}}}}`) + if _, _, err := parseUpstreamFrame(frame, parsed); !errors.Is(err, errWebUsageLimit) { + t.Fatalf("error = %v", err) + } +} + +func TestParseLiteImageCardAttachmentVariants(t *testing.T) { + parsed := &parsedChat{} + frame := map[string]any{"result": map[string]any{"response": map[string]any{ + "cardAttachments": []any{ + map[string]any{"jsonData": map[string]any{"id": "pending", "imageChunk": map[string]any{"progress": 50, "imageUrl": "generated/partial.jpg"}}}, + map[string]any{"jsonData": map[string]any{"id": "final", "imageChunk": map[string]any{"progress": 100, "image_url": "users/user_1/generated/final/image.jpg", "moderated": false}}}, + }, + }}} + data, _ := json.Marshal(frame) + kind, delta, err := parseUpstreamFrame(data, parsed) + want := "https://assets.grok.com/users/user_1/generated/final/image.jpg" + if err != nil || kind != "image" || delta != want || !slices.Equal(parsed.Images, []string{want}) { + t.Fatalf("kind=%q delta=%q images=%#v err=%v", kind, delta, parsed.Images, err) + } +} + +func TestCapturedLiteRenderFileFlowCompletesOnImageCard(t *testing.T) { + fixture := strings.Join([]string{ + `data: {"result":{"conversation":{"conversationId":"conv_1"}}}`, + `data: {"result":{"response":{"cardAttachment":{"jsonData":"{\"id\":\"x1Xyo\",\"type\":\"render_file\",\"cardType\":\"generated_image_card\",\"image_chunk\":null}"},"messageTag":"final"}}}`, + `data: {"result":{"response":{"token":"/home/workdir/artifacts/imagine_images/cat.jpg","isThinking":false,"messageTag":"final"}}}`, + `data: {"result":{"response":{"cardAttachment":{"jsonData":"{\"id\":\"x1Xyo\",\"type\":\"render_file\",\"cardType\":\"generated_image_card\",\"image_chunk\":{\"imageUuid\":\"image_1\",\"imageUrl\":\"users/user_1/generated/cat/image.jpg\",\"progress\":100,\"moderated\":false}}"},"messageTag":"final"}}}`, + `data: {"result":{"response":{"token":"这段文本不应影响图片完成","isThinking":false,"messageTag":"final"}}}`, + }, "\n") + firstImage := "" + parsed, err := consumeUpstream(strings.NewReader(fixture), func(kind, delta string) error { + if kind == "image" { + firstImage = delta + return errLiteImageReady + } + return nil + }) + if !errors.Is(err, errLiteImageReady) { + t.Fatalf("error = %v", err) + } + if firstImage != "https://assets.grok.com/users/user_1/generated/cat/image.jpg" || parsed.Text.String() != "" { + t.Fatalf("image=%q text=%q parsed=%#v", firstImage, parsed.Text.String(), parsed) + } +} + +func TestConsumeUpstreamHandlesConcatenatedImageEditFrames(t *testing.T) { + fixture := `{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/edit/image.jpg","progress":50}}}}` + + `{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/edit/image.jpg","progress":100}}}}` + + `{"result":{"response":{"modelResponse":{"generatedImageUrls":["users/test/generated/edit/image.jpg"]}}}}` + parsed, err := consumeUpstream(strings.NewReader(fixture), nil) + if err != nil { + t.Fatal(err) + } + want := "https://assets.grok.com/users/test/generated/edit/image.jpg" + if !slices.Equal(parsed.Images, []string{want}) { + t.Fatalf("images = %#v, want %#v", parsed.Images, []string{want}) + } +} + +func TestStreamingImageEditRejectsModeratedFinalImage(t *testing.T) { + parsed := &parsedChat{} + frame := []byte(`{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/moderated/image.jpg","progress":100,"moderated":true}}}}`) + kind, delta, err := parseUpstreamFrame(frame, parsed) + if err != nil || kind != "" || delta != "" || len(parsed.Images) != 0 { + t.Fatalf("kind=%q delta=%q images=%#v err=%v", kind, delta, parsed.Images, err) + } + final := []byte(`{"result":{"response":{"modelResponse":{"generatedImageUrls":["users/test/generated/moderated/image.jpg"]}}}}`) + if kind, delta, err := parseUpstreamFrame(final, parsed); err != nil || kind != "" || delta != "" || len(parsed.Images) != 0 { + t.Fatalf("fallback kind=%q delta=%q images=%#v err=%v", kind, delta, parsed.Images, err) + } + capture := append(append([]byte(nil), frame...), final...) + if urls := imageEditResultURLs(parsed, capture); len(urls) != 0 { + t.Fatalf("moderated capture leaked images: %#v", urls) + } +} + +func TestImageEditRejectsUnconfirmedCountAndResolution(t *testing.T) { + adapter := &Adapter{} + for _, request := range []provider.ImageEditRequest{ + {ImageURLs: []string{"data:image/png;base64,AA=="}, Count: 2, Resolution: "1k"}, + {ImageURLs: []string{"data:image/png;base64,AA=="}, Count: 1, Resolution: "2k"}, + {ImageURLs: []string{"data:image/png;base64,AA=="}, Count: 1, Resolution: "1k", PartialImages: 1}, + {ImageURLs: []string{"data:image/png;base64,AA=="}, Count: 1, Resolution: "1k", Streaming: true, PartialImages: 4}, + } { + response, err := adapter.EditImage(context.Background(), request) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != http.StatusBadRequest || !bytes.Contains(body, []byte(`"error"`)) { + t.Fatalf("status=%d body=%s", response.StatusCode, body) + } + } +} + +func TestBuildImageEditPayloadMatchesCapturedAspectRatioShape(t *testing.T) { + payload := buildImageEditPayload("改成兔子", []string{"https://assets.grok.com/users/test/reference/content"}, "post_1", "1:1") + metadata, _ := payload["responseMetadata"].(map[string]any) + override, _ := metadata["modelConfigOverride"].(map[string]any) + modelMap, _ := override["modelMap"].(map[string]any) + config, _ := modelMap["imageEditModelConfig"].(map[string]any) + if payload["modelName"] != "imagine-image-edit" || payload["imageGenerationCount"] != 2 || modelMap["imageEditModel"] != "imagine" { + t.Fatalf("payload = %#v", payload) + } + if config["aspectRatio"] != "1:1" || config["parentPostId"] != "post_1" || !slices.Equal(config["imageReferences"].([]string), []string{"https://assets.grok.com/users/test/reference/content"}) { + t.Fatalf("image edit config = %#v", config) + } + withoutRatio := buildImageEditPayload("edit", []string{"reference"}, "post_2", "") + metadata = withoutRatio["responseMetadata"].(map[string]any) + override = metadata["modelConfigOverride"].(map[string]any) + modelMap = override["modelMap"].(map[string]any) + config = modelMap["imageEditModelConfig"].(map[string]any) + if _, exists := config["aspectRatio"]; exists { + t.Fatalf("empty aspect ratio leaked into payload: %#v", config) + } +} + +func TestImageEditAspectRatioSupportsOpenAISize(t *testing.T) { + for _, test := range []struct { + aspectRatio string + size string + want string + }{ + {aspectRatio: "1:1", size: "1536x1024", want: "1:1"}, + {size: "1024x1024", want: "1:1"}, + {size: "1024x1536", want: "2:3"}, + {size: "1536x1024", want: "3:2"}, + {size: "auto", want: "auto"}, + {want: ""}, + } { + got, err := resolveImageEditAspectRatio(test.aspectRatio, test.size) + if err != nil || got != test.want { + t.Fatalf("aspect=%q size=%q got=%q err=%v", test.aspectRatio, test.size, got, err) + } + } +} + +func TestParseImageEditStreamFrame(t *testing.T) { + frame, ok := parseImageEditStreamFrame([]byte(`{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/edit-part-0/image.jpg","progress":50}}}}`)) + if !ok || frame.URL != "https://assets.grok.com/users/test/generated/edit-part-0/image.jpg" || frame.Progress != 50 || frame.Moderated { + t.Fatalf("partial frame = %#v ok=%t", frame, ok) + } + frame, ok = parseImageEditStreamFrame([]byte(`{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/edit/image.jpg","isFinal":true,"moderated":true}}}}`)) + if !ok || frame.Progress != 100 || !frame.Moderated { + t.Fatalf("final frame = %#v ok=%t", frame, ok) + } +} + +func TestImageEditStreamUsesOfficialOpenAIEvents(t *testing.T) { + png := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0, 'I', 'H', 'D', 'R'} + partial := openAIImageEditStreamEvent("image_edit.partial_image", png, 123, "1024x1024", 0) + if partial["type"] != "image_edit.partial_image" || partial["partial_image_index"] != 0 || partial["usage"] != nil || partial["output_format"] != "png" { + t.Fatalf("partial event = %#v", partial) + } + completed := openAIImageEditStreamEvent("image_edit.completed", []byte("jpeg"), 123, "auto", 0) + usage, _ := completed["usage"].(map[string]any) + if completed["type"] != "image_edit.completed" || completed["partial_image_index"] != nil || usage["total_tokens"] != 0 { + t.Fatalf("completed event = %#v", completed) + } + var output bytes.Buffer + if err := writeSSE(&output, "image_edit.partial_image", partial); err != nil { + t.Fatal(err) + } + if err := writeSSE(&output, "image_edit.completed", completed); err != nil { + t.Fatal(err) + } + value := output.String() + if !strings.Contains(value, "event: image_edit.partial_image") || !strings.Contains(value, "event: image_edit.completed") || strings.Contains(value, "image_generation.") { + t.Fatalf("stream = %q", value) + } +} + +func TestExtractCapturedImageURLsPrefersFinalImage(t *testing.T) { + fixture := []byte(`{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/id-part-0/image.jpg","progress":50}}}}` + + `{"result":{"response":{"streamingImageGenerationResponse":{"imageUrl":"users/test/generated/id/image.jpg","progress":100}}}}` + + `{"result":{"response":{"modelResponse":{"generatedImageUrls":["users/test/generated/id/image.jpg"]}}}}`) + want := []string{"https://assets.grok.com/users/test/generated/id/image.jpg"} + if got := extractCapturedImageURLs(fixture); !slices.Equal(got, want) { + t.Fatalf("urls = %#v, want %#v", got, want) + } +} + +func TestExtractCapturedImageURLsHandlesNestedJSONData(t *testing.T) { + fixture := []byte(`{"result":{"response":{"unknownWrapper":{"jsonData":"{\"image_chunk\":{\"imageUrl\":\"users/test/generated/id-part-0/image.jpg\",\"progress\":50}}"}}}}` + + `{"result":{"response":{"unknownWrapper":{"jsonData":"{\"image_chunk\":{\"imageUrl\":\"users/test/generated/id/image.jpg\",\"progress\":100,\"moderated\":false}}"}}}}`) + want := []string{"https://assets.grok.com/users/test/generated/id/image.jpg"} + if got := extractCapturedImageURLs(fixture); !slices.Equal(got, want) { + t.Fatalf("urls = %#v, want %#v", got, want) + } +} + +func TestLiteModelResponseCardAttachmentsFallback(t *testing.T) { + parsed := &parsedChat{} + frame := map[string]any{"result": map[string]any{"response": map[string]any{ + "modelResponse": map[string]any{ + "generatedImageUrls": []any{}, + "cardAttachmentsJson": []any{`{"id":"x1Xyo","image_chunk":{"imageUrl":"users/user_1/generated/fallback/image.jpg","progress":100,"moderated":false}}`}, + }, + }}} + data, _ := json.Marshal(frame) + kind, delta, err := parseUpstreamFrame(data, parsed) + if err != nil || kind != "image" || delta != "https://assets.grok.com/users/user_1/generated/fallback/image.jpg" { + t.Fatalf("kind=%q delta=%q images=%#v err=%v", kind, delta, parsed.Images, err) + } +} + +func TestBuildDirectFileUploadBodyMatchesImagineMultipartProtocol(t *testing.T) { + raw := []byte("png-binary") + body, contentType, err := buildDirectFileUploadBody(provider.ImageInput{ + Filename: "测试中文.png", MIMEType: "image/png", Data: raw, + }, imagineSelfUploadSource) + if err != nil { + t.Fatal(err) + } + mediaType, parameters, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "multipart/form-data" || parameters["boundary"] == "" { + t.Fatalf("content type=%q parameters=%#v err=%v", contentType, parameters, err) + } + if !bytes.Contains(body, []byte(`Content-Disposition: form-data; name="file"; filename="测试中文.png"`)) || bytes.Contains(body, []byte("filename*=")) { + t.Fatalf("multipart disposition does not match browser format: %q", body) + } + reader := multipart.NewReader(bytes.NewReader(body), parameters["boundary"]) + filePart, err := reader.NextPart() + if err != nil { + t.Fatal(err) + } + fileData, err := io.ReadAll(filePart) + if err != nil || filePart.FormName() != "file" || filePart.FileName() != "测试中文.png" || filePart.Header.Get("Content-Type") != "image/png" || !bytes.Equal(fileData, raw) { + t.Fatalf("file name=%q filename=%q content-type=%q data=%q err=%v", filePart.FormName(), filePart.FileName(), filePart.Header.Get("Content-Type"), fileData, err) + } + sourcePart, err := reader.NextPart() + if err != nil { + t.Fatal(err) + } + source, err := io.ReadAll(sourcePart) + if err != nil || sourcePart.FormName() != "file_source" || string(source) != imagineSelfUploadSource { + t.Fatalf("source name=%q value=%q err=%v", sourcePart.FormName(), source, err) + } + if part, err := reader.NextPart(); !errors.Is(err, io.EOF) || part != nil { + t.Fatalf("unexpected trailing part=%v err=%v", part, err) + } +} + +func TestBrowserMultipartFilenameEscapesAndSanitizesUnsafeValues(t *testing.T) { + for _, test := range []struct { + name string + value string + want string + }{ + {name: "quote and backslash", value: "a\\b\"c.png", want: `a\\b\"c.png`}, + {name: "line breaks", value: "a\r\nb.png", want: "ab.png"}, + {name: "control characters", value: "a\x00\tb.png", want: "a__b.png"}, + {name: "empty after cleanup", value: "\r\n", want: "upload.bin"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := browserMultipartFilename(test.value); got != test.want { + t.Fatalf("filename = %q, want %q", got, test.want) + } + }) + } +} + +func TestBuildDirectFileUploadBodySanitizesUnsafeFilename(t *testing.T) { + body, contentType, err := buildDirectFileUploadBody(provider.ImageInput{ + Filename: "a\\b\"c\r\n\x00.png", MIMEType: "image/png", Data: []byte("png"), + }, "") + if err != nil { + t.Fatal(err) + } + _, parameters, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatal(err) + } + reader := multipart.NewReader(bytes.NewReader(body), parameters["boundary"]) + part, err := reader.NextPart() + if err != nil { + t.Fatal(err) + } + if part.FormName() != "file" || part.FileName() != "a\\b\"c_.png" { + t.Fatalf("form name=%q filename=%q", part.FormName(), part.FileName()) + } +} + +func TestBuildDirectFileUploadBodyOmitsSourceForChat(t *testing.T) { + body, contentType, err := buildDirectFileUploadBody(provider.ImageInput{Filename: "chat.png", MIMEType: "image/png", Data: []byte("png")}, "") + if err != nil { + t.Fatal(err) + } + _, parameters, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatal(err) + } + reader := multipart.NewReader(bytes.NewReader(body), parameters["boundary"]) + part, err := reader.NextPart() + if err != nil || part.FormName() != "file" { + t.Fatalf("file part=%v err=%v", part, err) + } + if _, err := io.Copy(io.Discard, part); err != nil { + t.Fatal(err) + } + if part, err := reader.NextPart(); !errors.Is(err, io.EOF) || part != nil { + t.Fatalf("chat upload unexpectedly contains another form field: part=%v err=%v", part, err) + } +} + +func TestVideoReferenceUsesV2DirectUpload(t *testing.T) { + dataURI := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/http/upload-file-v2/direct" { + t.Errorf("unexpected upload path %q", request.URL.Path) + writer.WriteHeader(http.StatusNotFound) + return + } + if err := request.ParseMultipartForm(2 << 20); err != nil { + t.Errorf("multipart: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + file, header, err := request.FormFile("file") + if err != nil { + t.Errorf("file part: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + defer file.Close() + content, _ := io.ReadAll(file) + if header.Filename != "image.png" || header.Header.Get("Content-Type") != "image/png" || len(content) == 0 { + t.Errorf("upload filename=%q content-type=%q bytes=%d", header.Filename, header.Header.Get("Content-Type"), len(content)) + } + if request.FormValue("file_source") != imagineSelfUploadSource { + t.Errorf("file_source = %q", request.FormValue("file_source")) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"uploadId":"upload-1","fileMetadata":{"fileMetadataId":"metadata-1","fileUri":"users/test/reference/content"}}`) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + manager := infraegress.NewManager(egressRepositoryStub{}, cipher) + lease, err := manager.Acquire(context.Background(), egressdomain.ScopeWeb, "video-reference-test") + if err != nil { + t.Fatal(err) + } + defer lease.Release() + adapter := NewAdapter(Config{BaseURL: server.URL}, manager, cipher, nil, nil) + uri, err := adapter.prepareVideoReference(context.Background(), adapter.config(), lease, "test-sso", dataURI) + if err != nil { + t.Fatal(err) + } + if uri != "https://assets.grok.com/users/test/reference/content" { + t.Fatalf("uri = %q", uri) + } +} + +func TestDecodeDirectFileUploadResponse(t *testing.T) { + uploaded, err := decodeDirectFileUploadResponse(strings.NewReader(`{ + "uploadId":"upload-1", + "fileMetadata":{"fileMetadataId":"metadata-1","fileUri":"users/test/reference/content"} + }`)) + if err != nil || uploaded.ID != "metadata-1" || uploaded.URI != "https://assets.grok.com/users/test/reference/content" { + t.Fatalf("uploaded=%#v err=%v", uploaded, err) + } + if _, err := decodeDirectFileUploadResponse(strings.NewReader(`{"uploadId":"upload-1","fileMetadata":{}}`)); err == nil { + t.Fatal("incomplete V2 upload response was accepted") + } +} + +func TestWebMediaStreamErrorRedactsSensitiveValues(t *testing.T) { + err := webMediaStreamError(map[string]any{ + "message": "Bearer sensitive-token from owner@example.com at https://grok.com/private?token=secret", + }) + for _, secret := range []string{"sensitive-token", "owner@example.com", "token=secret"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("stream error exposed %q: %v", secret, err) + } + } +} + +func TestWebMediaUpstreamDiagnosticLogsStageHeadersWithoutBodyPreview(t *testing.T) { + var output bytes.Buffer + adapter := &Adapter{logger: slog.New(slog.NewTextHandler(&output, nil))} + body := []byte(`Just a moment...

access_token=secret owner@example.com https://grok.com/private?token=secret

` + strings.Repeat("A", 1024)) + upstreamErr := newWebMediaUpstreamError(http.StatusForbidden, body, true) + response := &http.Response{ + StatusCode: http.StatusForbidden, + ContentLength: 70000, + Header: http.Header{ + "Content-Type": []string{"text/html; charset=UTF-8"}, + "Server": []string{"cloudflare"}, + "Cf-Ray": []string{"test-ray-SIN"}, + }, + } + + adapter.logWebMediaUpstreamRejection("video_reference_upload", response, upstreamErr) + logLine := output.String() + for _, expected := range []string{ + "msg=web_media_upstream_rejected", "stage=video_reference_upload", "status=403", + "body_truncated=true", "body_prefix_sha256=", "body_kind=html", "cloudflare_challenge=true", + "content_type=\"text/html; charset=UTF-8\"", + "server=cloudflare", "cf_ray=test-ray-SIN", + } { + if !strings.Contains(logLine, expected) { + t.Fatalf("log missing %q: %s", expected, logLine) + } + } + for _, secret := range []string{"Just a moment", "challenge-secret", "access_token=secret", "owner@example.com", "token=secret", strings.Repeat("A", 256)} { + if strings.Contains(logLine, secret) { + t.Fatalf("log exposed %q: %s", secret, logLine) + } + } +} + +func TestChatModelsUseLowestSufficientTierFirst(t *testing.T) { + adapter := &Adapter{} + tests := []struct { + model string + want []account.WebTier + }{ + {model: "grok-chat-fast", want: []account.WebTier{account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy}}, + {model: "grok-chat-auto", want: []account.WebTier{account.WebTierSuper, account.WebTierHeavy}}, + {model: "grok-chat-expert", want: []account.WebTier{account.WebTierSuper, account.WebTierHeavy}}, + {model: "grok-chat-heavy", want: []account.WebTier{account.WebTierHeavy}}, + } + for _, test := range tests { + got := adapter.TierOrder(test.model) + if !slices.Equal(got, test.want) { + t.Fatalf("tier order for %s = %v, want %v", test.model, got, test.want) + } + } +} + +func TestOnlyChatModelsExposeRateLimitModes(t *testing.T) { + for _, spec := range Catalog() { + if spec.Capability == modeldomain.CapabilityChat { + if !slices.Contains([]string{"auto", "fast", "expert", "heavy"}, spec.Mode) { + t.Fatalf("chat model %s has invalid quota mode %q", spec.PublicID, spec.Mode) + } + continue + } + if spec.ProtocolModel == "imagine-lite" { + if spec.Mode != "fast" { + t.Fatalf("Lite image must use fast quota mode, got %q", spec.Mode) + } + continue + } + if spec.Mode != "" { + t.Fatalf("media model %s must not expose chat quota mode %q", spec.PublicID, spec.Mode) + } + } +} + +func TestConsumeUpstreamChatFixture(t *testing.T) { + fixture := strings.Join([]string{ + `data: {"result":{"conversation":{"conversationId":"conv_1"}}}`, + `data: {"result":{"response":{"userResponse":{"responseId":"up_1"}}}}`, + `data: {"result":{"response":{"token":"thinking ","isThinking":true,"messageTag":"analysis"}}}`, + `data: {"result":{"response":{"token":"hello","isThinking":false,"messageTag":"final","webSearchResults":{"results":[{"url":"https://example.com"}]}}}}`, + `data: [DONE]`, + }, "\n") + parsed, err := consumeUpstream(strings.NewReader(fixture), nil) + if err != nil { + t.Fatal(err) + } + if parsed.ConversationID != "conv_1" || parsed.ParentID != "up_1" || parsed.Reasoning.String() != "thinking " || parsed.Text.String() != "hello" || len(parsed.SearchSources) != 1 { + t.Fatalf("parsed = %#v, text=%q reasoning=%q", parsed, parsed.Text.String(), parsed.Reasoning.String()) + } +} + +func TestWebVisibleStreamPhaseSuppressesLateReasoning(t *testing.T) { + phase := webVisibleStreamPhase{} + tests := []struct { + kind string + delta string + want bool + }{ + {kind: "reasoning", delta: "first thought", want: true}, + {kind: "text", delta: "", want: false}, + {kind: "reasoning", delta: "continued thought", want: true}, + {kind: "text", delta: "answer", want: true}, + {kind: "reasoning", delta: "late thought", want: false}, + {kind: "text", delta: " continued", want: true}, + } + for index, test := range tests { + if got := phase.Allow(test.kind, test.delta); got != test.want { + t.Fatalf("step %d Allow(%q, %q) = %v, want %v", index, test.kind, test.delta, got, test.want) + } + } +} + +func TestPreflightRejectsInBandErrorBeforeStreaming(t *testing.T) { + source := io.NopCloser(strings.NewReader(`data: {"error":{"message":"rate limited","code":8}}` + "\n")) + if _, err := preflightUpstream(source); err == nil || !strings.Contains(err.Error(), "rate limited") { + t.Fatalf("error = %v", err) + } +} + +func TestPreflightClassifiesAntiBotRejection(t *testing.T) { + source := io.NopCloser(strings.NewReader(`{"error":{"message":"Request rejected by anti-bot rules.","code":7,"details":[]}}` + "\n")) + if _, err := preflightUpstream(source); !errors.Is(err, errWebAntiBot) { + t.Fatalf("error = %v", err) + } +} + +func TestImagineRequestContainsOnlyProtocolProperties(t *testing.T) { + message := imagineRequestMessage("request", "prompt", "16:9", false, true, 8) + item := message["item"].(map[string]any) + content := item["content"].([]any)[0].(map[string]any) + properties := content["properties"].(map[string]any) + if properties["aspect_ratio"] != "16:9" || properties["enable_pro"] != true || properties["enable_nsfw"] != false || properties["num_generations"] != 8 { + t.Fatalf("properties = %#v", properties) + } + encoded := string(MarshalJSONBytes(message)) + assertForbiddenFieldsAbsent(t, encoded) +} + +func TestImagineResolutionAndBatchMapping(t *testing.T) { + tests := []struct { + resolution string + count int + pro bool + batch int + }{ + {resolution: "1k", count: 1, batch: 4}, + {resolution: "1k", count: 4, batch: 4}, + {resolution: "1k", count: 5, batch: 8}, + {resolution: "2k", count: 8, pro: true, batch: 8}, + {resolution: "2k", count: 9, pro: true, batch: 12}, + {resolution: "2k", count: 10, pro: true, batch: 12}, + } + for _, test := range tests { + config, ok := resolveImagineModel("imagine", test.resolution, test.count) + if !ok || config.Pro != test.pro || config.NativeBatchSize != test.batch || config.MaxReturnCount != 10 { + t.Fatalf("resolution=%s count=%d config=%#v", test.resolution, test.count, config) + } + } + config, _ := resolveImagineModel("imagine", "1k", 1) + if got := imagineUpstreamGenerationCount(true, 1, config); got != 1 { + t.Fatalf("streaming upstream count = %d, want 1", got) + } + if got := imagineUpstreamGenerationCount(false, 1, config); got != 4 { + t.Fatalf("non-streaming upstream count = %d, want 4", got) + } +} + +func TestImageStreamingRejectsMultipleOutputs(t *testing.T) { + response, err := (&Adapter{}).GenerateImage(context.Background(), provider.ImageGenerationRequest{ + Model: "grok-imagine-image-quality", Prompt: "cat", Count: 2, Streaming: true, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + var payload map[string]any + if json.Unmarshal(body, &payload) != nil { + t.Fatalf("body=%s", body) + } + errorValue, _ := payload["error"].(map[string]any) + if response.StatusCode != http.StatusBadRequest || errorValue["message"] != "Streaming is only supported with n=1." || errorValue["type"] != "image_generation_user_error" || errorValue["param"] != "input" || errorValue["code"] != "unsupported_parameter" { + t.Fatalf("status=%d error=%#v", response.StatusCode, errorValue) + } +} + +func TestImageAspectRatioFollowsXAIContractAndSizeAlias(t *testing.T) { + for input, expected := range map[string]string{"auto": "auto", "19.5:9": "19.5:9", "9:20": "9:20", "1536x1024": "3:2", "1024x1536": "2:3"} { + got, err := resolveImageAspectRatio(input, "") + if err != nil || got != expected { + t.Fatalf("aspect ratio %q = %q, err=%v", input, got, err) + } + } + if got, err := resolveImageAspectRatio("", "1024x1024"); err != nil || got != "1:1" { + t.Fatalf("size alias = %q, err=%v", got, err) + } + if _, err := resolveImageAspectRatio("7:5", ""); err == nil { + t.Fatal("unsupported aspect ratio accepted") + } +} + +func TestImagineCollectorHandlesOutOfOrderFrames(t *testing.T) { + collector := newImagineCollector() + collector.Accept(map[string]any{"type": "json", "current_status": "completed", "image_id": "image-b", "order": 2.0, "moderated": false}) + collector.Accept(map[string]any{"type": "image", "id": "image-a", "grid_index": 0.0, "url": "https://imagine-public.x.ai/imagine-public/images/image-a.jpg"}) + collector.Accept(map[string]any{"type": "json", "current_status": "completed", "image_id": "image-a", "moderated": false}) + if collector.Done(2) { + t.Fatal("collector completed before delayed image payload arrived") + } + collector.Accept(map[string]any{"type": "image", "id": "image-b", "grid_index": 1.0, "url": "https://imagine-public.x.ai/imagine-public/images/image-b.jpg"}) + if !collector.Done(2) { + t.Fatal("collector did not complete after both image payloads arrived") + } + images := collector.Images() + if len(images) != 2 || images[0].ID != "image-a" || images[1].ID != "image-b" { + t.Fatalf("images = %#v", images) + } + ready := collector.ReadyImages() + if len(ready) != 2 || len(collector.ReadyImages()) != 0 { + t.Fatalf("ready images emitted more than once: %#v", ready) + } +} + +func TestImagineCollectorIgnoresWebSocketPreviewFrames(t *testing.T) { + collector := newImagineCollector() + collector.Accept(map[string]any{ + "type": "image", "id": "image-a", "order": 0.0, + "percentage_complete": 50.0, + "url": "https://imagine-public.x.ai/imagine-public/images/image-a.png", + "blob": "preview", + }) + previews := collector.ReadyPreviews() + if len(previews) != 1 || previews[0].ID != "image-a" || previews[0].Blob != "preview" || len(collector.ReadyPreviews()) != 0 { + t.Fatalf("previews = %#v", previews) + } + collector.Accept(map[string]any{ + "type": "json", "current_status": "completed", "image_id": "image-a", + "order": 0.0, "moderated": false, + }) + if collector.Done(1) || collector.UsableCount() != 0 || len(collector.Images()) != 0 { + t.Fatalf("preview became final: %#v", collector) + } + collector.Accept(map[string]any{ + "type": "image", "id": "image-a", "order": 0.0, + "percentage_complete": 100.0, + "url": "https://imagine-public.x.ai/imagine-public/images/image-a.jpg", + "blob": "final", + }) + if !collector.Done(1) || collector.UsableCount() != 1 { + t.Fatalf("final image not recognized: %#v", collector) + } + images := collector.Images() + if len(images) != 1 || images[0].URL != "https://imagine-public.x.ai/imagine-public/images/image-a.jpg" || images[0].Blob != "final" { + t.Fatalf("images = %#v", images) + } +} + +func TestImagineCollectorKeepsInterleavedJobsIsolated(t *testing.T) { + collector := newImagineCollector() + collector.Accept(map[string]any{ + "type": "image", "id": "image-a", "order": 0.0, "percentage_complete": 50.0, "blob": "a-preview", + }) + collector.Accept(map[string]any{ + "type": "image", "id": "image-b", "order": 1.0, "percentage_complete": 50.0, "blob": "b-preview", + }) + previews := collector.ReadyPreviews() + if len(previews) != 2 || previews[0].ID != "image-a" || previews[0].Blob != "a-preview" || previews[1].ID != "image-b" || previews[1].Blob != "b-preview" { + t.Fatalf("previews = %#v", previews) + } + collector.Accept(map[string]any{ + "type": "image", "id": "image-b", "order": 1.0, "percentage_complete": 100.0, "blob": "b-final", + }) + collector.Accept(map[string]any{"type": "json", "current_status": "completed", "image_id": "image-b", "order": 1.0, "moderated": false}) + ready := collector.ReadyImages() + if len(ready) != 1 || ready[0].ID != "image-b" || ready[0].Blob != "b-final" { + t.Fatalf("first ready = %#v", ready) + } + collector.Accept(map[string]any{ + "type": "image", "id": "image-a", "order": 0.0, "percentage_complete": 100.0, "blob": "a-final", + }) + collector.Accept(map[string]any{"type": "json", "current_status": "completed", "image_id": "image-a", "order": 0.0, "moderated": false}) + ready = collector.ReadyImages() + if len(ready) != 1 || ready[0].ID != "image-a" || ready[0].Blob != "a-final" { + t.Fatalf("second ready = %#v", ready) + } +} + +func TestImagineCollectorCanReturnRequestedSubsetBeforeNativeBatchCompletes(t *testing.T) { + collector := newImagineCollector() + for index := 0; index < 3; index++ { + id := fmt.Sprintf("image-%d", index) + collector.Accept(map[string]any{ + "type": "image", "id": id, "order": float64(index), "percentage_complete": 100.0, + "url": "https://imagine-public.x.ai/imagine-public/images/" + id + ".jpg", + }) + collector.Accept(map[string]any{ + "type": "json", "current_status": "completed", "image_id": id, + "order": float64(index), "moderated": false, + }) + } + if collector.Done(4) || collector.UsableCount() != 3 { + t.Fatalf("collector=%#v usable=%d", collector, collector.UsableCount()) + } + if collector.UsableCount() < 2 { + t.Fatal("a request for two images should already be satisfiable") + } +} + +func TestImagineCollectorSettlesModeratedSlots(t *testing.T) { + collector := newImagineCollector() + collector.Accept(map[string]any{"type": "json", "current_status": "completed", "image_id": "blocked", "moderated": true}) + if !collector.Done(1) || len(collector.Images()) != 0 { + t.Fatalf("moderated collector = %#v", collector) + } +} + +func TestGeneratedImageAssetHostsRemainStrict(t *testing.T) { + if !trustedImageAssetHost("assets.grok.com") || !trustedImageAssetHost("imagine-public.x.ai") || !trustedImageAssetHost("imgen.x.ai") || trustedImageAssetHost("example.com") { + t.Fatal("generated image host allowlist is incorrect") + } +} + +func TestImageStreamUsesOfficialOpenAIEventsWithoutTokenUsage(t *testing.T) { + adapter := &Adapter{assets: imageAssetStoreStub{}} + urlItem, err := adapter.imageDataItem(context.Background(), account.Credential{}, imagineImageValue{URL: "https://imgen.x.ai/image.jpg", Blob: "aW1hZ2U="}, "url") + if err != nil || urlItem["url"] != "https://api.example/v1/media/images/img_test" || urlItem["mime_type"] != "image/jpeg" || urlItem["revised_prompt"] != "" { + t.Fatalf("url item = %#v, err=%v", urlItem, err) + } + b64Item, err := adapter.imageDataItem(context.Background(), account.Credential{}, imagineImageValue{Blob: "aW1hZ2U="}, "b64_json") + if err != nil || b64Item["b64_json"] != "aW1hZ2U=" || b64Item["mime_type"] != "image/jpeg" { + t.Fatalf("base64 item = %#v, err=%v", b64Item, err) + } + png := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0, 'I', 'H', 'D', 'R'} + partial := openAIImageStreamEvent("image_generation.partial_image", imagineImageValue{ID: "image-a", Width: 960, Height: 960}, png, 0) + if partial["type"] != "image_generation.partial_image" || partial["partial_image_index"] != 0 || partial["size"] != "960x960" || partial["output_format"] != "png" || partial["usage"] != nil { + t.Fatalf("partial event = %#v", partial) + } + completed := openAIImageStreamEvent("image_generation.completed", imagineImageValue{ID: "image-a", Width: 960, Height: 960}, []byte("jpeg"), 0) + if completed["type"] != "image_generation.completed" || completed["partial_image_index"] != nil || completed["quality"] != "auto" || completed["usage"] != nil { + t.Fatalf("completed event = %#v", completed) + } + var output bytes.Buffer + if err := writeSSE(&output, "image_generation.partial_image", partial); err != nil { + t.Fatal(err) + } + value := output.String() + if !strings.Contains(value, "event: image_generation.partial_image") || strings.Contains(value, "image_generation.started") || strings.Contains(value, "image_generation.image.completed") || strings.Contains(value, `"usage"`) { + t.Fatalf("stream event = %q", value) + } +} + +func TestImageDataItemRetriesStorageWithoutRegenerating(t *testing.T) { + store := &imageAssetStoreRetryStub{failures: 2} + adapter := &Adapter{assets: store} + item, err := adapter.imageDataItem(context.Background(), account.Credential{ID: 42}, imagineImageValue{Blob: "aW1hZ2U="}, "url") + if err != nil { + t.Fatal(err) + } + if store.calls != mediaOutputAttempts || item["url"] != "https://api.example/v1/media/images/img_retry" { + t.Fatalf("storage retry calls=%d item=%#v", store.calls, item) + } +} + +func TestImageDataItemClassifiesExhaustedStorageFailure(t *testing.T) { + store := &imageAssetStoreRetryStub{failures: mediaOutputAttempts} + adapter := &Adapter{assets: store} + _, err := adapter.imageDataItem(context.Background(), account.Credential{ID: 42}, imagineImageValue{Blob: "aW1hZ2U="}, "url") + if err == nil || !provider.IsMediaPostProcessingError(err) || store.calls != mediaOutputAttempts { + t.Fatalf("storage failure err=%v calls=%d", err, store.calls) + } + var processingErr *provider.MediaPostProcessingError + if !errors.As(err, &processingErr) || processingErr.Stage != provider.MediaPostProcessingStorage { + t.Fatalf("storage failure classification = %#v", processingErr) + } +} + +type imageAssetStoreStub struct{} + +func (imageAssetStoreStub) SaveImage(context.Context, []byte) (mediadomain.Asset, error) { + return mediadomain.Asset{ID: "img_test", MIMEType: "image/jpeg"}, nil +} + +func (imageAssetStoreStub) PublicImageURL(string) string { + return "https://api.example/v1/media/images/img_test" +} + +type imageAssetStoreRetryStub struct { + failures int + calls int +} + +func (s *imageAssetStoreRetryStub) SaveImage(context.Context, []byte) (mediadomain.Asset, error) { + s.calls++ + if s.calls <= s.failures { + return mediadomain.Asset{}, errors.New("temporary storage failure") + } + return mediadomain.Asset{ID: "img_retry", MIMEType: "image/jpeg"}, nil +} + +func (*imageAssetStoreRetryStub) PublicImageURL(string) string { + return "https://api.example/v1/media/images/img_retry" +} + +func TestParseVideoStreamFixture(t *testing.T) { + fixture := `data: {"result":{"response":{"streamingVideoGenerationResponse":{"progress":42,"videoPostId":"post_1"}}}}` + "\n" + + `data: {"result":{"response":{"streamingVideoGenerationResponse":{"progress":100,"videoPostId":"post_1","videoUrl":"/videos/final.mp4"}}}}` + "\n" + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(fixture))} + progress := 0 + result, postID, err := parseVideoStream(response, func(value int) { progress = value }) + if err != nil { + t.Fatal(err) + } + if progress != 100 || postID != "post_1" || result.URL != "https://assets.grok.com/videos/final.mp4" || result.ContentType != "video/mp4" { + t.Fatalf("result = %#v, post = %q, progress = %d", result, postID, progress) + } +} + +func TestParseVideoStreamPreservesUpstreamStatus(t *testing.T) { + response := &http.Response{StatusCode: http.StatusTooManyRequests, Body: io.NopCloser(strings.NewReader("limited"))} + _, _, err := parseVideoStream(response, nil) + status, ok := provider.ErrorHTTPStatus(err) + if !ok || status != http.StatusTooManyRequests { + t.Fatalf("status = %d, ok = %v, err = %v", status, ok, err) + } +} + +func TestParseVideoConcatenatedJSONFixture(t *testing.T) { + fixture := `{"result":{"conversation":{"conversationId":"conversation_1"}}}` + + `{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"video_1","progress":1,"videoPostId":"post_1","resolutionName":"720p"}}}}` + + `{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"video_1","progress":95,"videoPostId":"post_1"}}}}` + + `{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"video_1","progress":100,"assetId":"video_1","videoPostId":"post_1","videoUrl":"users/user_1/generated/video_1/generated_video.mp4","thumbnailImageUrl":"users/user_1/generated/video_1/preview_image.jpg","moderated":false}}}}` + + `{"result":{"response":{"token":"I generated a video","isSoftStop":true}}}` + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(fixture))} + var values []int + result, postID, err := parseVideoStream(response, func(value int) { values = append(values, value) }) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(values, []int{1, 95, 100}) || postID != "post_1" || result.URL != "https://assets.grok.com/users/user_1/generated/video_1/generated_video.mp4" || result.ContentType != "video/mp4" { + t.Fatalf("result = %#v, post = %q, progress = %#v", result, postID, values) + } +} + +func TestParseVideoStreamUsesModelResponseAttachment(t *testing.T) { + fixture := `data: {"result":{"response":{"streamingVideoGenerationResponse":{"progress":100,"videoPostId":"post_1"},"modelResponse":{"fileAttachments":["users/user_1/generated/video_1/generated_video.mp4"]}}}}` + "\n" + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(fixture))} + result, postID, err := parseVideoStream(response, nil) + if err != nil { + t.Fatal(err) + } + if postID != "post_1" || result.URL != "https://assets.grok.com/users/user_1/generated/video_1/generated_video.mp4" || result.ContentType != "video/mp4" { + t.Fatalf("result = %#v, post = %q", result, postID) + } +} + +func MarshalJSONBytes(value any) []byte { + data, _ := json.Marshal(value) + return data +} diff --git a/backend/internal/infra/provider/web/quota.go b/backend/internal/infra/provider/web/quota.go new file mode 100644 index 0000000000000000000000000000000000000000..f7268d4346d02660ca0a93ae403d660a97a5e66e --- /dev/null +++ b/backend/internal/infra/provider/web/quota.go @@ -0,0 +1,490 @@ +package web + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "google.golang.org/protobuf/encoding/protowire" +) + +const weeklyQuotaMode = "weekly" + +func (a *Adapter) SyncQuota(ctx context.Context, credential account.Credential) (provider.QuotaSnapshot, error) { + windows := make([]account.QuotaWindow, 0, 2) + autoWindow, autoErr := a.SyncQuotaMode(ctx, credential, "auto") + if errors.Is(autoErr, provider.ErrUnauthorized) { + return provider.QuotaSnapshot{}, autoErr + } + if autoErr == nil { + windows = append(windows, autoWindow) + } + fastWindow, fastErr := a.SyncQuotaMode(ctx, credential, "fast") + if errors.Is(fastErr, provider.ErrUnauthorized) { + return provider.QuotaSnapshot{}, fastErr + } + if fastErr == nil { + windows = append(windows, fastWindow) + } + if len(windows) > 0 { + tier, _ := resolveWebTierFromQuota(credential.WebTier, windows, false) + // Basic/未知账号没有付费周池,避免为每次完整同步额外访问付费端点。 + // 只有模式额度已经确认付费等级时才读取 weekly 作为权威额度。 + if tier == account.WebTierSuper || tier == account.WebTierHeavy { + if weekly, weeklyErr := a.syncWeeklyCredits(ctx, credential); weeklyErr == nil { + windows = []account.QuotaWindow{weekly} + } + } + return provider.QuotaSnapshot{Tier: tier, Windows: windows, SyncedAt: time.Now().UTC()}, nil + } + // 模式端点暂不可用时,仅已确认的付费账号允许用 weekly 兜底; + // Basic/Auto 不能凭周额度探测提权,也不应制造无意义的付费端点流量。 + if credential.WebTier == account.WebTierSuper || credential.WebTier == account.WebTierHeavy { + if weekly, weeklyErr := a.syncWeeklyCredits(ctx, credential); weeklyErr == nil { + return provider.QuotaSnapshot{Tier: credential.WebTier, Windows: []account.QuotaWindow{weekly}, SyncedAt: time.Now().UTC()}, nil + } else { + return provider.QuotaSnapshot{}, weeklyErr + } + } + if fastErr != nil { + return provider.QuotaSnapshot{}, fastErr + } + return provider.QuotaSnapshot{}, autoErr +} + +func resolveWebTierFromQuota(current account.WebTier, windows []account.QuotaWindow, weeklyAvailable bool) (account.WebTier, bool) { + if len(windows) > 0 { + tier, known := inferWebTierFromQuota(windows) + if !known { + // 上游可能随时调整额度。无法识别的新形态保持 Auto,并由路由层 + // 按 Basic 的最小权限处理;不能伪造一个已确认的套餐等级。 + return account.WebTierAuto, false + } + return tier, weeklyAvailable && tier != account.WebTierBasic + } + // 周额度是付费账号信号,但无法区分 Super/Heavy。已确认的等级在模式 + // 额度暂时不可用时保留;未确认(Auto/Basic)的不能凭周额度提权。 + if current == account.WebTierHeavy || current == account.WebTierSuper { + return current, weeklyAvailable + } + return current, false +} + +// inferWebTierFromQuota 使用 Grok Web /rest/rate-limits 的真实额度形态判级。 +// 同一快照出现矛盾信号时选择较低等级,避免把 Basic 账号路由到付费能力。 +func inferWebTierFromQuota(windows []account.QuotaWindow) (account.WebTier, bool) { + detected := account.WebTierAuto + rank := map[account.WebTier]int{ + account.WebTierBasic: 1, + account.WebTierSuper: 2, + account.WebTierHeavy: 3, + } + for _, window := range windows { + candidate := account.WebTierAuto + switch window.Mode { + case "auto": + switch window.Total { + case 7, 20: + candidate = account.WebTierBasic + case 50: + candidate = account.WebTierSuper + case 150: + candidate = account.WebTierHeavy + } + case "fast": + switch window.Total { + case 30: + candidate = account.WebTierBasic + case 140: + candidate = account.WebTierSuper + case 400: + candidate = account.WebTierHeavy + } + case "heavy": + if window.Total > 0 { + candidate = account.WebTierHeavy + } + } + if candidate != account.WebTierAuto && (detected == account.WebTierAuto || rank[candidate] < rank[detected]) { + detected = candidate + } + } + return detected, detected != account.WebTierAuto +} + +func (a *Adapter) SyncQuotaMode(ctx context.Context, credential account.Credential, mode string) (account.QuotaWindow, error) { + if mode == weeklyQuotaMode { + return a.syncWeeklyCredits(ctx, credential) + } + cfg := a.config() + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return account.QuotaWindow{}, err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return account.QuotaWindow{}, err + } + defer lease.Release() + payload, _ := json.Marshal(map[string]string{"modelName": mode}) + requestCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.QuotaTimeoutSeconds)*time.Second) + defer cancel() + endpoint := cfg.BaseURL + "/rest/rate-limits" + var response *http.Response + var body []byte + for attempt := 0; attempt < 2; attempt++ { + request, requestErr := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if requestErr != nil { + return account.QuotaWindow{}, requestErr + } + request.Header = buildHeaders(token, lease, "application/json") + applyAppHeaders(request.Header, cfg.BaseURL, cfg.BaseURL+"/") + a.applySignedStatsig(requestCtx, request, token, lease) + response, err = lease.DoDeferredForbidden(request) + if err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + return account.QuotaWindow{}, err + } + body, err = io.ReadAll(io.LimitReader(response.Body, 4<<20)) + _ = response.Body.Close() + if err != nil { + return account.QuotaWindow{}, err + } + if response.StatusCode == http.StatusForbidden { + // Preserve definitive account-block signals before a Statsig retry can discard the first response. + if provider.IsDefinitiveAccountBlockBody(body) { + return account.QuotaWindow{}, fmt.Errorf("%w: account blocked", provider.ErrUnauthorized) + } + lease.InvalidateClearance() + if attempt == 0 && a.invalidateSignedStatsig(http.MethodPost, endpoint) { + continue + } + } + break + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + if response.StatusCode == http.StatusUnauthorized { + return account.QuotaWindow{}, provider.ErrUnauthorized + } + if response.StatusCode == http.StatusForbidden && provider.IsDefinitiveAccountBlockBody(body) { + return account.QuotaWindow{}, fmt.Errorf("%w: account blocked", provider.ErrUnauthorized) + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + return account.QuotaWindow{}, fmt.Errorf("Grok Web 额度接口返回 %d", response.StatusCode) + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + var value struct { + WindowSizeSeconds int `json:"windowSizeSeconds"` + RemainingQueries int `json:"remainingQueries"` + TotalQueries int `json:"totalQueries"` + } + if err := json.Unmarshal(body, &value); err != nil { + return account.QuotaWindow{}, err + } + if value.TotalQueries <= 0 { + return account.QuotaWindow{}, fmt.Errorf("Grok Web 额度响应缺少 totalQueries") + } + if value.WindowSizeSeconds <= 0 { + value.WindowSizeSeconds = 7200 + } + now := time.Now().UTC() + resetAt := now.Add(time.Duration(value.WindowSizeSeconds) * time.Second) + return account.QuotaWindow{ + AccountID: credential.ID, Mode: mode, Remaining: max(0, value.RemainingQueries), Total: value.TotalQueries, + WindowSeconds: value.WindowSizeSeconds, ResetAt: &resetAt, SyncedAt: &now, Source: account.QuotaSourceUpstream, UpdatedAt: now, + }, nil +} + +func (a *Adapter) syncWeeklyCredits(ctx context.Context, credential account.Credential) (account.QuotaWindow, error) { + cfg := a.config() + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return account.QuotaWindow{}, err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return account.QuotaWindow{}, err + } + defer lease.Release() + + requestCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.QuotaTimeoutSeconds)*time.Second) + defer cancel() + endpoint := cfg.BaseURL + "/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig" + request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader([]byte{0, 0, 0, 0, 0})) + if err != nil { + return account.QuotaWindow{}, err + } + request.Header = buildHeaders(token, lease, "application/grpc-web+proto") + applyAppHeaders(request.Header, cfg.BaseURL, cfg.BaseURL+"/") + request.Header.Del("x-xai-request-id") + request.Header.Set("x-grpc-web", "1") + request.Header.Set("x-user-agent", "connect-es/2.1.1") + + response, err := lease.DoDeferredForbidden(request) + if err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, 0, err) + return account.QuotaWindow{}, err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + if err != nil { + return account.QuotaWindow{}, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + if response.StatusCode == http.StatusUnauthorized { + return account.QuotaWindow{}, provider.ErrUnauthorized + } + if response.StatusCode == http.StatusForbidden && provider.IsDefinitiveAccountBlockBody(body) { + return account.QuotaWindow{}, fmt.Errorf("%w: account blocked", provider.ErrUnauthorized) + } + if response.StatusCode == http.StatusForbidden { + lease.InvalidateClearance() + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + return account.QuotaWindow{}, fmt.Errorf("Grok Web 周额度接口返回 %d", response.StatusCode) + } + window, err := parseWeeklyCreditsResponse(body, credential.ID, time.Now().UTC()) + if err != nil { + return account.QuotaWindow{}, err + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, response.StatusCode, nil) + return window, nil +} + +func parseWeeklyCreditsResponse(body []byte, accountID uint64, syncedAt time.Time) (account.QuotaWindow, error) { + payload, err := firstGRPCWebMessage(body) + if err != nil { + return account.QuotaWindow{}, err + } + config, err := protobufMessageField(payload, 1) + if err != nil { + return account.QuotaWindow{}, fmt.Errorf("解析 Grok Web 周额度响应: %w", err) + } + var usagePercent float64 + var usagePresent bool + var periodStart, periodEnd *time.Time + breakdown := make([]account.QuotaBreakdown, 0, 8) + for len(config) > 0 { + number, fieldType, n := protowire.ConsumeTag(config) + if n < 0 { + return account.QuotaWindow{}, fmt.Errorf("周额度 protobuf tag 无效") + } + config = config[n:] + switch { + case number == 1 && fieldType == protowire.Fixed32Type: + value, consumed := protowire.ConsumeFixed32(config) + if consumed < 0 { + return account.QuotaWindow{}, fmt.Errorf("周额度使用率无效") + } + usagePercent = float64(math.Float32frombits(value)) + usagePresent = true + config = config[consumed:] + case (number == 4 || number == 5) && fieldType == protowire.BytesType: + value, consumed := protowire.ConsumeBytes(config) + if consumed < 0 { + return account.QuotaWindow{}, fmt.Errorf("周额度周期无效") + } + parsed, parseErr := parseProtoTimestamp(value) + if parseErr != nil { + return account.QuotaWindow{}, parseErr + } + if number == 4 { + periodStart = &parsed + } else { + periodEnd = &parsed + } + config = config[consumed:] + case number == 7 && fieldType == protowire.BytesType: + value, consumed := protowire.ConsumeBytes(config) + if consumed < 0 { + return account.QuotaWindow{}, fmt.Errorf("周额度产品分解无效") + } + if item, ok := parseQuotaBreakdown(value); ok { + breakdown = append(breakdown, item) + } + config = config[consumed:] + default: + consumed := protowire.ConsumeFieldValue(number, fieldType, config) + if consumed < 0 { + return account.QuotaWindow{}, fmt.Errorf("周额度 protobuf 字段无效") + } + config = config[consumed:] + } + } + if usagePresent && (math.IsNaN(usagePercent) || math.IsInf(usagePercent, 0) || usagePercent < 0 || usagePercent > 100) { + return account.QuotaWindow{}, fmt.Errorf("Grok Web 周额度响应缺少有效使用率") + } + if periodStart == nil || periodEnd == nil || !periodEnd.After(*periodStart) { + return account.QuotaWindow{}, fmt.Errorf("Grok Web 周额度响应缺少有效周期") + } + if !usagePresent && periodStart.Nanosecond() == 0 && periodEnd.Nanosecond() == 0 { + // Free accounts return a coarse entitlement period without a usage rate. + // A paid, unused weekly pool has the same rate omitted but retains its + // precise period boundaries, which represents zero percent used. + return account.QuotaWindow{}, fmt.Errorf("Grok Web 周额度响应缺少有效使用率") + } + windowSeconds := int(periodEnd.Sub(*periodStart).Seconds()) + if windowSeconds < 24*60*60 || windowSeconds > 31*24*60*60 { + return account.QuotaWindow{}, fmt.Errorf("Grok Web 周额度周期长度异常") + } + usedBasisPoints := int(math.Round(usagePercent * 100)) + return account.QuotaWindow{ + AccountID: accountID, Mode: weeklyQuotaMode, Remaining: max(0, 10000-usedBasisPoints), Total: 10000, + UsagePercent: usagePercent, Breakdown: breakdown, WindowSeconds: windowSeconds, + ResetAt: periodEnd, SyncedAt: &syncedAt, Source: account.QuotaSourceUpstream, UpdatedAt: syncedAt, + }, nil +} + +func firstGRPCWebMessage(body []byte) ([]byte, error) { + message, grpcStatus, err := parseGRPCWebFrames(body) + if err != nil { + return nil, err + } + if grpcStatus != "" && grpcStatus != "0" { + return nil, fmt.Errorf("Grok Web 周额度 gRPC 状态为 %s", grpcStatus) + } + if message == nil { + return nil, fmt.Errorf("Grok Web 周额度响应缺少消息帧") + } + return message, nil +} + +func parseGRPCWebFrames(body []byte) ([]byte, string, error) { + var message []byte + grpcStatus := "" + for len(body) > 0 { + if len(body) < 5 { + return nil, "", fmt.Errorf("gRPC-Web 响应包含不完整帧头") + } + flag := body[0] + length := int(binary.BigEndian.Uint32(body[1:5])) + body = body[5:] + if length < 0 || length > len(body) { + return nil, "", fmt.Errorf("gRPC-Web 帧长度无效") + } + payload := body[:length] + body = body[length:] + if flag&0x80 == 0 { + if flag != 0 { + return nil, "", fmt.Errorf("不支持压缩的 gRPC-Web 响应") + } + if message == nil { + message = append([]byte(nil), payload...) + } + continue + } + for _, line := range bytes.Split(payload, []byte{'\n'}) { + name, value, ok := bytes.Cut(bytes.TrimSpace(line), []byte{':'}) + if ok && string(bytes.ToLower(bytes.TrimSpace(name))) == "grpc-status" { + grpcStatus = string(bytes.TrimSpace(value)) + } + } + } + return message, grpcStatus, nil +} + +func protobufMessageField(message []byte, target protowire.Number) ([]byte, error) { + for len(message) > 0 { + number, fieldType, n := protowire.ConsumeTag(message) + if n < 0 { + return nil, fmt.Errorf("protobuf tag 无效") + } + message = message[n:] + if number == target && fieldType == protowire.BytesType { + value, consumed := protowire.ConsumeBytes(message) + if consumed < 0 { + return nil, fmt.Errorf("protobuf message 无效") + } + return value, nil + } + consumed := protowire.ConsumeFieldValue(number, fieldType, message) + if consumed < 0 { + return nil, fmt.Errorf("protobuf 字段无效") + } + message = message[consumed:] + } + return nil, fmt.Errorf("protobuf 缺少字段 %d", target) +} + +func parseProtoTimestamp(message []byte) (time.Time, error) { + var seconds int64 + var nanos int32 + for len(message) > 0 { + number, fieldType, n := protowire.ConsumeTag(message) + if n < 0 { + return time.Time{}, fmt.Errorf("protobuf timestamp tag 无效") + } + message = message[n:] + if fieldType == protowire.VarintType && (number == 1 || number == 2) { + value, consumed := protowire.ConsumeVarint(message) + if consumed < 0 { + return time.Time{}, fmt.Errorf("protobuf timestamp 值无效") + } + if number == 1 { + seconds = int64(value) + } else { + nanos = int32(value) + } + message = message[consumed:] + continue + } + consumed := protowire.ConsumeFieldValue(number, fieldType, message) + if consumed < 0 { + return time.Time{}, fmt.Errorf("protobuf timestamp 字段无效") + } + message = message[consumed:] + } + if seconds <= 0 || nanos < 0 || nanos >= int32(time.Second) { + return time.Time{}, fmt.Errorf("protobuf timestamp 范围无效") + } + return time.Unix(seconds, int64(nanos)).UTC(), nil +} + +func parseQuotaBreakdown(message []byte) (account.QuotaBreakdown, bool) { + var result account.QuotaBreakdown + var codePresent bool + for len(message) > 0 { + number, fieldType, n := protowire.ConsumeTag(message) + if n < 0 { + return account.QuotaBreakdown{}, false + } + message = message[n:] + switch { + case number == 1 && fieldType == protowire.VarintType: + value, consumed := protowire.ConsumeVarint(message) + if consumed < 0 { + return account.QuotaBreakdown{}, false + } + result.ProductCode = int(value) + codePresent = true + message = message[consumed:] + case number == 2 && fieldType == protowire.Fixed32Type: + value, consumed := protowire.ConsumeFixed32(message) + if consumed < 0 { + return account.QuotaBreakdown{}, false + } + result.UsagePercent = float64(math.Float32frombits(value)) + message = message[consumed:] + default: + consumed := protowire.ConsumeFieldValue(number, fieldType, message) + if consumed < 0 { + return account.QuotaBreakdown{}, false + } + message = message[consumed:] + } + } + if !codePresent || result.ProductCode < 0 || result.UsagePercent < 0 || result.UsagePercent > 100 || math.IsNaN(result.UsagePercent) || math.IsInf(result.UsagePercent, 0) { + return account.QuotaBreakdown{}, false + } + return result, true +} diff --git a/backend/internal/infra/provider/web/quota_test.go b/backend/internal/infra/provider/web/quota_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a1ad19b0e017506d8bee38f3099e30a3dd4b9aa3 --- /dev/null +++ b/backend/internal/infra/provider/web/quota_test.go @@ -0,0 +1,442 @@ +package web + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "math" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const capturedWeeklyCreditsHex = "00000000630a610d0000304112001a00220c089abbccd2061080f2d1fc012a0c089ab0f1d2061080f2d1fc013a07080515000020413a070804150000803f3a020802421e0802120c089abbccd2061080f2d1fc011a0c089ab0f1d2061080f2d1fc01580162006801800000000f677270632d7374617475733a300d0a" + +func TestParseCapturedWeeklyCreditsResponse(t *testing.T) { + body, err := hex.DecodeString(capturedWeeklyCreditsHex) + if err != nil { + t.Fatal(err) + } + syncedAt := time.Date(2026, 7, 12, 14, 0, 0, 0, time.UTC) + window, err := parseWeeklyCreditsResponse(body, 42, syncedAt) + if err != nil { + t.Fatal(err) + } + if window.AccountID != 42 || window.Mode != weeklyQuotaMode || window.Total != 10000 || window.Remaining != 8900 || window.WindowSeconds != 7*24*60*60 { + t.Fatalf("window = %#v", window) + } + if math.Abs(window.UsagePercent-11) > 0.001 || window.ResetAt == nil || window.ResetAt.Unix() != 1784436762 { + t.Fatalf("usage/reset = %#v", window) + } + if len(window.Breakdown) != 3 || window.Breakdown[0].ProductCode != account.QuotaProductImagine || window.Breakdown[0].UsagePercent != 10 || window.Breakdown[1].ProductCode != account.QuotaProductChat || window.Breakdown[1].UsagePercent != 1 || window.Breakdown[2].ProductCode != account.QuotaProductBuild || window.Breakdown[2].UsagePercent != 0 { + t.Fatalf("breakdown = %#v", window.Breakdown) + } +} + +func TestParseUnusedPreciseWeeklyCreditsResponse(t *testing.T) { + body, err := hex.DecodeString("00000000480a4612001a00220c08c5d5d3d20610c0c7a1ee012a0c08c5caf8d20610c0c7a1ee01421e0802120c08c5d5d3d20610c0c7a1ee011a0c08c5caf8d20610c0c7a1ee01580162006801800000000f677270632d7374617475733a300d0a") + if err != nil { + t.Fatal(err) + } + window, err := parseWeeklyCreditsResponse(body, 42, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if window.Total != 10000 || window.Remaining != 10000 || window.UsagePercent != 0 || window.ResetAt == nil || window.ResetAt.Nanosecond() == 0 { + t.Fatalf("window = %#v", window) + } +} + +func TestParseCoarseWeeklyCreditsResponseRemainsUnavailable(t *testing.T) { + body, err := hex.DecodeString("00000000300a2e12001a0022060880a6b6d2062a0608809bdbd2064212080212060880a6b6d2061a0608809bdbd206580162006801800000000f677270632d7374617475733a300d0a") + if err != nil { + t.Fatal(err) + } + if _, err := parseWeeklyCreditsResponse(body, 42, time.Now().UTC()); err == nil { + t.Fatal("expected coarse period without usage to be rejected") + } +} + +func TestSyncQuotaFetchesWeeklyOnlyAfterPaidTierIsConfirmed(t *testing.T) { + weeklyBody, err := hex.DecodeString(capturedWeeklyCreditsHex) + if err != nil { + t.Fatal(err) + } + var weeklyCalls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig": + weeklyCalls.Add(1) + writer.Header().Set("Content-Type", "application/grpc-web+proto") + _, _ = writer.Write(weeklyBody) + case "/rest/rate-limits": + var payload struct { + ModelName string `json:"modelName"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("quota payload: %v", err) + } + total := map[string]int{"auto": 50, "fast": 140}[payload.ModelName] + _ = json.NewEncoder(writer).Encode(map[string]any{ + "windowSizeSeconds": 7200, "remainingQueries": total, "totalQueries": total, + }) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: "test-signature", + }, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + snapshot, err := adapter.SyncQuota(context.Background(), account.Credential{ID: 2, WebTier: account.WebTierAuto, EncryptedAccessToken: encrypted}) + if err != nil { + t.Fatal(err) + } + if snapshot.Tier != account.WebTierSuper || len(snapshot.Windows) != 1 || snapshot.Windows[0].Mode != weeklyQuotaMode || weeklyCalls.Load() != 1 { + t.Fatalf("snapshot = %#v, weekly calls = %d", snapshot, weeklyCalls.Load()) + } +} + +func TestSyncQuotaStopsAfterFirstUnauthorizedMode(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + http.Error(writer, "unauthorized", http.StatusUnauthorized) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("expired-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: "test-signature", + }, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + _, err = adapter.SyncQuota(context.Background(), account.Credential{ID: 3, WebTier: account.WebTierAuto, EncryptedAccessToken: encrypted}) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } + if calls.Load() != 1 { + t.Fatalf("unauthorized credential made %d quota requests", calls.Load()) + } +} + +func TestSyncQuotaBlockedForbiddenIsUnauthorized(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte(`{"code":7,"message":"User is blocked [WKE=unauthorized:blocked-user]","details":[]}`)) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("blocked-sso") + if err != nil { + t.Fatal(err) + } + egressRepository := &recordingWebEgressRepository{node: egressdomain.Node{ + ID: 1, Name: "web", Scope: egressdomain.ScopeWeb, Enabled: true, Health: 1, + }} + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: "test-signature", + }, infraegress.NewManager(egressRepository, cipher), cipher, nil, nil) + _, err = adapter.SyncQuota(context.Background(), account.Credential{ID: 4, WebTier: account.WebTierAuto, EncryptedAccessToken: encrypted}) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } + // manual Statsig mode does not invalidate/retry; SyncQuota stops after the first unauthorized mode. + if calls.Load() != 1 { + t.Fatalf("blocked credential made %d quota requests, want 1", calls.Load()) + } + if updates := egressRepository.UpdateCount(); updates != 0 { + t.Fatalf("blocked account changed egress health %d times", updates) + } +} + +func TestSyncQuotaBlockedForbiddenSkipsStatsigRetryInURLMode(t *testing.T) { + var rateLimitCalls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + // URL mode may also probe the base URL for Statsig meta; only count quota endpoint. + if request.URL.Path == "/rest/rate-limits" { + rateLimitCalls.Add(1) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte(`{"code":7,"message":"User is blocked [WKE=unauthorized:blocked-user]","details":[]}`)) + return + } + http.NotFound(writer, request) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("blocked-sso-url") + if err != nil { + t.Fatal(err) + } + // URL mode would invalidate Statsig and retry unless blocked body is classified first. + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "url", StatsigSignerURL: "https://signer.example/sign", + }, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + _, err = adapter.SyncQuota(context.Background(), account.Credential{ID: 6, WebTier: account.WebTierAuto, EncryptedAccessToken: encrypted}) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } + if rateLimitCalls.Load() != 1 { + t.Fatalf("blocked credential made %d rate-limits requests, want 1 (no Statsig retry)", rateLimitCalls.Load()) + } +} + +func TestSyncQuotaGenericForbiddenIsNotUnauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte(`{"message":"temporary rejection"}`)) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("generic-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: "test-signature", + }, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + _, err = adapter.SyncQuota(context.Background(), account.Credential{ID: 5, WebTier: account.WebTierAuto, EncryptedAccessToken: encrypted}) + if err == nil || errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want generic forbidden error", err) + } +} + +func TestSyncWeeklyCreditsBlockedForbiddenIsUnauthorized(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte(`{"code":"unauthorized:blocked-user","error":"User is blocked"}`)) + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("blocked-weekly-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: "test-signature", + }, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + _, err = adapter.SyncQuotaMode(context.Background(), account.Credential{ID: 7, EncryptedAccessToken: encrypted}, weeklyQuotaMode) + if !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } + if calls.Load() != 1 { + t.Fatalf("blocked weekly credential made %d requests, want 1", calls.Load()) + } +} + +func TestInferWebTierFromUpstreamQuota(t *testing.T) { + tests := []struct { + name string + windows []account.QuotaWindow + want account.WebTier + known bool + }{ + {name: "current basic", windows: []account.QuotaWindow{{Mode: "auto", Total: 7}, {Mode: "fast", Total: 30}}, want: account.WebTierBasic, known: true}, + {name: "legacy basic", windows: []account.QuotaWindow{{Mode: "auto", Total: 20}}, want: account.WebTierBasic, known: true}, + {name: "super", windows: []account.QuotaWindow{{Mode: "auto", Total: 50}, {Mode: "fast", Total: 140}}, want: account.WebTierSuper, known: true}, + {name: "heavy", windows: []account.QuotaWindow{{Mode: "auto", Total: 150}, {Mode: "fast", Total: 400}}, want: account.WebTierHeavy, known: true}, + {name: "heavy mode", windows: []account.QuotaWindow{{Mode: "heavy", Total: 20}}, want: account.WebTierHeavy, known: true}, + {name: "conflicting signal uses lower tier", windows: []account.QuotaWindow{{Mode: "auto", Total: 50}, {Mode: "fast", Total: 30}}, want: account.WebTierBasic, known: true}, + {name: "unknown", windows: []account.QuotaWindow{{Mode: "auto", Total: 9}, {Mode: "fast", Total: 31}}, want: account.WebTierAuto, known: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, known := inferWebTierFromQuota(test.windows) + if got != test.want || known != test.known { + t.Fatalf("tier = %q, known = %v, want %q/%v", got, known, test.want, test.known) + } + }) + } +} + +func TestResolveWebTierUsesFreshWebQuotaOverStoredTier(t *testing.T) { + basicWindows := []account.QuotaWindow{{Mode: "auto", Total: 7}, {Mode: "fast", Total: 30}} + for _, stored := range []account.WebTier{account.WebTierAuto, account.WebTierBasic, account.WebTierSuper, account.WebTierHeavy} { + tier, useWeekly := resolveWebTierFromQuota(stored, basicWindows, true) + if tier != account.WebTierBasic || useWeekly { + t.Fatalf("stored %q resolved to %q, weekly=%v", stored, tier, useWeekly) + } + } + + tier, useWeekly := resolveWebTierFromQuota(account.WebTierBasic, []account.QuotaWindow{{Mode: "auto", Total: 50}}, true) + if tier != account.WebTierSuper || !useWeekly { + t.Fatalf("super snapshot resolved to %q, weekly=%v", tier, useWeekly) + } + + tier, useWeekly = resolveWebTierFromQuota(account.WebTierHeavy, nil, true) + if tier != account.WebTierHeavy || !useWeekly { + t.Fatalf("heavy weekly fallback resolved to %q, weekly=%v", tier, useWeekly) + } + + tier, useWeekly = resolveWebTierFromQuota(account.WebTierSuper, []account.QuotaWindow{{Mode: "auto", Total: 9}}, true) + if tier != account.WebTierAuto || useWeekly { + t.Fatalf("unknown snapshot resolved to %q, weekly=%v", tier, useWeekly) + } + + tier, useWeekly = resolveWebTierFromQuota(account.WebTierSuper, nil, true) + if tier != account.WebTierSuper || !useWeekly { + t.Fatalf("super weekly fallback resolved to %q, weekly=%v", tier, useWeekly) + } + + tier, useWeekly = resolveWebTierFromQuota(account.WebTierBasic, nil, true) + if tier != account.WebTierBasic || useWeekly { + t.Fatalf("basic should not be promoted when modes unavailable: got %q, weekly=%v", tier, useWeekly) + } + + tier, useWeekly = resolveWebTierFromQuota(account.WebTierAuto, nil, true) + if tier != account.WebTierAuto || useWeekly { + t.Fatalf("auto should not be promoted when modes unavailable: got %q, weekly=%v", tier, useWeekly) + } +} + +type recordingWebEgressRepository struct { + mu sync.Mutex + node egressdomain.Node + updates int +} + +func (r *recordingWebEgressRepository) ListEgressNodes(_ context.Context, scope egressdomain.Scope, _ repository.SortQuery) ([]egressdomain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.node.Scope != scope { + return nil, nil + } + return []egressdomain.Node{r.node}, nil +} + +func (r *recordingWebEgressRepository) GetEgressNode(context.Context, uint64) (egressdomain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.node, nil +} + +func (r *recordingWebEgressRepository) CreateEgressNode(context.Context, egressdomain.Node) (egressdomain.Node, error) { + return egressdomain.Node{}, errors.New("unsupported") +} + +func (r *recordingWebEgressRepository) UpdateEgressNode(_ context.Context, value egressdomain.Node) (egressdomain.Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.node = value + r.updates++ + return value, nil +} + +func (r *recordingWebEgressRepository) DeleteEgressNode(context.Context, uint64) error { + return errors.New("unsupported") +} + +func (r *recordingWebEgressRepository) UpdateCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.updates +} + +func TestSyncQuotaCorrectsStoredSuperFromFreshWebQuota(t *testing.T) { + var weeklyCalls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig": + weeklyCalls.Add(1) + http.Error(writer, "not available", http.StatusNotFound) + case "/rest/rate-limits": + var payload struct { + ModelName string `json:"modelName"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("quota payload: %v", err) + } + total := 0 + switch payload.ModelName { + case "auto": + total = 7 + case "fast": + total = 30 + default: + http.Error(writer, "unsupported mode", http.StatusBadRequest) + return + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "windowSizeSeconds": 7200, "remainingQueries": total, "totalQueries": total, + }) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("test-sso") + if err != nil { + t.Fatal(err) + } + adapter := NewAdapter(Config{ + BaseURL: server.URL, StatsigMode: "manual", StatsigManualValue: "test-signature", + }, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + snapshot, err := adapter.SyncQuota(context.Background(), account.Credential{ + ID: 1, WebTier: account.WebTierSuper, EncryptedAccessToken: encrypted, + }) + if err != nil { + t.Fatal(err) + } + if snapshot.Tier != account.WebTierBasic || len(snapshot.Windows) != 2 || snapshot.Windows[0].Mode != "auto" || snapshot.Windows[0].Total != 7 || snapshot.Windows[1].Mode != "fast" || snapshot.Windows[1].Total != 30 { + t.Fatalf("snapshot = %#v", snapshot) + } + if weeklyCalls.Load() != 0 { + t.Fatalf("basic account probed weekly endpoint %d times", weeklyCalls.Load()) + } +} diff --git a/backend/internal/infra/provider/web/security_test.go b/backend/internal/infra/provider/web/security_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6b263c74110e67d9987d7e299f69071cc5375776 --- /dev/null +++ b/backend/internal/infra/provider/web/security_test.go @@ -0,0 +1,79 @@ +package web + +import ( + "net/http" + "regexp" + "strings" + "testing" + + application "github.com/chenyme/grok2api/backend/internal/application/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" +) + +var forbiddenBrowserIdentityFields = []string{ + "grok_device_id", "x-anonuserid", "x-userid", "x-challenge", "x-signature", +} + +func TestCloudflareCookieWhitelistDropsBrowserIdentityFields(t *testing.T) { + raw := "cf_clearance=clear; __cf_bm=bm; _cfuvid=uv; cf_chl_2=challenge; grok_device_id=device; x-anonuserid=anon; x-userid=user; x-challenge=c; x-signature=s; unrelated=value" + sanitized := application.SanitizeCloudflareCookies(raw) + for _, expected := range []string{"cf_clearance=clear", "__cf_bm=bm", "_cfuvid=uv", "cf_chl_2=challenge"} { + if !strings.Contains(sanitized, expected) { + t.Fatalf("sanitized cookies missing %q: %s", expected, sanitized) + } + } + assertForbiddenFieldsAbsent(t, sanitized) +} + +func TestWebHeadersOnlyUseSSOAndCloudflareCookies(t *testing.T) { + lease := &infraegress.Lease{UserAgent: "test-agent", CFCookies: "cf_clearance=clear; x-userid=drop"} + headers := buildHeaders("sso=token-value; x-userid=drop; x-signature=drop", lease, "application/json") + cookie := headers.Get("Cookie") + if !strings.Contains(cookie, "sso=token-value") || !strings.Contains(cookie, "sso-rw=token-value") || !strings.Contains(cookie, "cf_clearance=clear") { + t.Fatalf("cookie header = %q", cookie) + } + assertForbiddenFieldsAbsent(t, cookie) + for name := range headers { + assertForbiddenFieldsAbsent(t, name) + } + for _, name := range []string{"Accept", "Accept-Language", "x-xai-request-id"} { + if headers.Get(name) == "" { + t.Fatalf("missing header %s", name) + } + } + if value := headers.Get("x-statsig-id"); value != "" { + t.Fatalf("unsigned base headers must not contain x-statsig-id: %q", value) + } + if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(headers.Get("x-xai-request-id")) { + t.Fatalf("x-xai-request-id = %q", headers.Get("x-xai-request-id")) + } + for name := range headers { + if strings.HasPrefix(strings.ToLower(name), "sec-ch-ua") { + t.Fatalf("synthetic client hint must not be sent: %s", name) + } + } +} + +func TestAppHeadersMatchStableBrowserFetchSignals(t *testing.T) { + headers := http.Header{} + applyAppHeaders(headers, "https://grok.com", "https://grok.com/") + for name, expected := range map[string]string{ + "Origin": "https://grok.com", "Referer": "https://grok.com/", "Cache-Control": "no-cache", + "Pragma": "no-cache", "Priority": "u=1, i", "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", + } { + if headers.Get(name) != expected { + t.Fatalf("%s = %q", name, headers.Get(name)) + } + } +} + +func assertForbiddenFieldsAbsent(t *testing.T, value string) { + t.Helper() + lower := strings.ToLower(value) + for _, forbidden := range forbiddenBrowserIdentityFields { + if strings.Contains(lower, forbidden) { + t.Fatalf("forbidden browser identity field %q found in %q", forbidden, value) + } + } +} diff --git a/backend/internal/infra/provider/web/sso_build.go b/backend/internal/infra/provider/web/sso_build.go new file mode 100644 index 0000000000000000000000000000000000000000..b46ffc68e360a53ca3c972977b28f29dc8758c54 --- /dev/null +++ b/backend/internal/infra/provider/web/sso_build.go @@ -0,0 +1,382 @@ +package web + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const ( + ssoBuildClientID = "b1a00492-073a-47ea-816f-4c329264a828" + ssoBuildScope = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write" + ssoAccountsURL = "https://accounts.x.ai/" + ssoDeviceURL = "https://auth.x.ai/oauth2/device/code" + ssoVerifyURL = "https://auth.x.ai/oauth2/device/verify" + ssoApproveURL = "https://auth.x.ai/oauth2/device/approve" + ssoTokenURL = "https://auth.x.ai/oauth2/token" + maxAuthBody = 2 << 20 +) + +type ssoBuildHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +type ssoBuildFlow struct { + client ssoBuildHTTPClient + userAgent string + cookies map[string]string +} + +func (a *Adapter) ConvertToBuild(ctx context.Context, credential accountdomain.Credential) (provider.CredentialSeed, error) { + if credential.Provider != accountdomain.ProviderWeb || credential.AuthType != accountdomain.AuthTypeSSO { + return provider.CredentialSeed{}, fmt.Errorf("仅 Grok Web SSO 账号支持转换") + } + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return provider.CredentialSeed{}, fmt.Errorf("解密 Grok Web SSO: %w", err) + } + token = normalizeSSOToken(token) + if token == "" { + return provider.CredentialSeed{}, provider.ErrUnauthorized + } + lease, err := a.egress.AcquireCredential(ctx, egressdomain.ScopeWeb, credential) + if err != nil { + return provider.CredentialSeed{}, err + } + defer lease.Release() + requestCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + flow := &ssoBuildFlow{ + client: lease, userAgent: lease.UserAgent, + cookies: map[string]string{"sso": token, "sso-rw": token}, + } + seed, err := flow.convert(requestCtx, credential) + if err != nil { + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, conversionStatus(err), err) + return provider.CredentialSeed{}, err + } + a.egress.Feedback(context.WithoutCancel(ctx), lease.NodeID, http.StatusOK, nil) + return seed, nil +} + +func (f *ssoBuildFlow) convert(ctx context.Context, credential accountdomain.Credential) (provider.CredentialSeed, error) { + status, finalURL, _, err := f.do(ctx, http.MethodGet, ssoAccountsURL, nil) + if err != nil { + return provider.CredentialSeed{}, err + } + if status == http.StatusUnauthorized || strings.Contains(finalURL, "sign-in") || strings.Contains(finalURL, "sign-up") { + return provider.CredentialSeed{}, provider.ErrUnauthorized + } + if status < 200 || status >= 400 { + return provider.CredentialSeed{}, fmt.Errorf("校验 Grok Web SSO 失败: %w", conversionHTTPError{status: status}) + } + + form := url.Values{"client_id": {ssoBuildClientID}, "scope": {ssoBuildScope}} + status, _, body, err := f.do(ctx, http.MethodPost, ssoDeviceURL, form) + if err != nil { + return provider.CredentialSeed{}, err + } + if status < 200 || status >= 300 { + return provider.CredentialSeed{}, fmt.Errorf("xAI Device Flow 启动失败: %w", conversionHTTPError{status: status}) + } + var device struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURIComplete string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &device); err != nil { + return provider.CredentialSeed{}, fmt.Errorf("解析 xAI Device Flow: %w", err) + } + if device.DeviceCode == "" || device.UserCode == "" || !safeXAIURL(device.VerificationURIComplete) { + return provider.CredentialSeed{}, fmt.Errorf("xAI Device Flow 返回字段不完整") + } + if device.Interval <= 0 { + device.Interval = 5 + } + if device.ExpiresIn <= 0 { + device.ExpiresIn = 1800 + } + + status, finalURL, _, err = f.do(ctx, http.MethodGet, device.VerificationURIComplete, nil) + if err != nil { + return provider.CredentialSeed{}, err + } + if status < 200 || status >= 400 { + return provider.CredentialSeed{}, fmt.Errorf("打开 Device Flow 验证页失败: %w", conversionHTTPError{status: status}) + } + status, finalURL, _, err = f.do(ctx, http.MethodPost, ssoVerifyURL, url.Values{"user_code": {device.UserCode}}) + if err != nil { + return provider.CredentialSeed{}, err + } + if status < 200 || status >= 400 { + return provider.CredentialSeed{}, fmt.Errorf("SSO 自动验证 Device Flow 失败: %w", conversionHTTPError{status: status}) + } + if !strings.Contains(finalURL, "consent") { + return provider.CredentialSeed{}, fmt.Errorf("SSO 自动验证 Device Flow 失败") + } + status, finalURL, _, err = f.do(ctx, http.MethodPost, ssoApproveURL, url.Values{ + "user_code": {device.UserCode}, "action": {"allow"}, "principal_type": {"User"}, "principal_id": {""}, + }) + if err != nil { + return provider.CredentialSeed{}, err + } + if status < 200 || status >= 400 { + return provider.CredentialSeed{}, fmt.Errorf("SSO 自动批准 Device Flow 失败: %w", conversionHTTPError{status: status}) + } + if !strings.Contains(finalURL, "done") { + return provider.CredentialSeed{}, fmt.Errorf("SSO 自动批准 Device Flow 失败") + } + + token, err := f.pollToken(ctx, device.DeviceCode, time.Duration(device.Interval)*time.Second, time.Duration(device.ExpiresIn)*time.Second) + if err != nil { + return provider.CredentialSeed{}, err + } + claims := decodeBuildClaims(firstValue(token.IDToken, token.AccessToken)) + userID := claimString(claims, "sub") + email := claimString(claims, "email") + teamID := claimString(claims, "team_id") + name := strings.TrimSpace(credential.Name) + if name == "" { + name = "Grok Web account" + } + return provider.CredentialSeed{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, + Name: firstValue(email, name+" Build", userID, "Grok Build account"), Email: email, UserID: userID, TeamID: teamID, + SourceKey: "sso-build:" + security.HashToken(token.AccessToken), OIDCClientID: ssoBuildClientID, + AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.ExpiresAt, + }, nil +} + +type ssoBuildToken struct { + AccessToken string + RefreshToken string + IDToken string + ExpiresAt time.Time +} + +func (f *ssoBuildFlow) pollToken(ctx context.Context, deviceCode string, interval, expiresIn time.Duration) (ssoBuildToken, error) { + if interval < time.Second { + interval = time.Second + } + deadline := time.Now().Add(min(expiresIn, 75*time.Second)) + for time.Now().Before(deadline) { + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return ssoBuildToken{}, ctx.Err() + case <-timer.C: + } + status, _, body, err := f.do(ctx, http.MethodPost, ssoTokenURL, url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "client_id": {ssoBuildClientID}, "device_code": {deviceCode}, + }) + if err != nil { + return ssoBuildToken{}, err + } + var payload struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return ssoBuildToken{}, fmt.Errorf("解析 xAI OAuth Token: %w", err) + } + if status >= 200 && status < 300 && payload.AccessToken != "" { + if payload.ExpiresIn <= 0 { + payload.ExpiresIn = 3600 + } + return ssoBuildToken{AccessToken: payload.AccessToken, RefreshToken: payload.RefreshToken, IDToken: payload.IDToken, ExpiresAt: time.Now().UTC().Add(time.Duration(payload.ExpiresIn) * time.Second)}, nil + } + switch payload.Error { + case "authorization_pending": + continue + case "slow_down": + interval += 5 * time.Second + continue + case "access_denied", "expired_token": + return ssoBuildToken{}, provider.ErrAuthorizationDenied + default: + if status >= 400 { + return ssoBuildToken{}, fmt.Errorf("xAI OAuth Token 失败 (%s): %w", firstValue(payload.ErrorDescription, payload.Error), conversionHTTPError{status: status}) + } + return ssoBuildToken{}, fmt.Errorf("xAI OAuth Token 失败: %s", firstValue(payload.ErrorDescription, payload.Error, strconv.Itoa(status))) + } + } + return ssoBuildToken{}, fmt.Errorf("xAI Device Flow 轮询超时") +} + +func (f *ssoBuildFlow) do(ctx context.Context, method, endpoint string, form url.Values) (int, string, []byte, error) { + if !safeXAIURL(endpoint) { + return 0, "", nil, fmt.Errorf("xAI OAuth URL 不安全") + } + currentURL := endpoint + currentMethod := method + currentForm := form + for redirects := 0; redirects <= 8; redirects++ { + var body io.Reader + if currentForm != nil { + body = strings.NewReader(currentForm.Encode()) + } + request, err := http.NewRequestWithContext(ctx, currentMethod, currentURL, body) + if err != nil { + return 0, "", nil, err + } + request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8") + request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + request.Header.Set("User-Agent", f.userAgent) + request.Header.Set("Cookie", f.cookieHeader()) + if currentForm != nil { + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + response, err := f.client.Do(request) + if err != nil { + return 0, "", nil, err + } + f.captureCookies(response) + data, readErr := io.ReadAll(io.LimitReader(response.Body, maxAuthBody+1)) + _ = response.Body.Close() + if readErr != nil { + return response.StatusCode, currentURL, nil, readErr + } + if len(data) > maxAuthBody { + return response.StatusCode, currentURL, nil, fmt.Errorf("xAI OAuth 响应超过 2 MiB") + } + if response.StatusCode < 300 || response.StatusCode > 399 { + return response.StatusCode, currentURL, data, nil + } + location := strings.TrimSpace(response.Header.Get("Location")) + if location == "" { + return response.StatusCode, currentURL, data, fmt.Errorf("xAI OAuth 重定向缺少 Location") + } + base, _ := url.Parse(currentURL) + next, err := url.Parse(location) + if err != nil { + return response.StatusCode, currentURL, data, err + } + currentURL = base.ResolveReference(next).String() + if !safeXAIURL(currentURL) { + return response.StatusCode, currentURL, data, fmt.Errorf("xAI OAuth 重定向到非受信域名") + } + if response.StatusCode == http.StatusSeeOther || ((response.StatusCode == http.StatusMovedPermanently || response.StatusCode == http.StatusFound) && currentMethod != http.MethodGet && currentMethod != http.MethodHead) { + currentMethod = http.MethodGet + currentForm = nil + } + } + return 0, currentURL, nil, fmt.Errorf("xAI OAuth 重定向次数过多") +} + +func (f *ssoBuildFlow) captureCookies(response *http.Response) { + for _, cookie := range response.Cookies() { + name := strings.TrimSpace(cookie.Name) + value := strings.TrimSpace(cookie.Value) + if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") { + continue + } + if cookie.MaxAge < 0 { + delete(f.cookies, name) + continue + } + f.cookies[name] = value + } +} + +func (f *ssoBuildFlow) cookieHeader() string { + keys := make([]string, 0, len(f.cookies)) + for key := range f.cookies { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+f.cookies[key]) + } + return strings.Join(parts, "; ") +} + +func safeXAIURL(raw string) bool { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" { + return false + } + host := strings.ToLower(parsed.Hostname()) + return host == "x.ai" || strings.HasSuffix(host, ".x.ai") +} + +func normalizeSSOToken(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "sso=") { + value = strings.TrimSpace(value[len("sso="):]) + } + if token, _, found := strings.Cut(value, ";"); found { + value = strings.TrimSpace(token) + } + return strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(value) +} + +func decodeBuildClaims(token string) map[string]any { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil + } + data, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + var claims map[string]any + if json.Unmarshal(data, &claims) != nil { + return nil + } + return claims +} + +func claimString(claims map[string]any, key string) string { + value, _ := claims[key].(string) + return strings.TrimSpace(value) +} + +func firstValue(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +type conversionHTTPError struct{ status int } + +func (e conversionHTTPError) Error() string { return fmt.Sprintf("xAI OAuth HTTP %d", e.status) } + +func conversionStatus(err error) int { + var statusErr conversionHTTPError + if errors.As(err, &statusErr) { + return statusErr.status + } + if errors.Is(err, provider.ErrUnauthorized) { + return http.StatusUnauthorized + } + return 0 +} + +var _ provider.BuildCredentialConverter = (*Adapter)(nil) +var _ ssoBuildHTTPClient = (*infraegress.Lease)(nil) diff --git a/backend/internal/infra/provider/web/sso_build_test.go b/backend/internal/infra/provider/web/sso_build_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0e4e54f4615d89756aecbbd1f6ccb4b4a38a7693 --- /dev/null +++ b/backend/internal/infra/provider/web/sso_build_test.go @@ -0,0 +1,65 @@ +package web + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type scriptedSSOClient struct { + responses []*http.Response + requests []*http.Request +} + +func (c *scriptedSSOClient) Do(request *http.Request) (*http.Response, error) { + c.requests = append(c.requests, request) + response := c.responses[0] + c.responses = c.responses[1:] + return response, nil +} + +func TestSSOBuildFlowFollowsOnlyTrustedXAIHTTPSRedirects(t *testing.T) { + client := &scriptedSSOClient{responses: []*http.Response{ + {StatusCode: http.StatusFound, Header: http.Header{"Location": []string{"https://auth.x.ai/next"}, "Set-Cookie": []string{"session=abc; Path=/; Secure"}}, Body: io.NopCloser(strings.NewReader(""))}, + {StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("ok"))}, + }} + flow := &ssoBuildFlow{client: client, userAgent: "test-agent", cookies: map[string]string{"sso": "secret"}} + status, finalURL, body, err := flow.do(context.Background(), http.MethodGet, ssoAccountsURL, nil) + if err != nil { + t.Fatal(err) + } + if status != http.StatusOK || finalURL != "https://auth.x.ai/next" || string(body) != "ok" { + t.Fatalf("response = %d %s %q", status, finalURL, body) + } + if len(client.requests) != 2 || client.requests[1].Header.Get("User-Agent") != "test-agent" { + t.Fatalf("requests = %#v", client.requests) + } + cookie := client.requests[1].Header.Get("Cookie") + if !strings.Contains(cookie, "sso=secret") || !strings.Contains(cookie, "session=abc") { + t.Fatalf("redirect cookies = %q", cookie) + } + + unsafe := &scriptedSSOClient{responses: []*http.Response{{StatusCode: http.StatusFound, Header: http.Header{"Location": []string{"https://example.com/steal"}}, Body: io.NopCloser(strings.NewReader(""))}}} + flow = &ssoBuildFlow{client: unsafe, userAgent: "test-agent", cookies: map[string]string{"sso": "secret"}} + if _, _, _, err := flow.do(context.Background(), http.MethodGet, ssoAccountsURL, nil); err == nil { + t.Fatal("unsafe redirect was accepted") + } +} + +func TestSSOBuildConversionSanitizesTokenAndURLs(t *testing.T) { + if token := normalizeSSOToken("sso=token-value; x-userid=drop"); token != "token-value" { + t.Fatalf("token = %q", token) + } + for _, value := range []string{"https://accounts.x.ai/", "https://auth.x.ai/oauth2/device/code"} { + if !safeXAIURL(value) { + t.Fatalf("trusted URL rejected: %s", value) + } + } + for _, value := range []string{"http://auth.x.ai/", "https://x.ai.example.com/", "https://user@auth.x.ai/"} { + if safeXAIURL(value) { + t.Fatalf("unsafe URL accepted: %s", value) + } + } +} diff --git a/backend/internal/infra/provider/web/statsig.go b/backend/internal/infra/provider/web/statsig.go new file mode 100644 index 0000000000000000000000000000000000000000..3f3e85fd7f7fd51d5356c8c20398c638167548ba --- /dev/null +++ b/backend/internal/infra/provider/web/statsig.go @@ -0,0 +1,433 @@ +package web + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/pkg/signerurl" + "golang.org/x/net/html" + "golang.org/x/sync/singleflight" +) + +const ( + defaultStatsigSignerURL = "https://grok.wodf.de/sign" + statsigCacheTTL = time.Hour + statsigCacheMaxEntries = 4096 + statsigMetaBodyLimit = 4 << 20 + statsigResponseLimit = 4 << 10 +) + +type statsigCacheEntry struct { + value string + expiresAt time.Time +} + +type statsigSignResult struct { + value string + source string +} + +type statsigWarmTarget struct { + method string + target string +} + +type statsigSigner struct { + client *http.Client + fetchMeta func(context.Context, string, string, *infraegress.Lease) (string, error) + validateEndpoint func(context.Context, string) error + now func() time.Time + mu sync.Mutex + entries map[string]statsigCacheEntry + refreshes singleflight.Group +} + +func newStatsigSigner() *statsigSigner { + return &statsigSigner{ + client: &http.Client{ + Timeout: 12 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + }, + fetchMeta: fetchStatsigMetaContent, + validateEndpoint: validateStatsigSignerEndpoint, + now: time.Now, + entries: make(map[string]statsigCacheEntry), + } +} + +func (s *statsigSigner) Sign(ctx context.Context, baseURL, signerURL, token string, lease *infraegress.Lease, method, target string) (string, string, error) { + key, path, err := statsigSignatureKey(baseURL, signerURL, method, target) + if err != nil { + return "", "", err + } + if value, ok := s.cached(key, s.now().UTC()); ok { + return value, "cache", nil + } + value, err, _ := s.refreshes.Do(key, func() (any, error) { + now := s.now().UTC() + if cached, ok := s.cached(key, now); ok { + return statsigSignResult{value: cached, source: "cache"}, nil + } + fresh, refreshErr := s.freshSignature(ctx, baseURL, signerURL, token, lease, method, path) + if refreshErr != nil { + if stale, ok := s.stale(key); ok { + return statsigSignResult{value: stale, source: "stale"}, nil + } + return statsigSignResult{}, refreshErr + } + s.store(key, fresh, now.Add(statsigCacheTTL), now) + return statsigSignResult{value: fresh, source: "refresh"}, nil + }) + if err != nil { + return "", "", err + } + result := value.(statsigSignResult) + return result.value, result.source, nil +} + +// Warm 使用一次 metaContent 请求预热多个常用签名键,避免按账号或按路径重复抓取首页。 +func (s *statsigSigner) Warm(ctx context.Context, baseURL, signerURL, token string, lease *infraegress.Lease, targets []statsigWarmTarget) (int, error) { + now := s.now().UTC() + type pendingTarget struct { + key string + method string + path string + } + pending := make([]pendingTarget, 0, len(targets)) + for _, target := range targets { + key, path, err := statsigSignatureKey(baseURL, signerURL, target.method, target.target) + if err != nil { + return 0, err + } + if _, ok := s.cached(key, now); ok { + continue + } + pending = append(pending, pendingTarget{key: key, method: target.method, path: path}) + } + if len(pending) == 0 { + return 0, nil + } + meta, err := s.fetchMeta(ctx, baseURL, token, lease) + if err != nil { + return 0, err + } + warmed := 0 + for _, target := range pending { + value, signErr := s.requestSignature(ctx, signerURL, target.method, target.path, meta) + if signErr != nil { + return warmed, signErr + } + s.store(target.key, value, now.Add(statsigCacheTTL), now) + warmed++ + } + return warmed, nil +} + +func (s *statsigSigner) freshSignature(ctx context.Context, baseURL, signerURL, token string, lease *infraegress.Lease, method, path string) (string, error) { + meta, err := s.fetchMeta(ctx, baseURL, token, lease) + if err != nil { + return "", err + } + signature, err := s.requestSignature(ctx, signerURL, method, path, meta) + if err == nil { + return signature, nil + } + + meta, refreshErr := s.fetchMeta(ctx, baseURL, token, lease) + if refreshErr != nil { + return "", fmt.Errorf("刷新 Statsig metaContent: %w", refreshErr) + } + signature, retryErr := s.requestSignature(ctx, signerURL, method, path, meta) + if retryErr != nil { + return "", fmt.Errorf("Statsig 签名失败: %w", retryErr) + } + return signature, nil +} + +func (s *statsigSigner) Invalidate(baseURL, signerURL, method, target string) { + key, _, err := statsigSignatureKey(baseURL, signerURL, method, target) + if err != nil { + return + } + s.mu.Lock() + delete(s.entries, key) + s.mu.Unlock() +} + +func (s *statsigSigner) Clear() { + s.mu.Lock() + clear(s.entries) + s.mu.Unlock() +} + +func (s *statsigSigner) cached(key string, now time.Time) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.entries[key] + if !ok || entry.value == "" || !now.Before(entry.expiresAt) { + return "", false + } + return entry.value, true +} + +func (s *statsigSigner) stale(key string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.entries[key] + return entry.value, ok && validStatsigID(entry.value) +} + +func (s *statsigSigner) store(key, value string, expiresAt, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + for existingKey, entry := range s.entries { + if !now.Before(entry.expiresAt) { + delete(s.entries, existingKey) + } + } + if len(s.entries) >= statsigCacheMaxEntries { + oldestKey := "" + var oldestExpiry time.Time + for existingKey, entry := range s.entries { + if oldestKey == "" || entry.expiresAt.Before(oldestExpiry) { + oldestKey, oldestExpiry = existingKey, entry.expiresAt + } + } + delete(s.entries, oldestKey) + } + s.entries[key] = statsigCacheEntry{value: value, expiresAt: expiresAt} +} + +func statsigSignatureKey(baseURL, signerURL, method, target string) (string, string, error) { + parsed, err := url.Parse(target) + if err != nil { + return "", "", fmt.Errorf("解析 Statsig 目标地址: %w", err) + } + path := parsed.EscapedPath() + if path == "" { + path = "/" + } + method = strings.ToUpper(strings.TrimSpace(method)) + return strings.TrimRight(baseURL, "/") + "\x00" + strings.TrimSpace(signerURL) + "\x00" + method + "\x00" + path, path, nil +} + +func (s *statsigSigner) requestSignature(ctx context.Context, endpoint, method, path, metaContent string) (string, error) { + if err := s.validateEndpoint(ctx, endpoint); err != nil { + return "", err + } + payload, _ := json.Marshal(map[string]any{ + "method": strings.ToUpper(strings.TrimSpace(method)), + "path": path, + "environment": map[string]string{ + "metaContent": metaContent, + }, + }) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + response, err := s.client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, statsigResponseLimit+1)) + if err != nil { + return "", err + } + if len(body) > statsigResponseLimit { + return "", fmt.Errorf("签名响应超过安全上限") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("签名服务返回 %d", response.StatusCode) + } + var value struct { + StatsigID string `json:"x-statsig-id"` + } + if json.Unmarshal(body, &value) != nil || !validStatsigID(value.StatsigID) { + return "", fmt.Errorf("签名服务响应无效") + } + return value.StatsigID, nil +} + +func validateStatsigSignerEndpoint(ctx context.Context, endpoint string) error { + _ = ctx + return signerurl.Validate(endpoint) +} + +func fetchStatsigMetaContent(ctx context.Context, baseURL, token string, lease *infraegress.Lease) (string, error) { + if lease == nil { + return "", fmt.Errorf("Statsig 获取缺少出口租约") + } + requestCtx, cancel := context.WithTimeout(infraegress.WithPhysicalCallStage(ctx, "statsig_meta"), 15*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/index", nil) + if err != nil { + return "", err + } + request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + request.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + request.Header.Set("Cache-Control", "no-cache") + request.Header.Set("Pragma", "no-cache") + request.Header.Set("Sec-Fetch-Dest", "document") + request.Header.Set("Sec-Fetch-Mode", "navigate") + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.Header.Set("Upgrade-Insecure-Requests", "1") + request.Header.Set("User-Agent", lease.UserAgent) + request.Header.Set("Cookie", infraegress.BuildSSOCookie(token, lease.CFCookies)) + response, err := lease.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("Grok index 返回 %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, statsigMetaBodyLimit+1)) + if err != nil { + return "", err + } + if len(body) > statsigMetaBodyLimit { + return "", fmt.Errorf("Grok index 超过安全上限") + } + content, err := extractStatsigMetaContent(body) + if err != nil { + return "", err + } + return content, nil +} + +func extractStatsigMetaContent(body []byte) (string, error) { + tokenizer := html.NewTokenizer(bytes.NewReader(body)) + for { + switch tokenizer.Next() { + case html.ErrorToken: + if tokenizer.Err() == io.EOF { + return "", fmt.Errorf("Grok index 缺少 grok-site-verification") + } + return "", tokenizer.Err() + case html.StartTagToken, html.SelfClosingTagToken: + name, hasAttrs := tokenizer.TagName() + if !strings.EqualFold(string(name), "meta") || !hasAttrs { + continue + } + metaName := "" + content := "" + for { + key, value, more := tokenizer.TagAttr() + switch strings.ToLower(string(key)) { + case "name": + metaName = normalizeStatsigMetaName(string(value)) + case "content": + content = strings.TrimSpace(string(value)) + } + if !more { + break + } + } + if metaName == "grok-site-verification" && content != "" { + return content, nil + } + } + } +} + +func normalizeStatsigMetaName(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + return strings.NewReplacer("‐", "-", "‑", "-", "‒", "-", "–", "-", "—", "-", "―", "-").Replace(value) +} + +func validStatsigID(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + decoded, err := base64.RawStdEncoding.DecodeString(value) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(value) + } + return err == nil && len(decoded) == 70 +} + +func (a *Adapter) applySignedStatsig(ctx context.Context, request *http.Request, token string, lease *infraegress.Lease) { + if request == nil { + return + } + cfg := a.config() + request.Header.Del("x-statsig-id") + if cfg.StatsigMode == "manual" { + if value := strings.TrimSpace(cfg.StatsigManualValue); validStatsigID(value) { + request.Header.Set("x-statsig-id", value) + } + return + } + if a.statsig == nil { + return + } + value, source, err := a.statsig.Sign(ctx, cfg.BaseURL, cfg.StatsigSignerURL, token, lease, request.Method, request.URL.String()) + if err == nil { + request.Header.Set("x-statsig-id", value) + if source == "refresh" { + a.log().Info("web_statsig_refreshed", "method", request.Method, "path", request.URL.EscapedPath()) + } else if source == "stale" { + a.log().Warn("web_statsig_refresh_failed_using_stale", "method", request.Method, "path", request.URL.EscapedPath()) + } + return + } + a.log().Warn("web_statsig_fetch_failed", "method", request.Method, "path", request.URL.EscapedPath(), "error", err) +} + +// WarmStatsig 只使用一个 Web 账号和一个出口租约预热共享签名,不会逐账号访问上游。 +func (a *Adapter) WarmStatsig(ctx context.Context, credential account.Credential) (int, error) { + cfg := a.config() + if cfg.StatsigMode == "manual" { + if !validStatsigID(strings.TrimSpace(cfg.StatsigManualValue)) { + return 0, fmt.Errorf("手动 Statsig 配置无效") + } + return 0, nil + } + if a.statsig == nil { + return 0, fmt.Errorf("Statsig 签名器未初始化") + } + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return 0, err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return 0, err + } + defer lease.Release() + baseURL := strings.TrimRight(cfg.BaseURL, "/") + return a.statsig.Warm(ctx, cfg.BaseURL, cfg.StatsigSignerURL, token, lease, []statsigWarmTarget{ + {method: http.MethodPost, target: baseURL + "/rest/app-chat/conversations/new"}, + {method: http.MethodPost, target: baseURL + "/rest/rate-limits"}, + {method: http.MethodPost, target: baseURL + "/rest/media/post/create"}, + }) +} + +func (a *Adapter) invalidateSignedStatsig(method, target string) bool { + cfg := a.config() + if cfg.StatsigMode == "url" && a.statsig != nil { + a.statsig.Invalidate(cfg.BaseURL, cfg.StatsigSignerURL, method, target) + if parsed, err := url.Parse(target); err == nil { + a.log().Info("web_statsig_invalidated", "method", method, "path", parsed.EscapedPath()) + } + return true + } + return false +} diff --git a/backend/internal/infra/provider/web/statsig_test.go b/backend/internal/infra/provider/web/statsig_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b32444e6a80b86d454d4634d20c6455f2740e7e4 --- /dev/null +++ b/backend/internal/infra/provider/web/statsig_test.go @@ -0,0 +1,273 @@ +package web + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestExtractStatsigMetaContentAcceptsCurrentMetaName(t *testing.T) { + for _, name := range []string{"grok-site―verification", "grok-site-verification"} { + body := []byte(``) + value, err := extractStatsigMetaContent(body) + if err != nil || value != "meta-value" { + t.Fatalf("name=%q value=%q err=%v", name, value, err) + } + } +} + +func TestStatsigSignerSendsMethodPathAndMetaContent(t *testing.T) { + raw := make([]byte, 70) + encoded := base64.RawStdEncoding.EncodeToString(raw) + signer := newStatsigSigner() + signer.validateEndpoint = func(context.Context, string) error { return nil } + signer.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload struct { + Method string `json:"method"` + Path string `json:"path"` + Environment struct { + MetaContent string `json:"metaContent"` + } `json:"environment"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.Method != "POST" || payload.Path != "/rest/app-chat/conversations/id/responses" || payload.Environment.MetaContent != "meta-value" { + t.Fatalf("payload=%#v", payload) + } + body, _ := json.Marshal(map[string]string{"x-statsig-id": encoded}) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), Header: http.Header{}}, nil + })} + value, err := signer.requestSignature(context.Background(), "https://signer.example/sign", "post", "/rest/app-chat/conversations/id/responses", "meta-value") + if err != nil || value != encoded { + t.Fatalf("value=%q err=%v", value, err) + } +} + +func TestStatsigSignerRejectsInvalidShape(t *testing.T) { + signer := newStatsigSigner() + signer.validateEndpoint = func(context.Context, string) error { return nil } + signer.client = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"x-statsig-id":"invalid"}`)), Header: http.Header{}}, nil + })} + if _, err := signer.requestSignature(context.Background(), "https://signer.example/sign", "POST", "/rest/test", "meta"); err == nil { + t.Fatal("invalid signature was accepted") + } +} + +func TestValidateStatsigSignerEndpointUsesAdminURLBoundary(t *testing.T) { + for _, endpoint := range []string{ + "https://grok.wodf.de/sign", + "https://signer.example/sign", + "http://grok-signer-go:8788/sign", + "http://host.docker.internal:8788/sign", + "http://127.0.0.1:8788/sign", + "https://10.0.0.1:8443/sign", + } { + if err := validateStatsigSignerEndpoint(context.Background(), endpoint); err != nil { + t.Fatalf("endpoint %q rejected: %v", endpoint, err) + } + } + for _, endpoint := range []string{ + "http://grok.wodf.de/sign", + "https://user:pass@grok.wodf.de/sign", + "https://grok.wodf.de:8443/sign", + "https://grok.wodf.de/sign?token=value", + "http://8.8.8.8:8788/sign", + } { + if err := validateStatsigSignerEndpoint(context.Background(), endpoint); err == nil { + t.Fatalf("unsafe endpoint %q accepted", endpoint) + } + } +} + +func TestStatsigSignerClientRejectsRedirects(t *testing.T) { + signer := newStatsigSigner() + request, err := http.NewRequest(http.MethodGet, "http://grok-signer-go:8788/redirect", nil) + if err != nil { + t.Fatal(err) + } + if err := signer.client.CheckRedirect(request, []*http.Request{request}); !errors.Is(err, http.ErrUseLastResponse) { + t.Fatalf("redirect policy error = %v", err) + } +} + +func TestStatsigSignerCachesByMethodAndPathForOneHour(t *testing.T) { + var fetches int + var signedMeta []string + now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + signer := newStatsigSigner() + signer.now = func() time.Time { return now } + signer.validateEndpoint = func(context.Context, string) error { return nil } + signer.fetchMeta = func(context.Context, string, string, *infraegress.Lease) (string, error) { + fetches++ + return fmt.Sprintf("meta-%d", fetches), nil + } + signer.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload struct { + Environment struct { + MetaContent string `json:"metaContent"` + } `json:"environment"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + signedMeta = append(signedMeta, payload.Environment.MetaContent) + raw := make([]byte, 70) + raw[0] = byte(len(signedMeta)) + encoded := base64.RawStdEncoding.EncodeToString(raw) + body, _ := json.Marshal(map[string]string{"x-statsig-id": encoded}) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), Header: http.Header{}}, nil + })} + first, _, err := signer.Sign(context.Background(), "https://grok.com", "https://signer.example/sign", "token-a", nil, http.MethodPost, "https://grok.com/rest/test") + if err != nil { + t.Fatal(err) + } + second, _, err := signer.Sign(context.Background(), "https://grok.com", "https://signer.example/sign", "token-b", nil, http.MethodPost, "https://grok.com/rest/test") + if err != nil { + t.Fatal(err) + } + if fetches != 1 || len(signedMeta) != 1 || first != second { + t.Fatalf("cached fetches=%d signedMeta=%v first=%q second=%q", fetches, signedMeta, first, second) + } + + now = now.Add(time.Hour) + third, _, err := signer.Sign(context.Background(), "https://grok.com", "https://signer.example/sign", "token-b", nil, http.MethodPost, "https://grok.com/rest/test") + if err != nil { + t.Fatal(err) + } + if fetches != 2 || third == first { + t.Fatalf("hourly refresh fetches=%d first=%q third=%q", fetches, first, third) + } + + signer.Invalidate("https://grok.com", "https://signer.example/sign", http.MethodPost, "https://grok.com/rest/test") + fourth, _, err := signer.Sign(context.Background(), "https://grok.com", "https://signer.example/sign", "token-a", nil, http.MethodPost, "https://grok.com/rest/test") + if err != nil { + t.Fatal(err) + } + if fetches != 3 || fourth == third { + t.Fatalf("invalidation fetches=%d third=%q fourth=%q", fetches, third, fourth) + } + + if _, _, err := signer.Sign(context.Background(), "https://grok.com", "https://signer.example/sign", "token-a", nil, http.MethodPost, "https://grok.com/rest/other"); err != nil { + t.Fatal(err) + } + if fetches != 4 { + t.Fatalf("different path reused signature: fetches=%d", fetches) + } +} + +func TestStatsigWarmupFetchesMetaOnceForSharedPaths(t *testing.T) { + var fetches, signatures int + signer := newStatsigSigner() + signer.validateEndpoint = func(context.Context, string) error { return nil } + signer.fetchMeta = func(context.Context, string, string, *infraegress.Lease) (string, error) { + fetches++ + return "shared-meta", nil + } + signer.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + signatures++ + var payload struct { + Environment struct { + MetaContent string `json:"metaContent"` + } `json:"environment"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.Environment.MetaContent != "shared-meta" { + t.Fatalf("meta = %q", payload.Environment.MetaContent) + } + raw := make([]byte, 70) + raw[0] = byte(signatures) + body, _ := json.Marshal(map[string]string{"x-statsig-id": base64.RawStdEncoding.EncodeToString(raw)}) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), Header: http.Header{}}, nil + })} + targets := []statsigWarmTarget{ + {method: http.MethodPost, target: "https://grok.com/rest/chat"}, + {method: http.MethodPost, target: "https://grok.com/rest/rate-limits"}, + {method: http.MethodPost, target: "https://grok.com/rest/media/post/create"}, + } + warmed, err := signer.Warm(context.Background(), "https://grok.com", "https://signer.example/sign", "token", nil, targets) + if err != nil { + t.Fatal(err) + } + if warmed != len(targets) || fetches != 1 || signatures != len(targets) { + t.Fatalf("warmed=%d fetches=%d signatures=%d", warmed, fetches, signatures) + } + if warmedAgain, err := signer.Warm(context.Background(), "https://grok.com", "https://signer.example/sign", "token", nil, targets); err != nil || warmedAgain != 0 || fetches != 1 { + t.Fatalf("cached warmup=%d fetches=%d err=%v", warmedAgain, fetches, err) + } +} + +func TestApplySignedStatsigUsesManualValue(t *testing.T) { + value := base64.RawStdEncoding.EncodeToString(make([]byte, 70)) + adapter := &Adapter{cfg: Config{BaseURL: "https://grok.com", StatsigMode: "manual", StatsigManualValue: value}} + request, err := http.NewRequest(http.MethodPost, "https://grok.com/rest/test", nil) + if err != nil { + t.Fatal(err) + } + adapter.applySignedStatsig(context.Background(), request, "token", nil) + if request.Header.Get("x-statsig-id") != value { + t.Fatalf("x-statsig-id = %q", request.Header.Get("x-statsig-id")) + } +} + +func TestStatsigInvalidationDoesNotReuseRejectedValue(t *testing.T) { + now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + raw := make([]byte, 70) + raw[0] = 1 + previous := base64.RawStdEncoding.EncodeToString(raw) + signer := newStatsigSigner() + signer.now = func() time.Time { return now } + key, _, err := statsigSignatureKey("https://grok.com", "https://signer.example/sign", http.MethodPost, "https://grok.com/rest/test") + if err != nil { + t.Fatal(err) + } + signer.store(key, previous, now.Add(time.Hour), now) + signer.fetchMeta = func(context.Context, string, string, *infraegress.Lease) (string, error) { + return "", errors.New("signer unavailable") + } + signer.Invalidate("https://grok.com", "https://signer.example/sign", http.MethodPost, "https://grok.com/rest/test") + value, source, err := signer.Sign(context.Background(), "https://grok.com", "https://signer.example/sign", "token", nil, http.MethodPost, "https://grok.com/rest/test") + if err == nil || value != "" || source != "" { + t.Fatalf("value=%q source=%q err=%v", value, source, err) + } +} + +func TestApplySignedStatsigNeverLeavesRandomFallback(t *testing.T) { + adapter := &Adapter{cfg: Config{BaseURL: "https://grok.com", StatsigMode: "manual", StatsigManualValue: "invalid"}} + request, err := http.NewRequest(http.MethodPost, "https://grok.com/rest/test", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("x-statsig-id", "random-fallback") + adapter.applySignedStatsig(context.Background(), request, "token", nil) + if value := request.Header.Get("x-statsig-id"); value != "" { + t.Fatalf("x-statsig-id = %q", value) + } +} + +func TestStatsigInvalidationOnlyAppliesToURLMode(t *testing.T) { + manual := &Adapter{cfg: Config{StatsigMode: "manual"}, statsig: newStatsigSigner()} + if manual.invalidateSignedStatsig(http.MethodPost, "https://grok.com/rest/test") { + t.Fatal("manual Statsig must not be invalidated automatically") + } + urlMode := &Adapter{cfg: Config{BaseURL: "https://grok.com", StatsigMode: "url", StatsigSignerURL: "https://signer.example/sign"}, statsig: newStatsigSigner()} + if !urlMode.invalidateSignedStatsig(http.MethodPost, "https://grok.com/rest/test") { + t.Fatal("URL Statsig must be invalidated after anti-bot rejection") + } +} diff --git a/backend/internal/infra/provider/web/tools.go b/backend/internal/infra/provider/web/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..bc5177c39281864e833378462ef29237181046b2 --- /dev/null +++ b/backend/internal/infra/provider/web/tools.go @@ -0,0 +1,459 @@ +package web + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "html" + "regexp" + "strings" +) + +const ( + maxFunctionTools = 128 + maxToolDescriptionSize = 16 << 10 +) + +var ( + toolNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + toolSyntaxPattern = regexp.MustCompile(`(?i)(.*?)`) + toolCallPattern = regexp.MustCompile(`(?is)(.*?)`) + toolNameTagPattern = regexp.MustCompile(`(?is)(.*?)`) + toolParamsTagPattern = regexp.MustCompile(`(?is)(.*?)`) + functionCallPattern = regexp.MustCompile(`(?is)(.*?)`) + functionNamePattern = regexp.MustCompile(`(?is)(.*?)`) + functionArgsPattern = regexp.MustCompile(`(?is)(.*?)`) + invokePattern = regexp.MustCompile(`(?is)(.*?)`) +) + +type functionTool struct { + Name string + Description string + Parameters json.RawMessage +} + +type toolConfiguration struct { + Functions []functionTool + HostedWebSearch bool + available map[string]struct{} + Choice string + ForcedName string + ResponseTools []any + ResponseChoice any +} + +type parsedToolCall struct { + ID string + Name string + Arguments string +} + +type toolParseResult struct { + Calls []parsedToolCall + SawSyntax bool + Start int + End int +} + +type toolStreamResult struct { + SafeText string + Calls []parsedToolCall + Complete bool + Raw string +} + +type toolStreamSieve struct { + available map[string]struct{} + buffer string + capturing bool + done bool +} + +// parseToolConfiguration 兼容 Chat Completions 与 Responses 的函数工具结构。 +func parseToolConfiguration(rawTools, rawChoice json.RawMessage) (toolConfiguration, error) { + configuration := toolConfiguration{Choice: "auto", ResponseChoice: "auto"} + trimmed := bytes.TrimSpace(rawTools) + if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) { + var values []map[string]any + if err := json.Unmarshal(trimmed, &values); err != nil { + return toolConfiguration{}, errors.New("tools 必须是数组") + } + if len(values) > maxFunctionTools { + return toolConfiguration{}, fmt.Errorf("tools 不能超过 %d 个", maxFunctionTools) + } + configuration.ResponseTools = make([]any, 0, len(values)) + for _, value := range values { + configuration.ResponseTools = append(configuration.ResponseTools, value) + function, supported, err := parseFunctionTool(value) + if err != nil { + return toolConfiguration{}, err + } + if supported { + configuration.Functions = append(configuration.Functions, function) + continue + } + typeName, _ := value["type"].(string) + switch strings.ToLower(strings.TrimSpace(typeName)) { + case "web_search", "web_search_preview": + // Grok Web 原生搜索始终由上游执行,这两个标准声明无需注入函数提示词。 + configuration.HostedWebSearch = true + default: + return toolConfiguration{}, fmt.Errorf("Grok Web 暂不支持 tools.type=%q", typeName) + } + } + } + + choice, forcedName, responseChoice, err := parseToolChoice(rawChoice) + if err != nil { + return toolConfiguration{}, err + } + configuration.Choice = choice + configuration.ForcedName = forcedName + configuration.ResponseChoice = responseChoice + configuration.available = make(map[string]struct{}, len(configuration.Functions)) + for _, function := range configuration.Functions { + if _, exists := configuration.available[function.Name]; exists { + return toolConfiguration{}, fmt.Errorf("function tool 名称 %q 重复", function.Name) + } + configuration.available[function.Name] = struct{}{} + } + if forcedName != "" { + if _, ok := configuration.available[forcedName]; !ok { + return toolConfiguration{}, fmt.Errorf("tool_choice 指定的函数 %q 不存在", forcedName) + } + } + if (choice == "required" || forcedName != "") && len(configuration.Functions) == 0 && !configuration.HostedWebSearch { + return toolConfiguration{}, errors.New("tool_choice 要求调用函数,但 tools 中没有可用函数") + } + return configuration, nil +} + +func parseFunctionTool(value map[string]any) (functionTool, bool, error) { + typeName, _ := value["type"].(string) + if strings.ToLower(strings.TrimSpace(typeName)) != "function" { + return functionTool{}, false, nil + } + definition := value + if nested, ok := value["function"].(map[string]any); ok { + definition = nested + } + name, _ := definition["name"].(string) + name = strings.TrimSpace(name) + if !toolNamePattern.MatchString(name) { + return functionTool{}, false, errors.New("function tool 的 name 必须是 1 到 64 位字母、数字、下划线或连字符") + } + description, _ := definition["description"].(string) + if len(description) > maxToolDescriptionSize { + return functionTool{}, false, fmt.Errorf("函数 %q 的 description 过长", name) + } + parameters := json.RawMessage(`{"type":"object","properties":{}}`) + if raw, ok := definition["parameters"]; ok { + encoded, err := json.Marshal(raw) + if err != nil || !json.Valid(encoded) { + return functionTool{}, false, fmt.Errorf("函数 %q 的 parameters 不是有效 JSON", name) + } + parameters = encoded + } + return functionTool{Name: name, Description: strings.TrimSpace(description), Parameters: parameters}, true, nil +} + +func parseToolChoice(raw json.RawMessage) (string, string, any, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return "auto", "", "auto", nil + } + var text string + if json.Unmarshal(trimmed, &text) == nil { + text = strings.ToLower(strings.TrimSpace(text)) + switch text { + case "auto", "none", "required": + return text, "", text, nil + default: + return "", "", nil, errors.New("tool_choice 必须是 auto、none、required 或函数对象") + } + } + var value map[string]any + if json.Unmarshal(trimmed, &value) != nil { + return "", "", nil, errors.New("tool_choice 格式无效") + } + typeName, _ := value["type"].(string) + typeName = strings.ToLower(strings.TrimSpace(typeName)) + switch typeName { + case "none", "auto", "required": + return typeName, "", value, nil + case "function": + name, _ := value["name"].(string) + if nested, ok := value["function"].(map[string]any); ok { + name, _ = nested["name"].(string) + } + name = strings.TrimSpace(name) + if !toolNamePattern.MatchString(name) { + return "", "", nil, errors.New("tool_choice.function.name 无效") + } + return "required", name, value, nil + default: + return "", "", nil, fmt.Errorf("Grok Web 暂不支持 tool_choice.type=%q", typeName) + } +} + +// injectToolPrompt 将函数定义转换为 Grok Web 可稳定生成的 XML 调用约定。 +func injectToolPrompt(prompt string, configuration toolConfiguration) string { + if len(configuration.Functions) == 0 || configuration.Choice == "none" { + return prompt + } + var definitions strings.Builder + for index, function := range configuration.Functions { + if index > 0 { + definitions.WriteString("\n\n") + } + definitions.WriteString("Tool: ") + definitions.WriteString(function.Name) + if function.Description != "" { + definitions.WriteString("\nDescription: ") + definitions.WriteString(function.Description) + } + definitions.WriteString("\nParameters: ") + definitions.Write(function.Parameters) + } + choiceInstruction := "Call a tool when it is clearly needed. Otherwise respond in plain text." + if configuration.ForcedName != "" { + choiceInstruction = fmt.Sprintf("You MUST call the tool named %q and must not write a plain-text reply.", configuration.ForcedName) + } else if configuration.Choice == "required" && !configuration.HostedWebSearch { + choiceInstruction = "You MUST call at least one available tool and must not write a plain-text reply." + } + system := fmt.Sprintf(`You have access to the following tools. + +AVAILABLE TOOLS: +%s + +TOOL CALL FORMAT - follow these rules exactly: +- When calling a tool, output only the XML block below, with no text before or after it. +- must contain one valid JSON object. +- Put multiple calls inside one element. +- Do not use Markdown code fences. + + + + TOOL_NAME + {"key":"value"} + + + +WHEN TO CALL: %s`, definitions.String(), choiceInstruction) + return "[system]\n" + system + "\n\n" + prompt +} + +func toolCallsToXML(raw json.RawMessage) string { + var values []struct { + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } + if json.Unmarshal(raw, &values) != nil || len(values) == 0 { + return "" + } + var builder strings.Builder + builder.WriteString("") + for _, value := range values { + if !toolNamePattern.MatchString(value.Function.Name) { + continue + } + arguments := normalizeToolArguments(value.Function.Arguments) + builder.WriteString("\n \n ") + builder.WriteString(html.EscapeString(value.Function.Name)) + builder.WriteString("\n ") + builder.WriteString(arguments) + builder.WriteString("\n ") + } + builder.WriteString("\n") + return builder.String() +} + +// parseToolCalls 解析模型输出,并仅保留请求中声明过的函数名。 +func parseToolCalls(text string, available map[string]struct{}) toolParseResult { + result := toolParseResult{SawSyntax: toolSyntaxPattern.MatchString(text), Start: -1, End: -1} + if !result.SawSyntax { + return result + } + if match := toolCallsRootPattern.FindStringSubmatchIndex(text); match != nil { + result.Start, result.End = match[0], match[1] + root := text[match[2]:match[3]] + for _, raw := range toolCallPattern.FindAllStringSubmatch(root, -1) { + nameMatch := toolNameTagPattern.FindStringSubmatch(raw[1]) + if len(nameMatch) == 0 { + continue + } + arguments := "{}" + if paramsMatch := toolParamsTagPattern.FindStringSubmatch(raw[1]); len(paramsMatch) > 0 { + arguments = paramsMatch[1] + } + appendParsedToolCall(&result.Calls, html.UnescapeString(strings.TrimSpace(nameMatch[1])), arguments, available) + } + return result + } + if match := functionCallPattern.FindStringSubmatchIndex(text); match != nil { + result.Start, result.End = match[0], match[1] + inner := text[match[2]:match[3]] + nameMatch := functionNamePattern.FindStringSubmatch(inner) + if len(nameMatch) > 0 { + arguments := "{}" + if argsMatch := functionArgsPattern.FindStringSubmatch(inner); len(argsMatch) > 0 { + arguments = argsMatch[1] + } + appendParsedToolCall(&result.Calls, html.UnescapeString(strings.TrimSpace(nameMatch[1])), arguments, available) + } + return result + } + if match := invokePattern.FindStringSubmatchIndex(text); match != nil { + result.Start, result.End = match[0], match[1] + appendParsedToolCall(&result.Calls, text[match[2]:match[3]], text[match[4]:match[5]], available) + return result + } + return parseJSONToolCalls(text, available, result) +} + +func parseJSONToolCalls(text string, available map[string]struct{}, result toolParseResult) toolParseResult { + start := strings.IndexByte(text, '{') + if start < 0 { + return result + } + decoder := json.NewDecoder(strings.NewReader(text[start:])) + var envelope struct { + ToolCalls []map[string]any `json:"tool_calls"` + } + if decoder.Decode(&envelope) != nil || len(envelope.ToolCalls) == 0 { + return result + } + result.Start = start + result.End = len(text) + for _, value := range envelope.ToolCalls { + name, _ := value["name"].(string) + arguments := value["arguments"] + if function, ok := value["function"].(map[string]any); ok { + name, _ = function["name"].(string) + arguments = function["arguments"] + } + if name == "" { + name, _ = value["tool_name"].(string) + } + if arguments == nil { + arguments = value["parameters"] + } + argumentText := "{}" + if text, ok := arguments.(string); ok { + argumentText = text + } else if arguments != nil { + encoded, _ := json.Marshal(arguments) + argumentText = string(encoded) + } + appendParsedToolCall(&result.Calls, strings.TrimSpace(name), argumentText, available) + } + return result +} + +func appendParsedToolCall(calls *[]parsedToolCall, name, arguments string, available map[string]struct{}) { + if _, ok := available[name]; !ok { + return + } + arguments = normalizeToolArguments(html.UnescapeString(strings.TrimSpace(arguments))) + if !json.Valid([]byte(arguments)) { + return + } + var object map[string]any + if json.Unmarshal([]byte(arguments), &object) != nil { + return + } + *calls = append(*calls, parsedToolCall{ID: newWebID("call"), Name: name, Arguments: arguments}) +} + +func normalizeToolArguments(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "{}" + } + var parsed any + if json.Unmarshal([]byte(value), &parsed) != nil { + return value + } + encoded, err := json.Marshal(parsed) + if err != nil { + return value + } + return string(encoded) +} + +func removeToolSyntax(text string, parsed toolParseResult) string { + if parsed.Start < 0 || parsed.End <= parsed.Start || parsed.End > len(text) { + return text + } + return strings.TrimSpace(text[:parsed.Start] + text[parsed.End:]) +} + +func newToolStreamSieve(available map[string]struct{}) *toolStreamSieve { + return &toolStreamSieve{available: available} +} + +// Feed 在流中发现工具 XML 后开始缓存,完整解析前不向客户端泄露内部标记。 +func (s *toolStreamSieve) Feed(chunk string) toolStreamResult { + if s.done || chunk == "" { + return toolStreamResult{SafeText: chunk} + } + combined := s.buffer + chunk + s.buffer = "" + if !s.capturing { + lower := strings.ToLower(combined) + index := strings.Index(lower, "") + if endIndex < 0 { + return toolStreamResult{SafeText: combined} + } + endIndex += len("") + raw := s.buffer[:endIndex] + remainder := s.buffer[endIndex:] + parsed := parseToolCalls(raw, s.available) + s.buffer = "" + s.capturing = false + s.done = len(parsed.Calls) > 0 + if len(parsed.Calls) == 0 { + raw += remainder + } + return toolStreamResult{SafeText: combined, Calls: parsed.Calls, Complete: true, Raw: raw} +} + +func (s *toolStreamSieve) Flush() toolStreamResult { + if s.done || s.buffer == "" { + return toolStreamResult{} + } + raw := s.buffer + s.buffer = "" + parsed := parseToolCalls(raw, s.available) + if len(parsed.Calls) > 0 { + s.done = true + return toolStreamResult{Calls: parsed.Calls, Complete: true, Raw: raw} + } + return toolStreamResult{SafeText: raw, Complete: parsed.SawSyntax, Raw: raw} +} + +func splitToolPrefix(value string) (string, string) { + prefix := " 0; size-- { + if strings.HasSuffix(lower, prefix[:size]) { + return value[:len(value)-size], value[len(value)-size:] + } + } + return value, "" +} diff --git a/backend/internal/infra/provider/web/tools_test.go b/backend/internal/infra/provider/web/tools_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8513cca9d7aa4d23d291c2b91c2c6889193306f2 --- /dev/null +++ b/backend/internal/infra/provider/web/tools_test.go @@ -0,0 +1,703 @@ +package web + +import ( + "bytes" + "encoding/json" + "strconv" + "strings" + "testing" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/infra/provider/conversation" + "github.com/chenyme/grok2api/backend/internal/infra/provider/searchresult" +) + +func TestParseToolConfigurationSupportsChatAndResponsesSchemas(t *testing.T) { + chatTools := json.RawMessage(`[{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]`) + chatChoice := json.RawMessage(`{"type":"function","function":{"name":"get_weather"}}`) + chat, err := parseToolConfiguration(chatTools, chatChoice) + if err != nil { + t.Fatal(err) + } + if len(chat.Functions) != 1 || chat.Functions[0].Name != "get_weather" || chat.ForcedName != "get_weather" || chat.Choice != "required" { + t.Fatalf("chat tool config = %#v", chat) + } + + responsesTools := json.RawMessage(`[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"web_search_preview"}]`) + responses, err := parseToolConfiguration(responsesTools, json.RawMessage(`"auto"`)) + if err != nil { + t.Fatal(err) + } + if len(responses.Functions) != 1 || responses.Functions[0].Name != "lookup" || len(responses.ResponseTools) != 2 { + t.Fatalf("responses tool config = %#v", responses) + } +} + +func TestParseToolConfigurationAllowsRequiredHostedWebSearch(t *testing.T) { + configuration, err := parseToolConfiguration( + json.RawMessage(`[{"type":"web_search","max_uses":8}]`), + json.RawMessage(`"required"`), + ) + if err != nil { + t.Fatal(err) + } + if configuration.Choice != "required" || len(configuration.Functions) != 0 || len(configuration.ResponseTools) != 1 { + t.Fatalf("configuration = %#v", configuration) + } +} + +func TestInjectToolPromptDoesNotForceClientFunctionWhenHostedSearchIsRequired(t *testing.T) { + configuration, err := parseToolConfiguration( + json.RawMessage(`[ + {"type":"web_search"}, + {"type":"function","name":"lookup","description":"Look up local data","parameters":{"type":"object"}} + ]`), + json.RawMessage(`"required"`), + ) + if err != nil { + t.Fatal(err) + } + prompt := injectToolPrompt("find the latest release", configuration) + if !strings.Contains(prompt, "Tool: lookup") { + t.Fatalf("client function definition missing from prompt: %s", prompt) + } + if strings.Contains(prompt, "You MUST call at least one available tool") { + t.Fatalf("hosted-search required incorrectly forced a client function: %s", prompt) + } + if !strings.Contains(prompt, "Call a tool when it is clearly needed") { + t.Fatalf("mixed hosted/client tool guidance is not optional: %s", prompt) + } +} + +func TestAnthropicWebSearchRequestConvertsForWebProvider(t *testing.T) { + converted, options, err := conversation.ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"stream":true, + "messages":[{"role":"user","content":"Perform a web search for the query: rust tutorials"}], + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":8}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-chat-fast", conversation.OperationMessages) + if err != nil { + t.Fatal(err) + } + var input openAIRequest + if err := json.Unmarshal(converted, &input); err != nil { + t.Fatal(err) + } + configuration, err := parseToolConfiguration(input.Tools, input.ToolChoice) + if err != nil { + t.Fatal(err) + } + if !options.AnthropicWebSearch || !options.AnthropicWebSearchRequired || options.AnthropicWebSearchQuery != "rust tutorials" || !configuration.HostedWebSearch || configuration.Choice != "required" { + t.Fatalf("options=%#v configuration=%#v", options, configuration) + } +} + +func TestNormalizeOpenAIInputReconstructsToolHistory(t *testing.T) { + toolCalls := json.RawMessage(`[{"id":"call_old","type":"function","function":{"name":"lookup","arguments":"{\"query\":\"xAI\"}"}}]`) + assistantContent, _ := json.Marshal("") + toolContent, _ := json.Marshal("result text") + chat, err := normalizeOpenAIInput(openAIRequest{Messages: []chatMessage{ + {Role: "assistant", Content: assistantContent, ToolCalls: toolCalls}, + {Role: "tool", Content: toolContent, ToolCallID: "call_old"}, + {Role: "user", Content: json.RawMessage(`"continue"`)}, + }}, "chat") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"lookup", `{"query":"xAI"}`, "Tool result (call_old): result text", "[user]\ncontinue"} { + if !strings.Contains(chat.Prompt, expected) { + t.Fatalf("chat prompt missing %q: %s", expected, chat.Prompt) + } + } + + input := json.RawMessage(`[ + {"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"query\":\"Grok\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"done"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"summarize"}]} + ]`) + responses, err := normalizeOpenAIInput(openAIRequest{Input: input}, "responses") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"lookup", "[tool result for call_1]", "done", "summarize"} { + if !strings.Contains(responses.Prompt, expected) { + t.Fatalf("responses prompt missing %q: %s", expected, responses.Prompt) + } + } +} + +func TestParseToolCallsBuildsStandardResults(t *testing.T) { + available := map[string]struct{}{"lookup": {}} + parsedResult := parseToolCalls(`prefixlookup{"query":"Grok"}`, available) + if len(parsedResult.Calls) != 1 || parsedResult.Calls[0].Name != "lookup" || parsedResult.Calls[0].Arguments != `{"query":"Grok"}` { + t.Fatalf("tool parse = %#v", parsedResult) + } + parsed := parsedChat{ToolCalls: parsedResult.Calls, ParallelTools: true} + chat := buildOpenAIResult("chat", "resp_test", "grok-chat-fast", parsed, false) + choice := chat["choices"].([]any)[0].(map[string]any) + message := choice["message"].(map[string]any) + if choice["finish_reason"] != "tool_calls" || len(message["tool_calls"].([]any)) != 1 || message["content"] != nil { + t.Fatalf("chat tool result = %#v", chat) + } + responses := buildOpenAIResult("responses", "resp_test", "grok-chat-fast", parsed, false) + output := responses["output"].([]any) + if len(output) != 1 || output[0].(map[string]any)["type"] != "function_call" { + t.Fatalf("responses tool result = %#v", responses) + } + messages := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false) + content := messages["content"].([]any) + if messages["type"] != "message" || messages["stop_reason"] != "tool_use" || content[0].(map[string]any)["type"] != "tool_use" { + t.Fatalf("messages tool result = %#v", messages) + } +} + +func TestBuildMessagesResultPreservesThinkingAndStopSequence(t *testing.T) { + parsed := parsedChat{} + parsed.Reasoning.WriteString("thought") + parsed.Text.WriteString("ABCSTOPXYZ") + result := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false, conversation.ResponseOptions{ + AnthropicThinking: true, StopSequences: []string{"STOP"}, + }) + content := result["content"].([]any) + if result["stop_reason"] != "stop_sequence" || result["stop_sequence"] != "STOP" || len(content) != 2 { + t.Fatalf("result = %#v", result) + } + if content[0].(map[string]any)["type"] != "thinking" || content[1].(map[string]any)["text"] != "ABC" { + t.Fatalf("content = %#v", content) + } +} + +func TestBuildMessagesResultEmitsServerWebSearchBlocks(t *testing.T) { + options := webSearchResponseOptions(t) + parsed := parsedChat{ServerTools: 1, SearchSources: []map[string]any{ + {"url": "https://doc.rust-lang.org", "title": "The Rust Book", "type": "web"}, + }} + parsed.Text.WriteString("Here you go.") + result := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false, options) + content := result["content"].([]any) + if len(content) != 3 { + t.Fatalf("content = %#v", content) + } + use := content[0].(map[string]any) + if use["type"] != "server_tool_use" || use["name"] != "web_search" || use["input"].(map[string]any)["query"] != "rust tutorials" { + t.Fatalf("server_tool_use = %#v", use) + } + searchResult := content[1].(map[string]any) + if searchResult["type"] != "web_search_tool_result" || searchResult["tool_use_id"] != use["id"] { + t.Fatalf("web_search_tool_result = %#v", searchResult) + } + hits := searchResult["content"].([]any) + if len(hits) != 1 || hits[0].(map[string]any)["url"] != "https://doc.rust-lang.org" { + t.Fatalf("hits = %#v", hits) + } + if content[2].(map[string]any)["type"] != "text" || result["stop_reason"] != "end_turn" { + t.Fatalf("result = %#v", result) + } + usage := result["usage"].(map[string]any)["server_tool_use"].(map[string]any) + if usage["web_search_requests"] != int64(1) { + t.Fatalf("usage = %#v", usage) + } +} + +func TestBuildMessagesResultEmitsUnavailableWebSearchError(t *testing.T) { + result := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsedChat{}, false, webSearchResponseOptions(t)) + content := result["content"].([]any) + errorContent := content[1].(map[string]any)["content"].(map[string]any) + if errorContent["type"] != "web_search_tool_result_error" || errorContent["error_code"] != "unavailable" { + t.Fatalf("error content = %#v", errorContent) + } +} + +func TestBuildMessagesResultDoesNotFabricateOptionalWebSearch(t *testing.T) { + parsed := parsedChat{} + parsed.Text.WriteString("Plain answer.") + result := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false, conversation.ResponseOptions{ + AnthropicWebSearch: true, AnthropicWebSearchQuery: "optional query", + }) + content := result["content"].([]any) + if len(content) != 1 || content[0].(map[string]any)["type"] != "text" || content[0].(map[string]any)["text"] != "Plain answer." { + t.Fatalf("optional search fabricated server blocks: %#v", content) + } + if _, exists := result["usage"].(map[string]any)["server_tool_use"]; exists { + t.Fatalf("optional search fabricated usage: %#v", result["usage"]) + } +} + +func TestBuildMessagesResultDoesNotTreatOtherServerToolsAsWebSearch(t *testing.T) { + parsed := parsedChat{ServerTools: 1} + parsed.Text.WriteString("Generated an image instead.") + result := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false, conversation.ResponseOptions{ + AnthropicWebSearch: true, AnthropicWebSearchQuery: "optional query", + }) + content := result["content"].([]any) + if len(content) != 1 || content[0].(map[string]any)["type"] != "text" { + t.Fatalf("non-search server tool fabricated web search blocks: %#v", content) + } + if _, exists := result["usage"].(map[string]any)["server_tool_use"]; exists { + t.Fatalf("non-search server tool fabricated web search usage: %#v", result["usage"]) + } + + required := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false, webSearchResponseOptions(t)) + requiredContent := required["content"].([]any) + errorContent, ok := requiredContent[1].(map[string]any)["content"].(map[string]any) + if !ok || errorContent["type"] != "web_search_tool_result_error" || errorContent["error_code"] != "unavailable" { + t.Fatalf("forced search with only a non-search server tool = %#v", requiredContent) + } +} + +func TestBuildMessagesResultFiltersUnsafeAndDuplicateSearchSources(t *testing.T) { + options := webSearchResponseOptions(t) + parsed := parsedChat{ServerTools: 1, SearchSources: []map[string]any{ + {"url": "https://example.com/a", "title": "Example"}, + {"url": "https://example.com/a", "title": "Duplicate"}, + {"url": "javascript:alert(1)", "title": "Script"}, + {"url": "file:///etc/passwd", "title": "File"}, + {"url": "/relative/path", "title": "Relative"}, + {"url": "https://user:secret@example.com/private", "title": "Credential URL"}, + }} + result := buildOpenAIResult("messages", "resp_test", "grok-chat-fast", parsed, false, options) + content := result["content"].([]any) + hits := content[1].(map[string]any)["content"].([]any) + if len(hits) != 1 { + t.Fatalf("unsafe or duplicate sources leaked into hits: %#v", hits) + } + if hits[0].(map[string]any)["url"] != "https://example.com/a" { + t.Fatalf("hits = %#v", hits) + } +} + +func TestBuildMessagesResultBoundsSearchSourcesAndTitles(t *testing.T) { + parsed := parsedChat{ServerTools: 1, SearchSources: make([]map[string]any, 0, 60)} + for index := 0; index < 60; index++ { + parsed.SearchSources = append(parsed.SearchSources, map[string]any{ + "url": "https://example.com/" + strconv.Itoa(index), + "title": strings.Repeat("界", 600), + }) + } + blocks := webMessagesSearchBlocks("srvtoolu_test", parsed, webSearchResponseOptions(t)) + hits := blocks[1].(map[string]any)["content"].([]any) + if len(hits) != 50 { + t.Fatalf("hits = %d, want 50", len(hits)) + } + if got := utf8.RuneCountInString(hits[0].(map[string]any)["title"].(string)); got != 512 { + t.Fatalf("title runes = %d, want 512", got) + } +} + +func TestWebMessagesStreamPreservesThinkingAndSplitStopSequence(t *testing.T) { + var output bytes.Buffer + stream := newWebMessagesStream(&output, "resp_test", "grok-chat-fast", 3, conversation.ResponseOptions{ + AnthropicThinking: true, StopSequences: []string{"STOP"}, + }) + for _, delta := range []struct{ kind, value string }{ + {kind: "reasoning", value: "thought"}, + {kind: "text", value: "ABCST"}, + {kind: "text", value: "OPXYZ"}, + } { + if err := stream.Delta(delta.kind, delta.value); err != nil { + t.Fatal(err) + } + } + if err := stream.Finish(parsedChat{}, map[string]any{"usage": map[string]any{"output_tokens": int64(2)}}); err != nil { + t.Fatal(err) + } + text := output.String() + if strings.Contains(text, "XYZ") || !strings.Contains(text, `"thinking":"thought"`) || !strings.Contains(text, `"text":"ABC"`) || !strings.Contains(text, `"stop_reason":"stop_sequence"`) { + t.Fatalf("stream = %s", text) + } +} + +func TestWebMessagesStreamEmitsServerWebSearchBeforeBufferedText(t *testing.T) { + var output bytes.Buffer + stream := newWebMessagesStream(&output, "resp_test", "grok-chat-fast", 3, webSearchResponseOptions(t)) + if err := stream.Delta("text", "Here you go."); err != nil { + t.Fatal(err) + } + parsed := parsedChat{ServerTools: 1, SearchSources: []map[string]any{ + {"url": "https://doc.rust-lang.org", "title": "The Rust Book", "type": "web"}, + }} + if err := stream.Finish(parsed, map[string]any{"usage": map[string]any{"output_tokens": int64(2)}}); err != nil { + t.Fatal(err) + } + text := output.String() + useAt := strings.Index(text, `"type":"server_tool_use"`) + resultAt := strings.Index(text, `"type":"web_search_tool_result"`) + textAt := strings.Index(text, `"content_block":{"text":"","type":"text"}`) + if useAt < 0 || resultAt < 0 || textAt < 0 || !(useAt < resultAt && resultAt < textAt) { + t.Fatalf("expected server_tool_use -> result -> text, got:\n%s", text) + } + if !strings.Contains(text, "rust tutorials") || !strings.Contains(text, `"web_search_requests":1`) { + t.Fatalf("missing query or usage:\n%s", text) + } +} + +func TestWebMessagesStreamKeepsThinkingBeforeServerWebSearch(t *testing.T) { + options := webSearchResponseOptions(t) + options.AnthropicThinking = true + var output bytes.Buffer + stream := newWebMessagesStream(&output, "resp_test", "grok-chat-fast", 3, options) + if err := stream.Delta("reasoning", "Need sources."); err != nil { + t.Fatal(err) + } + if err := stream.Delta("text", "Here you go."); err != nil { + t.Fatal(err) + } + parsed := parsedChat{ServerTools: 1, SearchSources: []map[string]any{ + {"url": "https://doc.rust-lang.org", "title": "The Rust Book"}, + }} + if err := stream.Finish(parsed, map[string]any{"usage": map[string]any{"output_tokens": int64(2)}}); err != nil { + t.Fatal(err) + } + text := output.String() + thinkingAt := strings.Index(text, `"type":"thinking"`) + useAt := strings.Index(text, `"type":"server_tool_use"`) + resultAt := strings.Index(text, `"type":"web_search_tool_result"`) + textAt := strings.Index(text, `"content_block":{"text":"","type":"text"}`) + if thinkingAt < 0 || useAt < 0 || resultAt < 0 || textAt < 0 || !(thinkingAt < useAt && useAt < resultAt && resultAt < textAt) { + t.Fatalf("expected thinking -> server_tool_use -> result -> text, got:\n%s", text) + } +} + +func TestWebMessagesStreamAppliesStopSequenceAfterServerWebSearch(t *testing.T) { + options := webSearchResponseOptions(t) + options.StopSequences = []string{"STOP"} + var output bytes.Buffer + stream := newWebMessagesStream(&output, "resp_test", "grok-chat-fast", 3, options) + if err := stream.Delta("text", "ABCST"); err != nil { + t.Fatal(err) + } + if err := stream.Delta("text", "OPXYZ"); err != nil { + t.Fatal(err) + } + parsed := parsedChat{ServerTools: 1, SearchSources: []map[string]any{{"url": "https://example.com", "title": "Example"}}} + if err := stream.Finish(parsed, map[string]any{"usage": map[string]any{"output_tokens": int64(2)}}); err != nil { + t.Fatal(err) + } + text := output.String() + if strings.Contains(text, "XYZ") || !strings.Contains(text, `"text":"ABC"`) || !strings.Contains(text, `"stop_reason":"stop_sequence"`) || !strings.Contains(text, `"stop_sequence":"STOP"`) { + t.Fatalf("stream stop sequence = %s", text) + } + useAt := strings.Index(text, `"type":"server_tool_use"`) + resultAt := strings.Index(text, `"type":"web_search_tool_result"`) + textAt := strings.Index(text, `"content_block":{"text":"","type":"text"}`) + if useAt < 0 || resultAt < 0 || textAt < 0 || !(useAt < resultAt && resultAt < textAt) { + t.Fatalf("web search blocks are out of order: %s", text) + } +} + +func TestWebMessagesStreamRejectsOversizedDeferredText(t *testing.T) { + var output bytes.Buffer + stream := newWebMessagesStream(&output, "resp_test", "grok-chat-fast", 3, conversation.ResponseOptions{AnthropicWebSearch: true}) + err := stream.Delta("text", strings.Repeat("x", (8<<20)+1)) + if err == nil || !strings.Contains(err.Error(), "缓冲") { + t.Fatalf("oversized deferred text error = %v", err) + } +} + +func webSearchResponseOptions(t *testing.T) conversation.ResponseOptions { + t.Helper() + _, options, err := conversation.ConvertRequestWithOptions([]byte(`{ + "model":"public","max_tokens":64,"stream":true, + "messages":[{"role":"user","content":"Perform a web search for the query: rust tutorials"}], + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":8}], + "tool_choice":{"type":"tool","name":"web_search"} + }`), "grok-chat-fast", conversation.OperationMessages) + if err != nil { + t.Fatal(err) + } + return options +} + +func TestToolStreamSieveHandlesSplitXML(t *testing.T) { + sieve := newToolStreamSieve(map[string]struct{}{"lookup": {}}) + first := sieve.Feed("before lookup{"q":"x"}`) + if !second.Complete || len(second.Calls) != 1 || second.Calls[0].Arguments != `{"q":"x"}` || strings.Contains(second.SafeText, "tool_calls") { + t.Fatalf("second = %#v", second) + } +} + +func TestToolStreamSievePreservesInvalidSyntaxAndTrailingText(t *testing.T) { + sieve := newToolStreamSieve(map[string]struct{}{"lookup": {}}) + result := sieve.Feed(`unknown{} trailing`) + if !result.Complete || len(result.Calls) != 0 || !strings.HasSuffix(result.Raw, " trailing") { + t.Fatalf("result = %#v", result) + } +} + +func TestSearchSourcesAndServerToolsAreDeduplicated(t *testing.T) { + parsed := &parsedChat{} + response := map[string]any{ + "webSearchResults": map[string]any{"results": []any{ + map[string]any{"url": "https://example.com", "title": "Example"}, + map[string]any{"url": "https://example.com", "title": "Duplicate"}, + }}, + "xSearchResults": map[string]any{"results": []any{ + map[string]any{"username": "xai", "postId": "123", "text": "post"}, + }}, + } + collectSearchSources(parsed, response) + collectSearchSources(parsed, response) + if len(parsed.SearchSources) != 2 { + t.Fatalf("search sources = %#v", parsed.SearchSources) + } + toolCard := map[string]any{"rolloutId": "search", "messageStepId": 1.0} + collectServerTool(parsed, toolCard) + collectServerTool(parsed, toolCard) + if parsed.ServerTools != 1 { + t.Fatalf("server tools = %d", parsed.ServerTools) + } +} + +func TestWebMessagesUsageDoesNotCountNonSearchServerTools(t *testing.T) { + parsed := parsedChat{ + ServerTools: 3, + SearchSources: []map[string]any{{"url": "https://example.com", "title": "Example"}}, + } + if got := webMessagesSearchRequests(parsed); got != 1 { + t.Fatalf("web_search_requests = %d, want 1", got) + } +} + +func TestCollectServerToolCountsOnlyNamedWebSearchForAnthropicUsage(t *testing.T) { + parsed := &parsedChat{} + webCard := map[string]any{ + "rolloutId": "web-1", "messageTag": "tool_usage_card", + "token": `web_search{"query":"x"}`, + } + collectServerTool(parsed, webCard) + collectServerTool(parsed, webCard) + collectServerTool(parsed, map[string]any{ + "rolloutId": "image-1", "messageTag": "tool_usage_card", + "token": `search_images`, + }) + collectServerTool(parsed, map[string]any{ + "rolloutId": "web-2", "messageTag": "tool_usage_card", "toolName": "web_search", + }) + if parsed.ServerTools != 3 || parsed.WebSearchTools != 2 || webMessagesSearchRequests(*parsed) != 2 { + t.Fatalf("server=%d web=%d usage=%d", parsed.ServerTools, parsed.WebSearchTools, webMessagesSearchRequests(*parsed)) + } +} + +func TestStructuredToolUsageCardDoesNotLeakIntoReasoning(t *testing.T) { + parsed := &parsedChat{} + frame := map[string]any{"result": map[string]any{"response": map[string]any{ + "messageTag": "tool_usage_card", "isThinking": true, + "toolUsageCardId": "search-1", + "toolUsageCard": map[string]any{ + "toolUsageCardId": "search-1", + "webSearch": map[string]any{"args": map[string]any{"query": "latest news"}}, + }, + "token": `web_search`, + }}} + data, _ := json.Marshal(frame) + kind, delta, err := parseUpstreamFrame(data, parsed) + if err != nil || kind != "" || delta != "" { + t.Fatalf("kind=%q delta=%q err=%v", kind, delta, err) + } + if parsed.Reasoning.Len() != 0 || parsed.ServerTools != 1 || parsed.WebSearchTools != 1 { + t.Fatalf("reasoning=%q server=%d web=%d", parsed.Reasoning.String(), parsed.ServerTools, parsed.WebSearchTools) + } +} + +func TestStructuredToolUsageCardWorksWithoutXMLToken(t *testing.T) { + parsed := &parsedChat{} + collectServerTool(parsed, map[string]any{ + "toolUsageCardId": "search-1", + "toolUsageCard": map[string]any{"webSearch": map[string]any{"args": map[string]any{"query": "x"}}}, + }) + collectServerTool(parsed, map[string]any{ + "toolUsageCardId": "browse-1", + "toolUsageCard": map[string]any{"browsePage": map[string]any{"args": map[string]any{"url": "https://example.com"}}}, + }) + if parsed.ServerTools != 2 || parsed.WebSearchTools != 1 { + t.Fatalf("server=%d web=%d", parsed.ServerTools, parsed.WebSearchTools) + } +} + +func TestCollectServerToolEnrichesEarlierUnknownCard(t *testing.T) { + parsed := &parsedChat{} + collectServerTool(parsed, map[string]any{ + "rolloutId": "web-late", "messageTag": "tool_usage_card", + }) + collectServerTool(parsed, map[string]any{ + "rolloutId": "web-late", "messageTag": "tool_usage_card", + "token": `web_search`, + }) + if parsed.ServerTools != 1 || parsed.WebSearchTools != 1 || webMessagesSearchRequests(*parsed) != 1 { + t.Fatalf("server=%d web=%d usage=%d", parsed.ServerTools, parsed.WebSearchTools, webMessagesSearchRequests(*parsed)) + } +} + +func TestCollectServerToolDistinguishesTokenCardsWithoutRolloutID(t *testing.T) { + parsed := &parsedChat{} + for _, name := range []string{"web_search", "search_images"} { + collectServerTool(parsed, map[string]any{ + "messageStepId": 1.0, "messageTag": "tool_usage_card", + "token": `` + name + ``, + }) + } + if parsed.ServerTools != 2 || parsed.WebSearchTools != 1 { + t.Fatalf("server=%d web=%d", parsed.ServerTools, parsed.WebSearchTools) + } +} + +func TestCollectServerToolBoundsTrackingState(t *testing.T) { + parsed := &parsedChat{} + for index := 0; index < maxTrackedServerTools+10; index++ { + collectServerTool(parsed, map[string]any{ + "rolloutId": "web-" + strconv.Itoa(index), "messageTag": "tool_usage_card", "toolName": "web_search", + }) + } + if len(parsed.serverToolKeys) != maxTrackedServerTools || len(parsed.webSearchKeys) != maxTrackedServerTools || parsed.ServerTools != maxTrackedServerTools || parsed.WebSearchTools != maxTrackedServerTools { + t.Fatalf("server keys=%d web keys=%d server=%d web=%d", len(parsed.serverToolKeys), len(parsed.webSearchKeys), parsed.ServerTools, parsed.WebSearchTools) + } +} + +func TestCollectSearchSourcesNormalizesFiltersAndBoundsInput(t *testing.T) { + results := make([]any, 0, searchresult.MaxResults+12) + results = append(results, + map[string]any{"url": "javascript:alert(1)", "title": "Script"}, + map[string]any{"url": "https://user:secret@example.com/private", "title": "Credential"}, + ) + for index := 0; index < searchresult.MaxResults+10; index++ { + results = append(results, map[string]any{ + "url": "HTTPS://Example.COM/" + strconv.Itoa(index), + "title": strings.Repeat("界", searchresult.MaxTitleRunes+10), + }) + } + parsed := &parsedChat{} + collectSearchSources(parsed, map[string]any{"webSearchResults": map[string]any{"results": results}}) + if len(parsed.SearchSources) != searchresult.MaxResults || len(parsed.sourceKeys) != searchresult.MaxResults { + t.Fatalf("sources=%d keys=%d, want %d", len(parsed.SearchSources), len(parsed.sourceKeys), searchresult.MaxResults) + } + first := parsed.SearchSources[0] + if first["url"] != "https://example.com/0" || utf8.RuneCountInString(first["title"].(string)) != searchresult.MaxTitleRunes { + t.Fatalf("first normalized source = %#v", first) + } + for _, source := range parsed.SearchSources { + if strings.Contains(source["url"].(string), "@") || strings.HasPrefix(source["url"].(string), "javascript:") { + t.Fatalf("unsafe source retained: %#v", source) + } + } +} + +func TestGeneratedImageURLsCollectAllCandidates(t *testing.T) { + parsed := &parsedChat{} + frame := map[string]any{"result": map[string]any{"response": map[string]any{ + "modelResponse": map[string]any{"generatedImageUrls": []any{"one.jpg", "two.jpg"}}, + }}} + data, _ := json.Marshal(frame) + kind, delta, err := parseUpstreamFrame(data, parsed) + if err != nil || kind != "image" || delta != "https://assets.grok.com/one.jpg" || len(parsed.Images) != 2 { + t.Fatalf("kind=%q delta=%q images=%#v err=%v", kind, delta, parsed.Images, err) + } +} + +func TestModelResponseCompletesMissingTextAndDirectSearchResults(t *testing.T) { + parsed := &parsedChat{} + first, _ := json.Marshal(map[string]any{"result": map[string]any{"response": map[string]any{ + "token": "Hello ", "isThinking": false, "messageTag": "final", + }}}) + if kind, delta, err := parseUpstreamFrame(first, parsed); err != nil || kind != "text" || delta != "Hello " { + t.Fatalf("first kind=%q delta=%q err=%v", kind, delta, err) + } + final, _ := json.Marshal(map[string]any{"result": map[string]any{"response": map[string]any{ + "modelResponse": map[string]any{ + "message": "Hello world", + "parentResponseId": "parent-1", + "webSearchResults": []any{ + map[string]any{"url": "https://example.com/news", "title": "News"}, + }, + "citedWebSearchResults": []any{ + map[string]any{"url": "https://example.com/news", "title": "Duplicate"}, + }, + "generatedImageUrls": []any{"users/test/generated/final/image.jpg"}, + }, + }}}) + kind, delta, err := parseUpstreamFrame(final, parsed) + if err != nil || kind != "text" || delta != "world" { + t.Fatalf("final kind=%q delta=%q err=%v", kind, delta, err) + } + if parsed.Text.String() != "Hello world" || parsed.ParentID != "parent-1" || len(parsed.SearchSources) != 1 || len(parsed.Images) != 1 { + t.Fatalf("parsed=%#v text=%q sources=%#v images=%#v", parsed, parsed.Text.String(), parsed.SearchSources, parsed.Images) + } + if kind, delta, err := parseUpstreamFrame(final, parsed); err != nil || kind != "" || delta != "" { + t.Fatalf("duplicate kind=%q delta=%q err=%v", kind, delta, err) + } +} + +func TestModelResponseUsesFullMessageWhenIncrementalTextIsMissing(t *testing.T) { + parsed := &parsedChat{} + frame, _ := json.Marshal(map[string]any{"result": map[string]any{"response": map[string]any{ + "modelResponse": map[string]any{"message": "final only"}, + }}}) + kind, delta, err := parseUpstreamFrame(frame, parsed) + if err != nil || kind != "text" || delta != "final only" || parsed.Text.String() != "final only" { + t.Fatalf("kind=%q delta=%q text=%q err=%v", kind, delta, parsed.Text.String(), err) + } +} + +func TestModelResponseSurfacesStreamErrors(t *testing.T) { + parsed := &parsedChat{} + frame, _ := json.Marshal(map[string]any{"result": map[string]any{"response": map[string]any{ + "modelResponse": map[string]any{"streamErrors": []any{map[string]any{"message": "upstream stream failed"}}}, + }}}) + if _, _, err := parseUpstreamFrame(frame, parsed); err == nil || !strings.Contains(err.Error(), "upstream stream failed") { + t.Fatalf("error=%v", err) + } +} + +func TestGrokRenderCitationBecomesMarkdownAndAnnotation(t *testing.T) { + parsed := &parsedChat{} + cardFrame := map[string]any{ + "result": map[string]any{ + "response": map[string]any{ + "cardAttachment": map[string]any{"jsonData": `{"id":"cite_1","url":"https://example.com"}`}, + "webSearchResults": map[string]any{"results": []any{map[string]any{"url": "https://example.com", "title": "Example"}}}, + }, + }, + } + cardData, _ := json.Marshal(cardFrame) + if _, _, err := parseUpstreamFrame(cardData, parsed); err != nil { + t.Fatal(err) + } + tokenFrame := map[string]any{ + "result": map[string]any{ + "response": map[string]any{ + "token": `Answer`, + "isThinking": false, + "messageTag": "final", + }, + }, + } + tokenData, _ := json.Marshal(tokenFrame) + kind, delta, err := parseUpstreamFrame(tokenData, parsed) + if err != nil || kind != "text" || delta != "Answer [[1]](https://example.com)" || len(parsed.Annotations) != 1 { + t.Fatalf("kind=%q delta=%q annotations=%#v err=%v", kind, delta, parsed.Annotations, err) + } + annotation := parsed.Annotations[0] + if annotation["title"] != "Example" || annotation["start_index"] != 6 || annotation["end_index"] != len(delta) { + t.Fatalf("annotation = %#v", annotation) + } +} + +func TestGrokRenderCitationRejectsUnsafeURLs(t *testing.T) { + for _, rawURL := range []string{ + "javascript:alert(1)", + "file:///etc/passwd", + "https://user:secret@example.com/private", + "/relative/path", + } { + parsed := &parsedChat{cardCache: map[string]map[string]any{ + "cite_unsafe": {"id": "cite_unsafe", "url": rawURL}, + }} + replacement, annotation := renderChatCard(parsed, "cite_unsafe", "render_inline_citation") + if replacement != "" || annotation != nil { + t.Fatalf("unsafe citation %q rendered as %q, %#v", rawURL, replacement, annotation) + } + } +} diff --git a/backend/internal/infra/provider/web/upstream.go b/backend/internal/infra/provider/web/upstream.go new file mode 100644 index 0000000000000000000000000000000000000000..5b24bf7e91155767e13b505d229f3b64e6e7d394 --- /dev/null +++ b/backend/internal/infra/provider/web/upstream.go @@ -0,0 +1,10 @@ +package web + +import "net/http" + +func responseUpstreamURL(response *http.Response) string { + if response == nil || response.Request == nil || response.Request.URL == nil { + return "" + } + return response.Request.URL.String() +} diff --git a/backend/internal/infra/provider/web/upstream_test.go b/backend/internal/infra/provider/web/upstream_test.go new file mode 100644 index 0000000000000000000000000000000000000000..74a8e9d1fb2084f8bca0477c27229dc29a569417 --- /dev/null +++ b/backend/internal/infra/provider/web/upstream_test.go @@ -0,0 +1,24 @@ +package web + +import ( + "net/http" + "testing" +) + +func TestResponseUpstreamURLHandlesResponsesWithoutRequestMetadata(t *testing.T) { + response := &http.Response{} + if got := responseUpstreamURL(response); got != "" { + t.Fatalf("responseUpstreamURL() = %q, want empty string", got) + } +} + +func TestResponseUpstreamURLReturnsRequestURL(t *testing.T) { + request, err := http.NewRequest(http.MethodPost, "https://grok.com/rest/app-chat/conversations/new", nil) + if err != nil { + t.Fatal(err) + } + response := &http.Response{Request: request} + if got := responseUpstreamURL(response); got != request.URL.String() { + t.Fatalf("responseUpstreamURL() = %q, want %q", got, request.URL.String()) + } +} diff --git a/backend/internal/infra/provider/web/video.go b/backend/internal/infra/provider/web/video.go new file mode 100644 index 0000000000000000000000000000000000000000..1a2582ceec74b562027a2c17e26b739896fe76a9 --- /dev/null +++ b/backend/internal/infra/provider/web/video.go @@ -0,0 +1,521 @@ +package web + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +type webMediaUpstreamError struct { + status int + summary string + bodyBytes int + bodyTruncated bool + bodyPrefixSHA256 string + bodyKind string + cloudflareChallenge bool +} + +func (e *webMediaUpstreamError) Error() string { + if e == nil { + return "" + } + return e.summary +} + +func (e *webMediaUpstreamError) HTTPStatusCode() int { + if e == nil { + return 0 + } + return e.status +} + +const ( + webMediaDiagnosticBodyLimit = 64 << 10 + webMediaDiagnosticSummaryLimit = 256 + webMediaDiagnosticFieldLimit = 160 +) + +var ( + webMediaAuthorizationPattern = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+`) + webMediaCookiePattern = regexp.MustCompile(`(?i)\b(cookie|set-cookie)\b\s*[:=]\s*[^\r\n]+`) + webMediaSecretPattern = regexp.MustCompile(`(?i)(["']?(?:authorization|proxy-authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|upload[_-]?url|cookie|sso|session[_-]?id)["']?\s*[:=]\s*["']?)[^"'\s,;}]+`) + webMediaJWTPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}(?:\.[A-Za-z0-9_-]{12,})?\b`) + webMediaEmailPattern = regexp.MustCompile(`(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b`) + webMediaURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + webMediaLongTokenPattern = regexp.MustCompile(`[A-Za-z0-9+/=_-]{256,}`) +) + +// newWebMediaUpstreamError keeps the HTTP status while exposing only a +// bounded, redacted summary through the error. Structured logs retain only +// body metadata and a prefix hash, never the upstream response body itself. +func newWebMediaUpstreamError(status int, body []byte, truncated bool) *webMediaUpstreamError { + digest := sha256.Sum256(body) + return &webMediaUpstreamError{ + status: status, + summary: summarizeWebMediaUpstreamError(status, body, truncated), + bodyBytes: len(body), + bodyTruncated: truncated, + bodyPrefixSHA256: fmt.Sprintf("%x", digest), + bodyKind: classifyWebMediaDiagnosticBody(body), + cloudflareChallenge: isCloudflareChallengeBody(body), + } +} + +func classifyWebMediaDiagnosticBody(body []byte) string { + if !utf8.Valid(body) { + return "binary" + } + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return "empty" + } + if json.Valid(body) { + return "json" + } + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "") + } else if truncated { + parts = append(parts, "响应正文过长") + } else if !structured { + parts = append(parts, "响应正文不可解析") + } else if code == "" { + parts = append(parts, "未提供错误详情") + } + return boundWebMediaDiagnostic(strings.Join(parts, ": "), webMediaDiagnosticSummaryLimit) +} + +func extractWebMediaUpstreamErrorFields(body []byte) (code, message string, structured bool) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return "", "", false + } + structured = true + if errorObject, ok := root["error"].(map[string]any); ok { + code = firstWebMediaDiagnosticCode(errorObject, "code", "type", "error") + message = firstString(errorObject, "message", "error", "detail") + } else if errorText, ok := root["error"].(string); ok { + message = errorText + } + if code == "" { + code = firstWebMediaDiagnosticCode(root, "code", "error_code", "type") + } + if message == "" { + message = firstString(root, "message", "error_message", "detail") + } + return safeWebMediaDiagnostic(code, 64), safeWebMediaDiagnostic(message, webMediaDiagnosticFieldLimit), true +} + +func firstWebMediaDiagnosticCode(value map[string]any, keys ...string) string { + if code := firstString(value, keys...); code != "" { + return code + } + if code, ok := firstInt(value, keys...); ok { + return fmt.Sprintf("%d", code) + } + return "" +} + +func safeWebMediaDiagnostic(value string, limit int) string { + value = strings.Join(strings.Fields(value), " ") + value = webMediaCookiePattern.ReplaceAllString(value, "$1: [REDACTED]") + value = webMediaAuthorizationPattern.ReplaceAllString(value, "$1 [REDACTED]") + value = webMediaSecretPattern.ReplaceAllString(value, "$1[REDACTED]") + value = webMediaJWTPattern.ReplaceAllString(value, "[REDACTED]") + value = webMediaEmailPattern.ReplaceAllString(value, "[REDACTED_EMAIL]") + value = webMediaURLPattern.ReplaceAllString(value, "[REDACTED_URL]") + value = webMediaLongTokenPattern.ReplaceAllString(value, "[REDACTED_LONG_VALUE]") + return boundWebMediaDiagnostic(value, limit) +} + +func boundWebMediaDiagnostic(value string, limit int) string { + if limit <= 0 || value == "" { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func (a *Adapter) GenerateVideo(ctx context.Context, request provider.VideoRequest) (provider.VideoResult, error) { + cfg := a.config() + token, err := a.cipher.Decrypt(request.Credential.EncryptedAccessToken) + if err != nil { + return provider.VideoResult{}, err + } + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWeb, request.Credential) + if err != nil { + return provider.VideoResult{}, err + } + defer lease.Release() + parentID := "" + references := make([]string, 0, len(request.ReferenceURLs)) + for _, rawReference := range request.ReferenceURLs { + reference, referenceErr := a.prepareVideoReference(ctx, cfg, lease, token, rawReference) + if referenceErr != nil { + return provider.VideoResult{}, referenceErr + } + references = append(references, reference) + } + if len(references) > 0 { + parentID, err = a.createMediaPost(ctx, cfg, lease, token, "MEDIA_POST_TYPE_IMAGE", references[0], "", "video_reference_media_post") + } else { + parentID, err = a.createMediaPost(ctx, cfg, lease, token, "MEDIA_POST_TYPE_VIDEO", "", request.Prompt, "video_prompt_media_post") + } + if err != nil { + return provider.VideoResult{}, err + } + segments := videoSegments(request.Duration) + if len(segments) == 0 { + return provider.VideoResult{}, fmt.Errorf("duration 必须在 1 到 15 秒之间") + } + ratio := resolveAspectRatio(request.AspectRatio) + resolution := request.Resolution + if resolution == "" { + resolution = "720p" + } + payload := videoCreatePayload(request.Prompt, parentID, ratio, resolution, segments[0], references) + response, err := a.postJSON(ctx, cfg, lease, token, cfg.BaseURL+"/rest/app-chat/conversations/new", payload, time.Duration(cfg.VideoTimeoutSeconds)*time.Second) + if err != nil { + return provider.VideoResult{}, err + } + result, _, parseErr := parseVideoStream(response, request.Progress) + _ = response.Body.Close() + if parseErr != nil { + if upstreamErr, ok := parseErr.(*webMediaUpstreamError); ok { + a.logWebMediaUpstreamRejection("video_generation", response, upstreamErr) + } + return provider.VideoResult{}, parseErr + } + if result.URL == "" { + return provider.VideoResult{}, fmt.Errorf("视频生成完成但没有返回内容 URL") + } + return result, nil +} + +func (a *Adapter) prepareVideoReference(ctx context.Context, cfg Config, lease *egress.Lease, token, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("视频参考图片 URL 不能为空") + } + image, err := a.loadChatImage(ctx, lease, value, 20<<20) + if err != nil { + return "", err + } + uploaded, err := a.uploadFileV2Direct(ctx, cfg, lease, token, image, cfg.BaseURL+"/imagine", imagineSelfUploadSource, "video_reference_upload") + if err != nil { + return "", err + } + if uploaded.URI == "" { + return "", fmt.Errorf("上传视频参考图片后未返回 fileUri") + } + return uploaded.URI, nil +} + +type videoContentReadCloser struct { + io.ReadCloser + release func() +} + +func (c *videoContentReadCloser) Close() error { + err := c.ReadCloser.Close() + if c.release != nil { + c.release() + c.release = nil + } + return err +} + +// DownloadVideo retrieves a completed Grok asset through its source SSO +// session. Direct asset URLs are not public and must not be exposed as a +// substitute for this authenticated transfer. +func (a *Adapter) DownloadVideo(ctx context.Context, credential account.Credential, rawURL string) (io.ReadCloser, string, int64, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme != "https" || !trustedImageAssetHost(parsed.Hostname()) || parsed.User != nil { + return nil, "", 0, fmt.Errorf("视频内容 URL 不受信任") + } + token, err := a.cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return nil, "", 0, err + } + // 视频生成与成品下载必须复用同一账号身份;否则 Resin 会为 WebAsset + // 重新分配租约,账号级 Cloudflare clearance 也不会进入下载请求。 + lease, err := a.egress.AcquireCredential(ctx, domainegress.ScopeWebAsset, credential) + if err != nil { + return nil, "", 0, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + lease.Release() + return nil, "", 0, err + } + request.Header = buildHeaders(token, lease, "") + request.Header.Del("Content-Type") + response, err := lease.Do(request) + if err != nil { + lease.Release() + return nil, "", 0, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + _ = response.Body.Close() + lease.Release() + return nil, "", 0, fmt.Errorf("下载视频返回 %d", response.StatusCode) + } + contentType := strings.ToLower(strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])) + if contentType == "" || contentType == "application/octet-stream" { + contentType = "video/mp4" + } + if !strings.HasPrefix(contentType, "video/") { + _ = response.Body.Close() + lease.Release() + return nil, "", 0, fmt.Errorf("上游视频 Content-Type 无效") + } + return &videoContentReadCloser{ReadCloser: response.Body, release: lease.Release}, contentType, response.ContentLength, nil +} + +func parseVideoStream(response *http.Response, progress func(int)) (provider.VideoResult, string, error) { + if response.StatusCode < 200 || response.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(response.Body, webMediaDiagnosticBodyLimit+1)) + if response.StatusCode == http.StatusUnauthorized { + return provider.VideoResult{}, "", provider.ErrUnauthorized + } + truncated := len(body) > webMediaDiagnosticBodyLimit + if truncated { + body = body[:webMediaDiagnosticBodyLimit] + } + return provider.VideoResult{}, "", newWebMediaUpstreamError(response.StatusCode, body, truncated) + } + var result provider.VideoResult + var postID string + handle := func(root map[string]any) (bool, error) { + if errorValue, ok := root["error"].(map[string]any); ok { + return false, webMediaStreamError(errorValue) + } + if errorValue := nestedMap(root, "result", "response", "error"); errorValue != nil { + return false, webMediaStreamError(errorValue) + } + stream := nestedMap(root, "result", "response", "streamingVideoGenerationResponse") + if stream != nil { + if value, ok := numberAsInt(stream["progress"]); ok && progress != nil { + progress(value) + } + if value, _ := stream["videoPostId"].(string); value != "" { + postID = value + } else if value, _ := stream["videoId"].(string); value != "" { + postID = value + } + moderated, _ := stream["moderated"].(bool) + if moderated { + return false, nil + } + if setVideoResultURL(&result, firstString(stream, "videoUrl", "contentUrl", "contentURL", "assetUrl", "assetURL", "fileUri", "fileURL")) { + return true, nil + } + } + for _, attachment := range videoFileAttachments(root) { + if setVideoResultURL(&result, attachment) { + return true, nil + } + } + return false, nil + } + + reader := bufio.NewReader(response.Body) + prefix, _ := reader.Peek(64) + trimmedPrefix := strings.TrimSpace(string(prefix)) + var err error + if strings.HasPrefix(trimmedPrefix, "data:") || strings.HasPrefix(trimmedPrefix, "event:") { + err = consumeVideoSSE(reader, handle) + } else { + err = consumeVideoJSON(reader, handle) + } + if err != nil { + return provider.VideoResult{}, "", err + } + return result, postID, nil +} + +func webMediaStreamError(value map[string]any) error { + message := safeWebMediaDiagnostic(firstString(value, "message", "error", "detail"), webMediaDiagnosticFieldLimit) + if message == "" { + message = "未提供错误详情" + } + return fmt.Errorf("视频上游错误: %s", message) +} + +func videoFileAttachments(root map[string]any) []string { + modelResponse := nestedMap(root, "result", "response", "modelResponse") + if modelResponse == nil { + return nil + } + values, _ := modelResponse["fileAttachments"].([]any) + attachments := make([]string, 0, len(values)) + for _, value := range values { + if attachment, _ := value.(string); attachment != "" { + attachments = append(attachments, attachment) + } + } + return attachments +} + +func setVideoResultURL(result *provider.VideoResult, value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + lower := strings.ToLower(value) + if !strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ".mp4") && !strings.Contains(lower, "/content") { + return false + } + result.URL = absoluteAssetURL(value) + result.ContentType = "video/mp4" + return true +} + +func consumeVideoSSE(reader io.Reader, handle func(map[string]any) (bool, error)) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 64<<10), 8<<20) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "data:") { + line = strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + if line == "" || line == "[DONE]" || !strings.HasPrefix(line, "{") { + continue + } + var root map[string]any + if json.Unmarshal([]byte(line), &root) != nil { + continue + } + complete, err := handle(root) + if err != nil { + return err + } + if complete { + return nil + } + } + return scanner.Err() +} + +func consumeVideoJSON(reader io.Reader, handle func(map[string]any) (bool, error)) error { + decoder := json.NewDecoder(io.LimitReader(reader, 64<<20)) + for { + var root map[string]any + if err := decoder.Decode(&root); err != nil { + if err == io.EOF { + return nil + } + return fmt.Errorf("解析视频上游流: %w", err) + } + complete, err := handle(root) + if err != nil { + return err + } + if complete { + return nil + } + } +} + +func nestedMap(value map[string]any, keys ...string) map[string]any { + current := value + for _, key := range keys { + next, ok := current[key].(map[string]any) + if !ok { + return nil + } + current = next + } + return current +} + +func videoSegments(seconds int) []int { + if seconds < 1 || seconds > 15 { + return nil + } + return []int{seconds} +} + +func videoCreatePayload(prompt, parentID, ratio, resolution string, seconds int, references []string) map[string]any { + config := map[string]any{"parentPostId": parentID, "aspectRatio": ratio, "videoLength": seconds, "resolutionName": resolution} + if len(references) > 0 { + config["isVideoEdit"] = false + config["isReferenceToVideo"] = true + config["imageReferences"] = references + } + return map[string]any{ + "temporary": true, "modelName": "imagine-video-gen", "message": prompt + " --mode=custom", "enableSideBySide": true, + "responseMetadata": map[string]any{"experiments": []any{}, "modelConfigOverride": map[string]any{"modelMap": map[string]any{"videoGenModelConfig": config}}}, + } +} diff --git a/backend/internal/infra/runtime/memory/quota_queue.go b/backend/internal/infra/runtime/memory/quota_queue.go new file mode 100644 index 0000000000000000000000000000000000000000..47a3f767efcce66cadf1c85d3a2f1d33e1c0f314 --- /dev/null +++ b/backend/internal/infra/runtime/memory/quota_queue.go @@ -0,0 +1,156 @@ +package memory + +import ( + "container/heap" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const maxQuotaRecoveryEvents = 100000 + +type quotaItem struct { + value account.QuotaRecoveryEvent + index int +} + +type quotaHeap []*quotaItem + +func (h quotaHeap) Len() int { return len(h) } +func (h quotaHeap) Less(i, j int) bool { + if h[i].value.DueAt.Equal(h[j].value.DueAt) { + return quotaEventKey(h[i].value) < quotaEventKey(h[j].value) + } + return h[i].value.DueAt.Before(h[j].value.DueAt) +} +func (h quotaHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i]; h[i].index, h[j].index = i, j } +func (h *quotaHeap) Push(value any) { + item := value.(*quotaItem) + item.index = len(*h) + *h = append(*h, item) +} +func (h *quotaHeap) Pop() any { + old := *h + item := old[len(old)-1] + item.index = -1 + *h = old[:len(old)-1] + return item +} + +type QuotaRecoveryQueue struct { + mu sync.Mutex + heap quotaHeap + items map[string]*quotaItem +} + +func NewQuotaRecoveryQueue() *QuotaRecoveryQueue { + return &QuotaRecoveryQueue{items: make(map[string]*quotaItem)} +} + +func (q *QuotaRecoveryQueue) ScheduleQuotaRecovery(_ context.Context, value account.QuotaRecoveryEvent) error { + if value.AccountID == 0 || value.Mode == "" || value.DueAt.IsZero() { + return fmt.Errorf("额度恢复事件无效") + } + q.mu.Lock() + defer q.mu.Unlock() + key := quotaEventKey(value) + if existing := q.items[key]; existing != nil { + value.ClaimToken = "" + existing.value = value + heap.Fix(&q.heap, existing.index) + return nil + } + if len(q.items) >= maxQuotaRecoveryEvents { + return fmt.Errorf("额度恢复队列已满") + } + item := "aItem{value: value} + heap.Push(&q.heap, item) + q.items[key] = item + return nil +} + +func (q *QuotaRecoveryQueue) EnsureQuotaRecovery(_ context.Context, value account.QuotaRecoveryEvent) error { + if value.AccountID == 0 || value.Mode == "" || value.DueAt.IsZero() { + return fmt.Errorf("额度恢复事件无效") + } + q.mu.Lock() + defer q.mu.Unlock() + key := quotaEventKey(value) + if q.items[key] != nil { + return nil + } + if len(q.items) >= maxQuotaRecoveryEvents { + return fmt.Errorf("额度恢复队列已满") + } + item := "aItem{value: value} + heap.Push(&q.heap, item) + q.items[key] = item + return nil +} + +func (q *QuotaRecoveryQueue) ClaimDueQuotaRecoveries(_ context.Context, now time.Time, limit int, lease time.Duration) ([]account.QuotaRecoveryEvent, error) { + if limit <= 0 { + limit = 100 + } + q.mu.Lock() + defer q.mu.Unlock() + values := make([]account.QuotaRecoveryEvent, 0, limit) + for len(values) < limit && q.heap.Len() > 0 && !q.heap[0].value.DueAt.After(now) { + item := heap.Pop(&q.heap).(*quotaItem) + token, err := quotaClaimToken() + if err != nil { + heap.Push(&q.heap, item) + return nil, err + } + item.value.DueAt = now.Add(lease) + item.value.ClaimToken = token + heap.Push(&q.heap, item) + values = append(values, item.value) + } + return values, nil +} + +func (q *QuotaRecoveryQueue) AckQuotaRecovery(_ context.Context, value account.QuotaRecoveryEvent) error { + q.mu.Lock() + defer q.mu.Unlock() + key := quotaEventKey(value) + if item := q.items[key]; item != nil { + if value.ClaimToken == "" || item.value.ClaimToken != value.ClaimToken { + return repository.ErrConflict + } + heap.Remove(&q.heap, item.index) + delete(q.items, key) + } + return nil +} + +func (q *QuotaRecoveryQueue) RescheduleQuotaRecovery(_ context.Context, value account.QuotaRecoveryEvent) error { + q.mu.Lock() + defer q.mu.Unlock() + item := q.items[quotaEventKey(value)] + if item == nil || value.ClaimToken == "" || item.value.ClaimToken != value.ClaimToken { + return repository.ErrConflict + } + value.ClaimToken = "" + item.value = value + heap.Fix(&q.heap, item.index) + return nil +} + +func quotaEventKey(value account.QuotaRecoveryEvent) string { + return fmt.Sprintf("%d:%s", value.AccountID, value.Mode) +} + +func quotaClaimToken() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} diff --git a/backend/internal/infra/runtime/memory/quota_queue_test.go b/backend/internal/infra/runtime/memory/quota_queue_test.go new file mode 100644 index 0000000000000000000000000000000000000000..be819aad41f6158faffaaac565ced22f3b8a3d5b --- /dev/null +++ b/backend/internal/infra/runtime/memory/quota_queue_test.go @@ -0,0 +1,58 @@ +package memory + +import ( + "context" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestQuotaRecoveryQueueClaimsInDueOrderAndReschedules(t *testing.T) { + ctx := context.Background() + queue := NewQuotaRecoveryQueue() + now := time.Now().UTC() + late := account.QuotaRecoveryEvent{AccountID: 2, Mode: "auto", DueAt: now.Add(time.Minute)} + due := account.QuotaRecoveryEvent{AccountID: 1, Mode: "fast", DueAt: now.Add(-time.Second)} + if err := queue.ScheduleQuotaRecovery(ctx, late); err != nil { + t.Fatal(err) + } + if err := queue.ScheduleQuotaRecovery(ctx, due); err != nil { + t.Fatal(err) + } + values, err := queue.ClaimDueQuotaRecoveries(ctx, now, 10, time.Minute) + if err != nil || len(values) != 1 || values[0].AccountID != due.AccountID { + t.Fatalf("values = %#v, err = %v", values, err) + } + claimedDue := values[0] + claimedDue.DueAt = now.Add(2 * time.Minute) + if err := queue.RescheduleQuotaRecovery(ctx, claimedDue); err != nil { + t.Fatal(err) + } + values, err = queue.ClaimDueQuotaRecoveries(ctx, now.Add(90*time.Second), 10, time.Minute) + if err != nil || len(values) != 1 || values[0].AccountID != late.AccountID { + t.Fatalf("values = %#v, err = %v", values, err) + } +} + +func TestEnsureQuotaRecoveryPreservesExistingBackoffAndClaim(t *testing.T) { + ctx := context.Background() + queue := NewQuotaRecoveryQueue() + now := time.Now().UTC() + event := account.QuotaRecoveryEvent{AccountID: 1, Mode: "fast", DueAt: now.Add(-time.Second), Attempts: 3} + if err := queue.ScheduleQuotaRecovery(ctx, event); err != nil { + t.Fatal(err) + } + claimed, err := queue.ClaimDueQuotaRecoveries(ctx, now, 1, time.Minute) + if err != nil || len(claimed) != 1 || claimed[0].ClaimToken == "" { + t.Fatalf("claimed = %#v, err = %v", claimed, err) + } + if err := queue.EnsureQuotaRecovery(ctx, account.QuotaRecoveryEvent{AccountID: 1, Mode: "fast", DueAt: now}); err != nil { + t.Fatal(err) + } + claimed[0].DueAt = now.Add(2 * time.Minute) + claimed[0].Attempts++ + if err := queue.RescheduleQuotaRecovery(ctx, claimed[0]); err != nil { + t.Fatalf("ensure overwrote active claim: %v", err) + } +} diff --git a/backend/internal/infra/runtime/memory/quota_refresh.go b/backend/internal/infra/runtime/memory/quota_refresh.go new file mode 100644 index 0000000000000000000000000000000000000000..a33c37a295b14a87599b71995272b73485e8a71e --- /dev/null +++ b/backend/internal/infra/runtime/memory/quota_refresh.go @@ -0,0 +1,180 @@ +package memory + +import ( + "container/heap" + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + maxQuotaRefreshDirty = 100000 + quotaRefreshExpiryCompactionMinStale = 1024 +) + +type quotaRefreshDirtyState struct { + accountID uint64 + mode string + generation uint64 + expiresAt time.Time + dirty bool +} + +type QuotaRefreshCoordinator struct { + mu sync.Mutex + values map[string]quotaRefreshDirtyState + dirty map[string]struct{} + expires quotaRefreshExpiryHeap +} + +func NewQuotaRefreshCoordinator() *QuotaRefreshCoordinator { + return &QuotaRefreshCoordinator{values: make(map[string]quotaRefreshDirtyState), dirty: make(map[string]struct{})} +} + +type quotaRefreshExpiry struct { + key string + generation uint64 + expiresAt time.Time +} + +type quotaRefreshExpiryHeap []quotaRefreshExpiry + +func (h quotaRefreshExpiryHeap) Len() int { return len(h) } +func (h quotaRefreshExpiryHeap) Less(i, j int) bool { return h[i].expiresAt.Before(h[j].expiresAt) } +func (h quotaRefreshExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *quotaRefreshExpiryHeap) Push(value any) { *h = append(*h, value.(quotaRefreshExpiry)) } +func (h *quotaRefreshExpiryHeap) Pop() any { + values := *h + last := values[len(values)-1] + *h = values[:len(values)-1] + return last +} + +func (c *QuotaRefreshCoordinator) MarkQuotaRefreshDirty(_ context.Context, accountID uint64, mode string, ttl time.Duration) (uint64, error) { + mode = strings.TrimSpace(mode) + if accountID == 0 || mode == "" || ttl <= 0 { + return 0, fmt.Errorf("quota refresh identity is invalid") + } + now := time.Now().UTC() + key := quotaRefreshKey(accountID, mode) + c.mu.Lock() + defer c.mu.Unlock() + c.pruneLocked(now) + state := c.values[key] + if _, dirty := c.dirty[key]; !dirty && len(c.dirty) >= maxQuotaRefreshDirty { + return 0, fmt.Errorf("quota refresh dirty set is full") + } + state.accountID = accountID + state.mode = mode + state.generation++ + state.expiresAt = now.Add(ttl) + state.dirty = true + c.values[key] = state + c.dirty[key] = struct{}{} + heap.Push(&c.expires, quotaRefreshExpiry{key: key, generation: state.generation, expiresAt: state.expiresAt}) + c.compactExpiryHeapLocked() + return state.generation, nil +} + +func (c *QuotaRefreshCoordinator) QuotaRefreshGeneration(_ context.Context, accountID uint64, mode string) (uint64, bool, error) { + now := time.Now().UTC() + key := quotaRefreshKey(accountID, strings.TrimSpace(mode)) + c.mu.Lock() + defer c.mu.Unlock() + state, ok := c.values[key] + if !ok || !now.Before(state.expiresAt) { + delete(c.values, key) + delete(c.dirty, key) + c.compactExpiryHeapLocked() + return 0, false, nil + } + return state.generation, state.dirty, nil +} + +func (c *QuotaRefreshCoordinator) ClearQuotaRefreshDirty(_ context.Context, accountID uint64, mode string, generation uint64) (bool, error) { + key := quotaRefreshKey(accountID, strings.TrimSpace(mode)) + now := time.Now().UTC() + c.mu.Lock() + defer c.mu.Unlock() + state, ok := c.values[key] + if !ok || !now.Before(state.expiresAt) { + delete(c.values, key) + delete(c.dirty, key) + c.compactExpiryHeapLocked() + return false, nil + } + if state.generation != generation { + return false, nil + } + state.dirty = false + c.values[key] = state + delete(c.dirty, key) + return true, nil +} + +func (c *QuotaRefreshCoordinator) ListQuotaRefreshDirty(_ context.Context, now time.Time, limit int) ([]repository.QuotaRefreshDirty, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + c.mu.Lock() + defer c.mu.Unlock() + c.pruneLocked(now) + values := make([]repository.QuotaRefreshDirty, 0, min(limit, len(c.dirty))) + for key := range c.dirty { + state, ok := c.values[key] + if !ok || !state.dirty || !now.Before(state.expiresAt) { + delete(c.dirty, key) + if ok && !now.Before(state.expiresAt) { + delete(c.values, key) + } + continue + } + values = append(values, repository.QuotaRefreshDirty{AccountID: state.accountID, Mode: state.mode, Generation: state.generation}) + } + sort.Slice(values, func(i, j int) bool { + if values[i].AccountID != values[j].AccountID { + return values[i].AccountID < values[j].AccountID + } + return values[i].Mode < values[j].Mode + }) + if len(values) > limit { + values = values[:limit] + } + c.compactExpiryHeapLocked() + return values, nil +} + +func (c *QuotaRefreshCoordinator) pruneLocked(now time.Time) { + for c.expires.Len() > 0 && !now.Before(c.expires[0].expiresAt) { + expired := heap.Pop(&c.expires).(quotaRefreshExpiry) + state, ok := c.values[expired.key] + if !ok || state.generation != expired.generation || !state.expiresAt.Equal(expired.expiresAt) { + continue + } + delete(c.values, expired.key) + delete(c.dirty, expired.key) + } +} + +// compactExpiryHeapLocked bounds stale generation entries without adding a +// second index. Rebuilding is amortized and retains exactly one expiry per key. +func (c *QuotaRefreshCoordinator) compactExpiryHeapLocked() { + if len(c.expires) <= len(c.values)*2+quotaRefreshExpiryCompactionMinStale { + return + } + values := make(quotaRefreshExpiryHeap, 0, len(c.values)) + for key, state := range c.values { + values = append(values, quotaRefreshExpiry{key: key, generation: state.generation, expiresAt: state.expiresAt}) + } + heap.Init(&values) + c.expires = values +} + +func quotaRefreshKey(accountID uint64, mode string) string { + return fmt.Sprintf("%d:%s", accountID, mode) +} diff --git a/backend/internal/infra/runtime/memory/quota_refresh_test.go b/backend/internal/infra/runtime/memory/quota_refresh_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f522eecb6a122668b1cc9fe9a54b4fd3b5472e67 --- /dev/null +++ b/backend/internal/infra/runtime/memory/quota_refresh_test.go @@ -0,0 +1,101 @@ +package memory + +import ( + "context" + "testing" + "time" +) + +func TestQuotaRefreshCoordinatorCompareAndClear(t *testing.T) { + coordinator := NewQuotaRefreshCoordinator() + ctx := context.Background() + first, err := coordinator.MarkQuotaRefreshDirty(ctx, 42, "fast", time.Hour) + if err != nil || first != 1 { + t.Fatalf("first generation = %d, err = %v", first, err) + } + second, err := coordinator.MarkQuotaRefreshDirty(ctx, 42, "fast", time.Hour) + if err != nil || second != 2 { + t.Fatalf("second generation = %d, err = %v", second, err) + } + if cleared, err := coordinator.ClearQuotaRefreshDirty(ctx, 42, "fast", first); err != nil || cleared { + t.Fatalf("stale clear = %v, err = %v", cleared, err) + } + if cleared, err := coordinator.ClearQuotaRefreshDirty(ctx, 42, "fast", second); err != nil || !cleared { + t.Fatalf("current clear = %v, err = %v", cleared, err) + } +} + +func TestQuotaRefreshCoordinatorExpiredLookupRemovesDirtyMembership(t *testing.T) { + coordinator := NewQuotaRefreshCoordinator() + ctx := context.Background() + + for _, accountID := range []uint64{41, 42} { + if _, err := coordinator.MarkQuotaRefreshDirty(ctx, accountID, "fast", time.Hour); err != nil { + t.Fatalf("mark account %d dirty: %v", accountID, err) + } + key := quotaRefreshKey(accountID, "fast") + state := coordinator.values[key] + state.expiresAt = time.Now().Add(-time.Second) + coordinator.values[key] = state + } + + if generation, dirty, err := coordinator.QuotaRefreshGeneration(ctx, 41, "fast"); err != nil || generation != 0 || dirty { + t.Fatalf("expired generation = %d, dirty = %v, err = %v", generation, dirty, err) + } + if cleared, err := coordinator.ClearQuotaRefreshDirty(ctx, 42, "fast", 1); err != nil || cleared { + t.Fatalf("expired clear = %v, err = %v", cleared, err) + } + if len(coordinator.dirty) != 0 { + t.Fatalf("expired dirty membership retained: %v", coordinator.dirty) + } +} + +func TestQuotaRefreshCoordinatorListExcludesClearedAndExpiredState(t *testing.T) { + coordinator := NewQuotaRefreshCoordinator() + ctx := context.Background() + + clearedGeneration, err := coordinator.MarkQuotaRefreshDirty(ctx, 1, "fast", time.Hour) + if err != nil { + t.Fatalf("mark cleared state: %v", err) + } + if cleared, err := coordinator.ClearQuotaRefreshDirty(ctx, 1, "fast", clearedGeneration); err != nil || !cleared { + t.Fatalf("clear state = %v, err = %v", cleared, err) + } + if _, err := coordinator.MarkQuotaRefreshDirty(ctx, 2, "fast", time.Hour); err != nil { + t.Fatalf("mark expired state: %v", err) + } + expiredKey := quotaRefreshKey(2, "fast") + expired := coordinator.values[expiredKey] + expired.expiresAt = time.Now().Add(-time.Second) + coordinator.values[expiredKey] = expired + + states, err := coordinator.ListQuotaRefreshDirty(ctx, time.Now(), 10) + if err != nil { + t.Fatalf("list dirty states: %v", err) + } + if len(states) != 0 { + t.Fatalf("listed cleared or expired states: %+v", states) + } +} + +func TestQuotaRefreshCoordinatorCompactsStaleExpiryGenerations(t *testing.T) { + coordinator := NewQuotaRefreshCoordinator() + ctx := context.Background() + const updates = 10000 + + for range updates { + if _, err := coordinator.MarkQuotaRefreshDirty(ctx, 42, "fast", 24*time.Hour); err != nil { + t.Fatal(err) + } + } + + if len(coordinator.values) != 1 || len(coordinator.dirty) != 1 { + t.Fatalf("coordinator state = values:%d dirty:%d", len(coordinator.values), len(coordinator.dirty)) + } + if got, maxExpected := len(coordinator.expires), quotaRefreshExpiryCompactionMinStale+2; got > maxExpected { + t.Fatalf("expiry heap retained %d stale generations, want at most %d", got, maxExpected) + } + if generation, dirty, err := coordinator.QuotaRefreshGeneration(ctx, 42, "fast"); err != nil || !dirty || generation != updates { + t.Fatalf("generation = %d, dirty = %v, err = %v", generation, dirty, err) + } +} diff --git a/backend/internal/infra/runtime/memory/reasoning_replay.go b/backend/internal/infra/runtime/memory/reasoning_replay.go new file mode 100644 index 0000000000000000000000000000000000000000..827657bb5de7d06570528a63bf167aba5cfe3b8d --- /dev/null +++ b/backend/internal/infra/runtime/memory/reasoning_replay.go @@ -0,0 +1,135 @@ +package memory + +import ( + "context" + "sort" + "sync" + "time" +) + +type reasoningReplayEntry struct { + items [][]byte + expiresAt time.Time + storedAt time.Time +} + +// ReasoningReplayStore 提供单实例有界推理回放缓存。 +type ReasoningReplayStore struct { + mu sync.Mutex + maxSize int + evictBatch int + values map[string]reasoningReplayEntry + ttlSlide bool +} + +// NewReasoningReplayStore 创建内存推理回放仓储;maxSize 为全局条目上限。 +func NewReasoningReplayStore(maxSize int) *ReasoningReplayStore { + if maxSize < 1 { + maxSize = 10240 + } + evictBatch := maxSize / 80 + if evictBatch < 1 { + evictBatch = 1 + } + if evictBatch > 128 { + evictBatch = 128 + } + return &ReasoningReplayStore{maxSize: maxSize, evictBatch: evictBatch, values: make(map[string]reasoningReplayEntry, maxSize), ttlSlide: true} +} + +func reasoningReplayMapKey(model, sessionKey string) string { + return model + "\x00" + sessionKey +} + +func cloneReplayItems(items [][]byte) [][]byte { + if len(items) == 0 { + return nil + } + cloned := make([][]byte, 0, len(items)) + for _, item := range items { + cloned = append(cloned, append([]byte(nil), item...)) + } + return cloned +} + +func (s *ReasoningReplayStore) Get(_ context.Context, model, sessionKey string, now time.Time, ttl time.Duration) ([][]byte, bool, error) { + if model == "" || sessionKey == "" { + return nil, false, nil + } + key := reasoningReplayMapKey(model, sessionKey) + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.values[key] + if !ok { + return nil, false, nil + } + if !now.Before(entry.expiresAt) { + delete(s.values, key) + return nil, false, nil + } + if s.ttlSlide { + if ttl <= 0 { + ttl = entry.expiresAt.Sub(entry.storedAt) + if ttl <= 0 { + ttl = time.Hour + } + } + entry.expiresAt = now.Add(ttl) + entry.storedAt = now + s.values[key] = entry + } + return cloneReplayItems(entry.items), true, nil +} + +func (s *ReasoningReplayStore) Set(_ context.Context, model, sessionKey string, items [][]byte, expiresAt time.Time) error { + if model == "" || sessionKey == "" || len(items) == 0 || expiresAt.IsZero() { + return nil + } + key := reasoningReplayMapKey(model, sessionKey) + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.values[key]; !exists { + s.evictLocked(now) + } + s.values[key] = reasoningReplayEntry{items: cloneReplayItems(items), expiresAt: expiresAt, storedAt: now} + return nil +} + +func (s *ReasoningReplayStore) Delete(_ context.Context, model, sessionKey string) error { + if model == "" || sessionKey == "" { + return nil + } + key := reasoningReplayMapKey(model, sessionKey) + s.mu.Lock() + delete(s.values, key) + s.mu.Unlock() + return nil +} + +func (s *ReasoningReplayStore) evictLocked(now time.Time) { + for key, entry := range s.values { + if !now.Before(entry.expiresAt) { + delete(s.values, key) + } + } + if len(s.values) < s.maxSize { + return + } + type candidate struct { + key string + storedAt time.Time + } + candidates := make([]candidate, 0, len(s.values)) + for key, entry := range s.values { + candidates = append(candidates, candidate{key: key, storedAt: entry.storedAt}) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].storedAt.Before(candidates[j].storedAt) }) + count := s.evictBatch + if count > len(candidates) { + count = len(candidates) + } + for index := 0; index < count; index++ { + delete(s.values, candidates[index].key) + } +} diff --git a/backend/internal/infra/runtime/memory/reasoning_replay_test.go b/backend/internal/infra/runtime/memory/reasoning_replay_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b4184bee7247e824129eb71a9cefdd4b95801724 --- /dev/null +++ b/backend/internal/infra/runtime/memory/reasoning_replay_test.go @@ -0,0 +1,59 @@ +package memory + +import ( + "context" + "testing" + "time" +) + +func TestReasoningReplayStoreSetGetDelete(t *testing.T) { + store := NewReasoningReplayStore(8) + ctx := context.Background() + now := time.Now().UTC() + items := [][]byte{[]byte(`{"type":"reasoning","encrypted_content":"abcdefghijklmnopqrstuvwxyz"}`)} + if err := store.Set(ctx, "grok", "sess", items, now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + got, ok, err := store.Get(ctx, "grok", "sess", now, time.Hour) + if err != nil || !ok || len(got) != 1 { + t.Fatalf("get = %v ok=%v err=%v", got, ok, err) + } + if err := store.Delete(ctx, "grok", "sess"); err != nil { + t.Fatal(err) + } + if _, ok, err := store.Get(ctx, "grok", "sess", now, time.Hour); err != nil || ok { + t.Fatalf("after delete ok=%v err=%v", ok, err) + } +} + +func TestReasoningReplayStoreEvictsOldest(t *testing.T) { + store := NewReasoningReplayStore(2) + ctx := context.Background() + now := time.Now().UTC() + _ = store.Set(ctx, "m", "a", [][]byte{[]byte("1")}, now.Add(time.Hour)) + time.Sleep(2 * time.Millisecond) + _ = store.Set(ctx, "m", "b", [][]byte{[]byte("2")}, now.Add(time.Hour)) + time.Sleep(2 * time.Millisecond) + _ = store.Set(ctx, "m", "c", [][]byte{[]byte("3")}, now.Add(time.Hour)) + if _, ok, _ := store.Get(ctx, "m", "a", now, time.Hour); ok { + t.Fatal("oldest entry should be evicted") + } + if _, ok, _ := store.Get(ctx, "m", "c", now, time.Hour); !ok { + t.Fatal("newest entry should remain") + } +} + +func TestReasoningReplayStoreHitRestoresConfiguredTTL(t *testing.T) { + store := NewReasoningReplayStore(8) + ctx := context.Background() + storedAt := time.Now() + if err := store.Set(ctx, "grok", "sess", [][]byte{[]byte("state")}, storedAt.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if _, ok, err := store.Get(ctx, "grok", "sess", storedAt.Add(30*time.Minute), 2*time.Hour); err != nil || !ok { + t.Fatalf("first get ok=%v err=%v", ok, err) + } + if _, ok, err := store.Get(ctx, "grok", "sess", storedAt.Add(90*time.Minute), 2*time.Hour); err != nil || !ok { + t.Fatalf("sliding ttl was not restored: ok=%v err=%v", ok, err) + } +} diff --git a/backend/internal/infra/runtime/memory/store.go b/backend/internal/infra/runtime/memory/store.go new file mode 100644 index 0000000000000000000000000000000000000000..29f05c9bac1bd8731e3cbe164d98bfcb93ac25ee --- /dev/null +++ b/backend/internal/infra/runtime/memory/store.go @@ -0,0 +1,418 @@ +package memory + +import ( + "context" + "crypto/rand" + "encoding/hex" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + maxEntries = 10000 + maxDeviceSessions = 1000 + shardCount = 64 +) + +type rateWindow struct { + startedAt time.Time + count int +} + +// RateLimiter 提供单实例固定分钟窗口限流。 +type RateLimiter struct { + shards [shardCount]rateShard +} + +type rateShard struct { + mu sync.Mutex + windows map[string]rateWindow +} + +func NewRateLimiter() *RateLimiter { + limiter := &RateLimiter{} + for index := range limiter.shards { + limiter.shards[index].windows = make(map[string]rateWindow) + } + return limiter +} + +func (r *RateLimiter) Allow(_ context.Context, key string, limit int, now time.Time) (bool, error) { + if limit <= 0 { + return true, nil + } + shard := &r.shards[shardIndex(key)] + shard.mu.Lock() + defer shard.mu.Unlock() + window := shard.windows[key] + if window.startedAt.IsZero() || now.Sub(window.startedAt) >= time.Minute { + window = rateWindow{startedAt: now, count: 0} + } + if window.count >= limit { + shard.windows[key] = window + return false, nil + } + window.count++ + shard.windows[key] = window + if len(shard.windows) > maxEntriesPerShard() { + cleanupRateShard(shard, now) + } + return true, nil +} + +func cleanupRateShard(shard *rateShard, now time.Time) { + for key, window := range shard.windows { + if now.Sub(window.startedAt) >= time.Minute { + delete(shard.windows, key) + } + } + for len(shard.windows) > maxEntriesPerShard() { + var oldestKey string + var oldest time.Time + for key, window := range shard.windows { + if oldestKey == "" || window.startedAt.Before(oldest) { + oldestKey = key + oldest = window.startedAt + } + } + delete(shard.windows, oldestKey) + } +} + +// ConcurrencyLimiter 提供单实例并发租约。 +type ConcurrencyLimiter struct { + shards [shardCount]concurrencyShard + indicesCache sync.Pool +} + +type concurrencyShard struct { + mu sync.Mutex + counts map[string]int +} + +func NewConcurrencyLimiter() *ConcurrencyLimiter { + limiter := &ConcurrencyLimiter{} + for index := range limiter.shards { + limiter.shards[index].counts = make(map[string]int) + } + limiter.indicesCache.New = func() any { return make([]int, 0, 256) } + return limiter +} + +func (l *ConcurrencyLimiter) Acquire(_ context.Context, key string, limit int) (func(), bool, error) { + if limit <= 0 { + return func() {}, true, nil + } + shard := &l.shards[shardIndex(key)] + shard.mu.Lock() + if shard.counts[key] >= limit { + shard.mu.Unlock() + return nil, false, nil + } + shard.counts[key]++ + shard.mu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + shard.mu.Lock() + defer shard.mu.Unlock() + shard.counts[key]-- + if shard.counts[key] <= 0 { + delete(shard.counts, key) + } + }) + }, true, nil +} + +func (l *ConcurrencyLimiter) Current(_ context.Context, key string) (int, error) { + shard := &l.shards[shardIndex(key)] + shard.mu.Lock() + defer shard.mu.Unlock() + return shard.counts[key], nil +} + +func (l *ConcurrencyLimiter) CurrentMany(_ context.Context, keys []string) (map[string]int, error) { + values := make(map[string]int, len(keys)) + if len(keys) == 0 { + return values, nil + } + + // 选号会一次读取整个候选池。先按分片聚合下标,避免同一分片在一次快照中 + // 被反复加锁数千次,并缩短高并发请求之间的锁竞争窗口。 + var counts [shardCount]int + for _, key := range keys { + counts[shardIndex(key)]++ + } + var offsets [shardCount + 1]int + for index := range shardCount { + offsets[index+1] = offsets[index] + counts[index] + } + cursors := offsets + grouped := l.indicesCache.Get().([]int) + if cap(grouped) < len(keys) { + grouped = make([]int, len(keys)) + } else { + grouped = grouped[:len(keys)] + } + defer func() { + // 不保留异常大的候选池缓冲,避免一次峰值长期占用进程内存。 + if cap(grouped) <= maxEntries { + l.indicesCache.Put(grouped[:0]) + } + }() + for keyIndex, key := range keys { + shard := int(shardIndex(key)) + grouped[cursors[shard]] = keyIndex + cursors[shard]++ + } + for shardIndex := range shardCount { + if offsets[shardIndex] == offsets[shardIndex+1] { + continue + } + shard := &l.shards[shardIndex] + shard.mu.Lock() + for _, keyIndex := range grouped[offsets[shardIndex]:offsets[shardIndex+1]] { + key := keys[keyIndex] + values[key] = shard.counts[key] + } + shard.mu.Unlock() + } + return values, nil +} + +type stickyBinding struct { + accountID uint64 + expiresAt time.Time +} + +// StickyStore 提供有界的单实例会话粘滞状态。 +type StickyStore struct { + shards [shardCount]stickyShard +} + +type stickyShard struct { + mu sync.Mutex + bindings map[string]stickyBinding +} + +func NewStickyStore() *StickyStore { + store := &StickyStore{} + for index := range store.shards { + store.shards[index].bindings = make(map[string]stickyBinding) + } + return store +} + +func (s *StickyStore) Get(_ context.Context, affinityKey string, now time.Time) (uint64, bool, error) { + shard := &s.shards[shardIndex(affinityKey)] + shard.mu.Lock() + defer shard.mu.Unlock() + binding, ok := shard.bindings[affinityKey] + if !ok { + return 0, false, nil + } + if !now.Before(binding.expiresAt) { + delete(shard.bindings, affinityKey) + return 0, false, nil + } + return binding.accountID, true, nil +} + +func (s *StickyStore) Bind(_ context.Context, affinityKey string, proposedAccountID uint64, now, expiresAt time.Time) (uint64, error) { + if affinityKey == "" || proposedAccountID == 0 || !now.Before(expiresAt) { + return proposedAccountID, nil + } + shard := &s.shards[shardIndex(affinityKey)] + shard.mu.Lock() + defer shard.mu.Unlock() + if binding, ok := shard.bindings[affinityKey]; ok && now.Before(binding.expiresAt) { + binding.expiresAt = expiresAt + shard.bindings[affinityKey] = binding + return binding.accountID, nil + } + shard.bindings[affinityKey] = stickyBinding{accountID: proposedAccountID, expiresAt: expiresAt} + pruneStickyBindingsLocked(shard, now) + return proposedAccountID, nil +} + +func (s *StickyStore) Set(_ context.Context, affinityKey string, accountID uint64, expiresAt time.Time) error { + if affinityKey == "" { + return nil + } + shard := &s.shards[shardIndex(affinityKey)] + shard.mu.Lock() + defer shard.mu.Unlock() + shard.bindings[affinityKey] = stickyBinding{accountID: accountID, expiresAt: expiresAt} + pruneStickyBindingsLocked(shard, time.Now()) + return nil +} + +func pruneStickyBindingsLocked(shard *stickyShard, now time.Time) { + if len(shard.bindings) > maxEntriesPerShard() { + for key, binding := range shard.bindings { + if !now.Before(binding.expiresAt) { + delete(shard.bindings, key) + } + } + for len(shard.bindings) > maxEntriesPerShard() { + var oldestKey string + var oldest time.Time + for key, binding := range shard.bindings { + if oldestKey == "" || binding.expiresAt.Before(oldest) { + oldestKey = key + oldest = binding.expiresAt + } + } + delete(shard.bindings, oldestKey) + } + } +} + +func (s *StickyStore) DeleteByAccount(_ context.Context, accountID uint64) error { + for index := range s.shards { + shard := &s.shards[index] + shard.mu.Lock() + for key, binding := range shard.bindings { + if binding.accountID == accountID { + delete(shard.bindings, key) + } + } + shard.mu.Unlock() + } + return nil +} + +func (s *StickyStore) DeleteByAccounts(_ context.Context, accountIDs []uint64) error { + ids := make(map[uint64]struct{}, len(accountIDs)) + for _, accountID := range accountIDs { + if accountID != 0 { + ids[accountID] = struct{}{} + } + } + if len(ids) == 0 { + return nil + } + for index := range s.shards { + shard := &s.shards[index] + shard.mu.Lock() + for key, binding := range shard.bindings { + if _, remove := ids[binding.accountID]; remove { + delete(shard.bindings, key) + } + } + shard.mu.Unlock() + } + return nil +} + +func shardIndex(key string) uint32 { + const offset32 = uint32(2166136261) + const prime32 = uint32(16777619) + hash := offset32 + for index := 0; index < len(key); index++ { + hash ^= uint32(key[index]) + hash *= prime32 + } + return hash % shardCount +} + +func maxEntriesPerShard() int { return (maxEntries + shardCount - 1) / shardCount } + +// DeviceSessionStore 保存不会跨重启恢复的短期 OAuth 会话。 +type DeviceSessionStore struct { + mu sync.Mutex + sessions map[string]account.DeviceSession +} + +func NewDeviceSessionStore() *DeviceSessionStore { + return &DeviceSessionStore{sessions: make(map[string]account.DeviceSession)} +} + +func (s *DeviceSessionStore) Create(_ context.Context, value account.DeviceSession) error { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + for id, session := range s.sessions { + if !now.Before(session.ExpiresAt) { + delete(s.sessions, id) + } + } + if _, exists := s.sessions[value.ID]; !exists && len(s.sessions) >= maxDeviceSessions { + var earliestID string + var earliestExpiry time.Time + for id, session := range s.sessions { + if earliestID == "" || session.ExpiresAt.Before(earliestExpiry) { + earliestID = id + earliestExpiry = session.ExpiresAt + } + } + delete(s.sessions, earliestID) + } + s.sessions[value.ID] = value + return nil +} + +func (s *DeviceSessionStore) Get(_ context.Context, id string, now time.Time) (account.DeviceSession, error) { + s.mu.Lock() + defer s.mu.Unlock() + value, ok := s.sessions[id] + if !ok || !now.Before(value.ExpiresAt) { + delete(s.sessions, id) + return account.DeviceSession{}, repository.ErrNotFound + } + return value, nil +} + +func (s *DeviceSessionStore) Update(_ context.Context, value account.DeviceSession) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.sessions[value.ID]; !ok { + return repository.ErrNotFound + } + s.sessions[value.ID] = value + return nil +} + +func (s *DeviceSessionStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) + return nil +} + +// LockStore 提供单实例非阻塞短期锁。 +type LockStore struct { + mu sync.Mutex + locks map[string]string +} + +func NewLockStore() *LockStore { return &LockStore{locks: make(map[string]string)} } + +func (s *LockStore) Acquire(_ context.Context, key string, _ time.Duration) (func(), bool, error) { + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, false, err + } + token := hex.EncodeToString(tokenBytes) + s.mu.Lock() + if _, exists := s.locks[key]; exists { + s.mu.Unlock() + return nil, false, nil + } + s.locks[key] = token + s.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + s.mu.Lock() + defer s.mu.Unlock() + if s.locks[key] == token { + delete(s.locks, key) + } + }) + }, true, nil +} diff --git a/backend/internal/infra/runtime/memory/store_test.go b/backend/internal/infra/runtime/memory/store_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e14999fbf1452714b7aff27fd45c40e491989448 --- /dev/null +++ b/backend/internal/infra/runtime/memory/store_test.go @@ -0,0 +1,153 @@ +package memory + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestRateAndConcurrencyLimits(t *testing.T) { + ctx := context.Background() + now := time.Now() + rate := NewRateLimiter() + if allowed, _ := rate.Allow(ctx, "key", 1, now); !allowed { + t.Fatal("第一次请求应被允许") + } + if allowed, _ := rate.Allow(ctx, "key", 1, now); allowed { + t.Fatal("同一分钟的第二次请求应被拒绝") + } + + concurrency := NewConcurrencyLimiter() + release, acquired, _ := concurrency.Acquire(ctx, "key", 1) + if !acquired { + t.Fatal("第一次并发租约应成功") + } + if _, acquired, _ := concurrency.Acquire(ctx, "key", 1); acquired { + t.Fatal("超过并发上限的租约不应成功") + } + release() + if _, acquired, _ := concurrency.Acquire(ctx, "key", 1); !acquired { + t.Fatal("释放后应能再次获取租约") + } + values, err := concurrency.CurrentMany(ctx, []string{"key", "missing"}) + if err != nil || values["key"] != 1 || values["missing"] != 0 { + t.Fatalf("并发快照 = %#v, err = %v", values, err) + } +} + +func BenchmarkConcurrencyLimiterCurrentMany(b *testing.B) { + ctx := context.Background() + limiter := NewConcurrencyLimiter() + keys := make([]string, 3000) + releases := make([]func(), 0, len(keys)) + for index := range keys { + key := fmt.Sprintf("account:%d", index+1) + keys[index] = key + release, acquired, err := limiter.Acquire(ctx, key, 8) + if err != nil || !acquired { + b.Fatalf("预置并发租约失败: acquired=%v err=%v", acquired, err) + } + releases = append(releases, release) + } + defer func() { + for _, release := range releases { + release() + } + }() + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(parallel *testing.PB) { + for parallel.Next() { + values, err := limiter.CurrentMany(ctx, keys) + if err != nil || len(values) != len(keys) { + b.Fatalf("并发快照数量 = %d, err = %v", len(values), err) + } + } + }) +} + +func TestStickyStoreExpires(t *testing.T) { + ctx := context.Background() + store := NewStickyStore() + now := time.Now() + if err := store.Set(ctx, "session", 42, now.Add(time.Second)); err != nil { + t.Fatal(err) + } + if id, ok, _ := store.Get(ctx, "session", now); !ok || id != 42 { + t.Fatalf("粘滞账号 = %d, %v", id, ok) + } + if _, ok, _ := store.Get(ctx, "session", now.Add(2*time.Second)); ok { + t.Fatal("过期粘滞绑定仍然可用") + } +} + +func TestStickyStoreBindPreservesExistingAccountAndRefreshesExpiry(t *testing.T) { + ctx := context.Background() + store := NewStickyStore() + now := time.Now().UTC() + bound, err := store.Bind(ctx, "session", 42, now, now.Add(time.Minute)) + if err != nil || bound != 42 { + t.Fatalf("first bind = %d, err = %v", bound, err) + } + bound, err = store.Bind(ctx, "session", 99, now.Add(30*time.Second), now.Add(90*time.Second)) + if err != nil || bound != 42 { + t.Fatalf("existing bind = %d, err = %v", bound, err) + } + if id, ok, err := store.Get(ctx, "session", now.Add(75*time.Second)); err != nil || !ok || id != 42 { + t.Fatalf("refreshed bind = %d, %v, err = %v", id, ok, err) + } + if _, ok, err := store.Get(ctx, "session", now.Add(100*time.Second)); err != nil || ok { + t.Fatalf("expired refreshed bind remains available: ok=%v err=%v", ok, err) + } +} + +func TestStickyStoreDeleteByAccountsScansEachShardOnce(t *testing.T) { + ctx := context.Background() + store := NewStickyStore() + expiresAt := time.Now().UTC().Add(time.Minute) + if err := store.Set(ctx, "first", 7, expiresAt); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, "second", 8, expiresAt); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, "keep", 9, expiresAt); err != nil { + t.Fatal(err) + } + if err := store.DeleteByAccounts(ctx, []uint64{7, 8, 8, 0}); err != nil { + t.Fatal(err) + } + for _, key := range []string{"first", "second"} { + if _, ok, err := store.Get(ctx, key, time.Now().UTC()); err != nil || ok { + t.Fatalf("deleted binding %q remains: ok=%v err=%v", key, ok, err) + } + } + if id, ok, err := store.Get(ctx, "keep", time.Now().UTC()); err != nil || !ok || id != 9 { + t.Fatalf("unrelated binding = %d, ok=%v err=%v", id, ok, err) + } +} + +func TestDeviceSessionStoreCleansExpiredAndStaysBounded(t *testing.T) { + ctx := context.Background() + store := NewDeviceSessionStore() + now := time.Now().UTC() + if err := store.Create(ctx, account.DeviceSession{ID: "expired", ExpiresAt: now.Add(-time.Minute)}); err != nil { + t.Fatal(err) + } + for index := range maxDeviceSessions + 1 { + id := fmt.Sprintf("session-%d", index) + if err := store.Create(ctx, account.DeviceSession{ID: id, ExpiresAt: now.Add(time.Duration(index+1) * time.Minute)}); err != nil { + t.Fatal(err) + } + } + if _, exists := store.sessions["expired"]; exists { + t.Fatal("expired device session was not removed") + } + if len(store.sessions) != maxDeviceSessions { + t.Fatalf("device sessions = %d, want %d", len(store.sessions), maxDeviceSessions) + } +} diff --git a/backend/internal/infra/runtime/redis/reasoning_replay.go b/backend/internal/infra/runtime/redis/reasoning_replay.go new file mode 100644 index 0000000000000000000000000000000000000000..11dccdf8242f711a02c4b315a011f979c3284fae --- /dev/null +++ b/backend/internal/infra/runtime/redis/reasoning_replay.go @@ -0,0 +1,89 @@ +package redis + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + redisclient "github.com/redis/go-redis/v9" +) + +// ReasoningReplayStore 基于 Redis 的推理回放仓储,支持多实例共享。 +type ReasoningReplayStore struct { + store *Store +} + +// NewReasoningReplayStore 包装已打开的运行态 Store。 +func NewReasoningReplayStore(store *Store) *ReasoningReplayStore { + return &ReasoningReplayStore{store: store} +} + +func (s *ReasoningReplayStore) redisKey(model, sessionKey string) string { + return s.store.key("reasoning-replay", hashKeyPart(model)+":"+hashKeyPart(sessionKey)) +} + +func hashKeyPart(value string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(value))) + return hex.EncodeToString(sum[:16]) +} + +func (s *ReasoningReplayStore) Get(ctx context.Context, model, sessionKey string, now time.Time, ttl time.Duration) ([][]byte, bool, error) { + if s == nil || s.store == nil || model == "" || sessionKey == "" { + return nil, false, nil + } + _ = now + key := s.redisKey(model, sessionKey) + raw, err := s.store.client.Get(ctx, key).Bytes() + if errors.Is(err, redisclient.Nil) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var items [][]byte + if err := json.Unmarshal(raw, &items); err != nil { + return nil, false, err + } + // 滑动续期:命中后恢复为完整配置 TTL。 + if ttl > 0 { + _ = s.store.client.Expire(ctx, key, ttl).Err() + } + return cloneItems(items), true, nil +} + +func (s *ReasoningReplayStore) Set(ctx context.Context, model, sessionKey string, items [][]byte, expiresAt time.Time) error { + if s == nil || s.store == nil || model == "" || sessionKey == "" || len(items) == 0 { + return nil + } + ttl := time.Until(expiresAt) + if ttl <= 0 { + return nil + } + raw, err := json.Marshal(items) + if err != nil { + return err + } + return s.store.client.Set(ctx, s.redisKey(model, sessionKey), raw, ttl).Err() +} + +func (s *ReasoningReplayStore) Delete(ctx context.Context, model, sessionKey string) error { + if s == nil || s.store == nil || model == "" || sessionKey == "" { + return nil + } + return s.store.client.Del(ctx, s.redisKey(model, sessionKey)).Err() +} + +func cloneItems(items [][]byte) [][]byte { + if len(items) == 0 { + return nil + } + cloned := make([][]byte, 0, len(items)) + for _, item := range items { + cloned = append(cloned, append([]byte(nil), item...)) + } + return cloned +} diff --git a/backend/internal/infra/runtime/redis/store.go b/backend/internal/infra/runtime/redis/store.go new file mode 100644 index 0000000000000000000000000000000000000000..c9bd7dbc42d2fb085697ce9b73e331e48bc57597 --- /dev/null +++ b/backend/internal/infra/runtime/redis/store.go @@ -0,0 +1,975 @@ +package redis + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/chenyme/grok2api/backend/internal/repository" + redisclient "github.com/redis/go-redis/v9" +) + +const ( + concurrencyLeaseGrace = time.Minute + concurrencyReleaseRetryInterval = 250 * time.Millisecond + concurrencyReleaseRetryTimeout = 3 * time.Second + concurrencyReleaseRetryBatchSize = 512 + concurrencyReleaseRetryQueueCapacity = 16384 + maxStickyBindingsPerAccount = 10000 + maxDeviceSessions = 1000 + maxQuotaRecoveryEvents = 100000 + maxQuotaRefreshDirty = 100000 + observedModelStateTTL = 30 * time.Minute + // At the per-account cap, one pipeline processes at most 80,000 members. + stickyDeletePipelineSize = 8 +) + +var rateScript = redisclient.NewScript(` +local current = redis.call('INCR', KEYS[1]) +if current == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end +if current > tonumber(ARGV[1]) then return 0 end +return 1 +`) + +var acquireLeaseScript = redisclient.NewScript(` +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) +if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end +redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4]) +redis.call('PEXPIRE', KEYS[1], ARGV[5]) +return 1 +`) + +var releaseLeaseScript = redisclient.NewScript(`return redis.call('ZREM', KEYS[1], ARGV[1])`) + +var releaseLockScript = redisclient.NewScript(` +if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end +return 0 +`) + +var setStickyScript = redisclient.NewScript(` +local old = redis.call('GET', KEYS[1]) +if old and old ~= ARGV[1] then redis.call('ZREM', ARGV[3] .. old, KEYS[1]) end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[4]) +redis.call('ZADD', KEYS[2], ARGV[5], KEYS[1]) +local excess = redis.call('ZCARD', KEYS[2]) - tonumber(ARGV[6]) +if excess > 0 then + local stale = redis.call('ZRANGE', KEYS[2], 0, excess - 1) + for _, key in ipairs(stale) do + if redis.call('GET', key) == ARGV[1] then redis.call('DEL', key) end + redis.call('ZREM', KEYS[2], key) + end +end +if redis.call('PTTL', KEYS[2]) < tonumber(ARGV[2]) then redis.call('PEXPIRE', KEYS[2], ARGV[2]) end +return 1 +`) + +var bindStickyScript = redisclient.NewScript(` +local selected = redis.call('GET', KEYS[1]) +if not selected then selected = ARGV[1] end +local accountSetKey = ARGV[3] .. selected +redis.call('SET', KEYS[1], selected, 'PX', ARGV[2]) +redis.call('ZREMRANGEBYSCORE', accountSetKey, '-inf', ARGV[4]) +redis.call('ZADD', accountSetKey, ARGV[5], KEYS[1]) +local excess = redis.call('ZCARD', accountSetKey) - tonumber(ARGV[6]) +if excess > 0 then + local stale = redis.call('ZRANGE', accountSetKey, 0, excess - 1) + for _, key in ipairs(stale) do + if redis.call('GET', key) == selected then redis.call('DEL', key) end + redis.call('ZREM', accountSetKey, key) + end +end +if redis.call('PTTL', accountSetKey) < tonumber(ARGV[2]) then redis.call('PEXPIRE', accountSetKey, ARGV[2]) end +return selected +`) + +var deleteStickyByAccountScript = redisclient.NewScript(` +local members = redis.call('ZRANGE', KEYS[1], 0, -1) +local deleted = 0 +for _, key in ipairs(members) do + if redis.call('GET', key) == ARGV[1] then + deleted = deleted + redis.call('DEL', key) + end +end +redis.call('DEL', KEYS[1]) +return deleted +`) + +var createDeviceSessionScript = redisclient.NewScript(` +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[2]) +if redis.call('ZCARD', KEYS[2]) >= tonumber(ARGV[4]) then return 0 end +if not redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3], 'NX') then return -1 end +redis.call('ZADD', KEYS[2], ARGV[5], KEYS[1]) +if redis.call('PTTL', KEYS[2]) < tonumber(ARGV[3]) then redis.call('PEXPIRE', KEYS[2], ARGV[3]) end +return 1 +`) + +var updateDeviceSessionScript = redisclient.NewScript(` +if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2], 'XX') +redis.call('ZADD', KEYS[2], ARGV[3], KEYS[1]) +if redis.call('PTTL', KEYS[2]) < tonumber(ARGV[2]) then redis.call('PEXPIRE', KEYS[2], ARGV[2]) end +return 1 +`) + +var deleteDeviceSessionScript = redisclient.NewScript(` +redis.call('ZREM', KEYS[2], KEYS[1]) +return redis.call('DEL', KEYS[1]) +`) + +var scheduleQuotaRecoveryScript = redisclient.NewScript(` +if not redis.call('ZSCORE', KEYS[1], ARGV[1]) and redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[4]) then return 0 end +redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) +redis.call('HSET', KEYS[2], ARGV[1], ARGV[3]) +redis.call('HDEL', KEYS[3], ARGV[1]) +return 1 +`) + +var ensureQuotaRecoveryScript = redisclient.NewScript(` +if redis.call('ZSCORE', KEYS[1], ARGV[1]) then return 2 end +if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[4]) then return 0 end +redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) +redis.call('HSET', KEYS[2], ARGV[1], ARGV[3]) +return 1 +`) + +var claimQuotaRecoveryScript = redisclient.NewScript(` +local values = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) +local result = {} +for _, value in ipairs(values) do + redis.call('ZADD', KEYS[1], ARGV[3], value) + redis.call('HSET', KEYS[3], value, ARGV[4]) + table.insert(result, value) + table.insert(result, redis.call('HGET', KEYS[2], value) or '0') + table.insert(result, ARGV[4]) +end +return result +`) + +var ackQuotaRecoveryScript = redisclient.NewScript(` +if redis.call('HGET', KEYS[3], ARGV[1]) ~= ARGV[2] then return 0 end +redis.call('HDEL', KEYS[2], ARGV[1]) +redis.call('HDEL', KEYS[3], ARGV[1]) +return redis.call('ZREM', KEYS[1], ARGV[1]) +`) + +var markQuotaRefreshDirtyScript = redisclient.NewScript(` +local now = tonumber(ARGV[4]) +local expired = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 1000) +for _, member in ipairs(expired) do + redis.call('ZREM', KEYS[3], member) + redis.call('ZREM', KEYS[2], member) + redis.call('HDEL', KEYS[1], member) +end +local memberExpires = redis.call('ZSCORE', KEYS[3], ARGV[1]) +if memberExpires and tonumber(memberExpires) <= now then + redis.call('ZREM', KEYS[3], ARGV[1]) + redis.call('ZREM', KEYS[2], ARGV[1]) + redis.call('HDEL', KEYS[1], ARGV[1]) +end +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now) +if not redis.call('ZSCORE', KEYS[2], ARGV[1]) and redis.call('ZCARD', KEYS[2]) >= tonumber(ARGV[3]) then return 0 end +local generation = redis.call('HINCRBY', KEYS[1], ARGV[1], 1) +redis.call('ZADD', KEYS[2], ARGV[2], ARGV[1]) +redis.call('ZADD', KEYS[3], ARGV[2], ARGV[1]) +local latest = redis.call('ZREVRANGE', KEYS[3], 0, 0, 'WITHSCORES') +if #latest == 2 then + redis.call('PEXPIREAT', KEYS[1], latest[2]) + redis.call('PEXPIREAT', KEYS[2], latest[2]) + redis.call('PEXPIREAT', KEYS[3], latest[2]) +end +return generation +`) + +var clearQuotaRefreshDirtyScript = redisclient.NewScript(` +local retentionExpires = redis.call('ZSCORE', KEYS[3], ARGV[1]) +if not retentionExpires or tonumber(retentionExpires) <= tonumber(ARGV[3]) then + redis.call('ZREM', KEYS[3], ARGV[1]) + redis.call('ZREM', KEYS[2], ARGV[1]) + redis.call('HDEL', KEYS[1], ARGV[1]) + return 0 +end +local dirtyExpires = redis.call('ZSCORE', KEYS[2], ARGV[1]) +if not dirtyExpires or tonumber(dirtyExpires) <= tonumber(ARGV[3]) then + redis.call('ZREM', KEYS[2], ARGV[1]) + return 0 +end +if tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0') ~= tonumber(ARGV[2]) then return 0 end +redis.call('ZREM', KEYS[2], ARGV[1]) +return 1 +`) + +var listQuotaRefreshDirtyScript = redisclient.NewScript(` +local limit = tonumber(ARGV[2]) +local now = tonumber(ARGV[1]) +local expired = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 1000) +for _, member in ipairs(expired) do + redis.call('ZREM', KEYS[3], member) + redis.call('ZREM', KEYS[2], member) + redis.call('HDEL', KEYS[1], member) +end +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now) +local members = redis.call('ZRANGEBYSCORE', KEYS[2], '(' .. ARGV[1], '+inf', 'LIMIT', 0, limit) +local result = {} +for _, member in ipairs(members) do + local retentionExpires = redis.call('ZSCORE', KEYS[3], member) + local generation = redis.call('HGET', KEYS[1], member) + if retentionExpires and tonumber(retentionExpires) > now and generation then + table.insert(result, member) + table.insert(result, generation) + end +end +return result +`) + +var setObservedModelStateScript = redisclient.NewScript(` +local previous = redis.call('HGET', KEYS[1], 'observed_at') +if previous and tonumber(previous) > tonumber(ARGV[2]) then return 0 end +redis.call('HSET', KEYS[1], 'model', ARGV[1], 'observed_at', ARGV[2]) +redis.call('PEXPIRE', KEYS[1], ARGV[3]) +return 1 +`) + +var quotaRefreshStateScript = redisclient.NewScript(` +local generation = redis.call('HGET', KEYS[1], ARGV[1]) or '0' +local retentionExpires = redis.call('ZSCORE', KEYS[3], ARGV[1]) +if not retentionExpires or tonumber(retentionExpires) <= tonumber(ARGV[2]) then + redis.call('ZREM', KEYS[3], ARGV[1]) + redis.call('ZREM', KEYS[2], ARGV[1]) + redis.call('HDEL', KEYS[1], ARGV[1]) + generation = '0' + return {generation, '0'} +end +local dirtyExpires = redis.call('ZSCORE', KEYS[2], ARGV[1]) +local dirty = '0' +if dirtyExpires and tonumber(dirtyExpires) > tonumber(ARGV[2]) then + dirty = '1' +elseif dirtyExpires then + redis.call('ZREM', KEYS[2], ARGV[1]) +end +return {generation, dirty} +`) + +var rescheduleQuotaRecoveryScript = redisclient.NewScript(` +if redis.call('HGET', KEYS[3], ARGV[1]) ~= ARGV[4] then return 0 end +redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) +redis.call('HSET', KEYS[2], ARGV[1], ARGV[3]) +redis.call('HDEL', KEYS[3], ARGV[1]) +return 1 +`) + +// Config 表示 Redis 运行态存储的启动配置。 +type Config struct { + Address string + Username string + Password string + Database int + KeyPrefix string + TLS bool + ConcurrencyLease time.Duration +} + +// Store 实现多实例共享的限流、并发租约、粘滞路由、Device OAuth 会话和分布式锁。 +type Store struct { + client *redisclient.Client + prefix string + concurrencyLease time.Duration + concurrencyReleaseQueue chan concurrencyReleaseRetry + concurrencyReleaseStop chan struct{} + concurrencyReleaseDone chan struct{} + closeOnce sync.Once + closeErr error +} + +type concurrencyReleaseRetry struct { + redisKey string + token string + expiresAt time.Time +} + +// Open 连接 Redis;选中的 Redis 不可用时直接返回启动错误。 +func Open(ctx context.Context, cfg Config) (*Store, error) { + options := &redisclient.Options{Addr: cfg.Address, Username: cfg.Username, Password: cfg.Password, DB: cfg.Database} + if cfg.TLS { + options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + client := redisclient.NewClient(options) + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() + return nil, fmt.Errorf("连接 Redis: %w", err) + } + lease := cfg.ConcurrencyLease + if lease <= 0 { + lease = 3 * time.Hour + } + store := &Store{ + client: client, prefix: cfg.KeyPrefix, concurrencyLease: lease, + concurrencyReleaseQueue: make(chan concurrencyReleaseRetry, concurrencyReleaseRetryQueueCapacity), + concurrencyReleaseStop: make(chan struct{}), concurrencyReleaseDone: make(chan struct{}), + } + go store.runConcurrencyReleaseRetries() + return store, nil +} + +func (s *Store) Close() error { + s.closeOnce.Do(func() { + close(s.concurrencyReleaseStop) + s.closeErr = s.client.Close() + <-s.concurrencyReleaseDone + }) + return s.closeErr +} + +func (s *Store) Ping(ctx context.Context) error { return s.client.Ping(ctx).Err() } + +func (s *Store) key(namespace, key string) string { return s.prefix + namespace + ":" + key } + +func (s *Store) GetObservedModelState(ctx context.Context, accountID uint64) (repository.ObservedModelState, bool, error) { + if accountID == 0 { + return repository.ObservedModelState{}, false, nil + } + values, err := s.client.HMGet(ctx, s.key("observed-model", strconv.FormatUint(accountID, 10)), "model", "observed_at").Result() + if err != nil { + return repository.ObservedModelState{}, false, err + } + if len(values) != 2 || values[0] == nil || values[1] == nil { + return repository.ObservedModelState{}, false, nil + } + model, ok := values[0].(string) + if !ok || strings.TrimSpace(model) == "" { + return repository.ObservedModelState{}, false, nil + } + observedMillis, err := strconv.ParseInt(fmt.Sprint(values[1]), 10, 64) + if err != nil || observedMillis <= 0 { + return repository.ObservedModelState{}, false, nil + } + return repository.ObservedModelState{Model: model, ObservedAt: time.UnixMilli(observedMillis).UTC()}, true, nil +} + +func (s *Store) SetObservedModelState(ctx context.Context, accountID uint64, value repository.ObservedModelState, ttl time.Duration) error { + if accountID == 0 || strings.TrimSpace(value.Model) == "" || value.ObservedAt.IsZero() { + return nil + } + if ttl <= 0 { + ttl = observedModelStateTTL + } + return setObservedModelStateScript.Run(ctx, s.client, + []string{s.key("observed-model", strconv.FormatUint(accountID, 10))}, + strings.TrimSpace(value.Model), value.ObservedAt.UTC().UnixMilli(), ttl.Milliseconds()).Err() +} + +// PublishSettingsChanged 发布运行设置失效通知,不在 Redis 中复制设置内容。 +func (s *Store) PublishSettingsChanged(ctx context.Context) error { + return s.client.Publish(ctx, s.key("events", "settings"), "reload").Err() +} + +func (s *Store) PublishInvalidation(ctx context.Context, event repository.InvalidationEvent) error { + if !event.Valid() { + return errors.New("invalid invalidation event") + } + if event.PublishedAt.IsZero() { + event.PublishedAt = time.Now().UTC() + } + revision, err := s.client.Incr(ctx, s.key("invalidation-revision", string(event.Layer())+":"+string(event.Provider))).Result() + if err != nil { + return err + } + event.Revision = uint64(revision) + payload, err := json.Marshal(event) + if err != nil { + return err + } + return s.client.Publish(ctx, s.key("events", "invalidation"), payload).Err() +} + +func (s *Store) ListenInvalidations(ctx context.Context, handler func(context.Context, repository.InvalidationEvent) error) error { + pubsub := s.client.Subscribe(ctx, s.key("events", "invalidation")) + defer pubsub.Close() + if _, err := pubsub.Receive(ctx); err != nil { + return err + } + channel := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return nil + case message, ok := <-channel: + if !ok { + return errors.New("Redis invalidation channel closed") + } + var event repository.InvalidationEvent + if err := json.Unmarshal([]byte(message.Payload), &event); err != nil || !event.Valid() { + continue + } + if err := handler(ctx, event); err != nil { + return err + } + } + } +} + +// ListenSettingsChanges 监听设置变更并调用重载函数,go-redis 会在连接中断后自动重连。 +func (s *Store) ListenSettingsChanges(ctx context.Context, handler func(context.Context) error) error { + pubsub := s.client.Subscribe(ctx, s.key("events", "settings")) + defer pubsub.Close() + if _, err := pubsub.Receive(ctx); err != nil { + return err + } + channel := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return nil + case _, ok := <-channel: + if !ok { + return errors.New("Redis 设置通知通道已关闭") + } + if err := handler(ctx); err != nil { + return err + } + } + } +} + +func (s *Store) Allow(ctx context.Context, key string, limit int, _ time.Time) (bool, error) { + if limit <= 0 { + return true, nil + } + result, err := rateScript.Run(ctx, s.client, []string{s.key("rate", key)}, limit, time.Minute.Milliseconds()).Int() + return result == 1, err +} + +func (s *Store) acquireConcurrency(ctx context.Context, key string, limit int) (func(), bool, error) { + if limit <= 0 { + return func() {}, true, nil + } + token, err := randomToken() + if err != nil { + return nil, false, err + } + now := time.Now().UTC() + expiresAt := now.Add(s.concurrencyLease) + redisKey := s.key("concurrency", key) + result, err := acquireLeaseScript.Run(ctx, s.client, []string{redisKey}, now.UnixMilli(), limit, expiresAt.UnixMilli(), token, (s.concurrencyLease + concurrencyLeaseGrace).Milliseconds()).Int() + if err != nil || result != 1 { + return nil, false, err + } + var once sync.Once + return func() { + once.Do(func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), concurrencyReleaseRetryTimeout) + defer cancel() + if err := releaseLeaseScript.Run(releaseCtx, s.client, []string{redisKey}, token).Err(); err != nil { + s.enqueueConcurrencyReleaseRetry(concurrencyReleaseRetry{redisKey: redisKey, token: token, expiresAt: expiresAt}) + } + }) + }, true, nil +} + +func (s *Store) enqueueConcurrencyReleaseRetry(value concurrencyReleaseRetry) { + select { + case <-s.concurrencyReleaseStop: + observeConcurrencyRelease("shutdown", 1) + return + default: + } + select { + case s.concurrencyReleaseQueue <- value: + observeConcurrencyRelease("queued", 1) + case <-s.concurrencyReleaseStop: + observeConcurrencyRelease("shutdown", 1) + default: + observeConcurrencyRelease("dropped", 1) + } +} + +func (s *Store) runConcurrencyReleaseRetries() { + defer close(s.concurrencyReleaseDone) + ticker := time.NewTicker(concurrencyReleaseRetryInterval) + defer ticker.Stop() + pending := make(map[string]concurrencyReleaseRetry) + for { + select { + case value := <-s.concurrencyReleaseQueue: + pending[value.token] = value + case <-ticker.C: + s.retryConcurrencyReleases(pending) + case <-s.concurrencyReleaseStop: + observeConcurrencyRelease("shutdown", len(pending)+len(s.concurrencyReleaseQueue)) + return + } + } +} + +func (s *Store) retryConcurrencyReleases(pending map[string]concurrencyReleaseRetry) { + if len(pending) == 0 { + return + } + now := time.Now().UTC() + expired := 0 + ctx, cancel := context.WithTimeout(context.Background(), concurrencyReleaseRetryTimeout) + defer cancel() + pipeline := s.client.Pipeline() + type queuedRelease struct { + token string + command *redisclient.IntCmd + } + commands := make([]queuedRelease, 0, min(len(pending), concurrencyReleaseRetryBatchSize)) + for token, value := range pending { + if !now.Before(value.expiresAt) { + delete(pending, token) + expired++ + continue + } + commands = append(commands, queuedRelease{token: token, command: pipeline.ZRem(ctx, value.redisKey, value.token)}) + if len(commands) >= concurrencyReleaseRetryBatchSize { + break + } + } + if len(commands) == 0 { + observeConcurrencyRelease("expired", expired) + return + } + _, _ = pipeline.Exec(ctx) + recovered := 0 + failed := 0 + for _, value := range commands { + if value.command.Err() == nil { + delete(pending, value.token) + recovered++ + } else { + failed++ + } + } + observeConcurrencyRelease("expired", expired) + observeConcurrencyRelease("recovered", recovered) + observeConcurrencyRelease("retry_failed", failed) +} + +func observeConcurrencyRelease(outcome string, count int) { + if count <= 0 { + return + } + perfmetrics.Default.Add("runtime_concurrency_release_total", perfmetrics.Labels{ + Subsystem: "runtime", Operation: "concurrency_lease", Stage: "release", Outcome: outcome, + }, int64(count)) +} + +func (s *Store) Current(ctx context.Context, key string) (int, error) { + redisKey := s.key("concurrency", key) + now := time.Now().UTC().UnixMilli() + pipe := s.client.TxPipeline() + pipe.ZRemRangeByScore(ctx, redisKey, "-inf", strconv.FormatInt(now, 10)) + count := pipe.ZCard(ctx, redisKey) + if _, err := pipe.Exec(ctx); err != nil { + return 0, err + } + return int(count.Val()), nil +} + +func (s *Store) CurrentMany(ctx context.Context, keys []string) (map[string]int, error) { + values := make(map[string]int, len(keys)) + if len(keys) == 0 { + return values, nil + } + now := "(" + strconv.FormatInt(time.Now().UTC().UnixMilli(), 10) + pipe := s.client.Pipeline() + counts := make(map[string]*redisclient.IntCmd, len(keys)) + for _, key := range keys { + redisKey := s.key("concurrency", key) + counts[key] = pipe.ZCount(ctx, redisKey, now, "+inf") + } + if _, err := pipe.Exec(ctx); err != nil { + return nil, err + } + for key, count := range counts { + values[key] = int(count.Val()) + } + return values, nil +} + +func (s *Store) Get(ctx context.Context, key string, now time.Time) (uint64, bool, error) { + value, err := s.client.Get(ctx, s.key("sticky", key)).Result() + if errors.Is(err, redisclient.Nil) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + id, err := strconv.ParseUint(value, 10, 64) + return id, err == nil, err +} + +func (s *Store) Bind(ctx context.Context, key string, proposedAccountID uint64, now, expiresAt time.Time) (uint64, error) { + if key == "" || proposedAccountID == 0 { + return proposedAccountID, nil + } + ttl := time.Until(expiresAt) + if ttl <= 0 { + return proposedAccountID, nil + } + id := strconv.FormatUint(proposedAccountID, 10) + value, err := bindStickyScript.Run( + ctx, + s.client, + []string{s.key("sticky", key)}, + id, + ttl.Milliseconds(), + s.prefix+"sticky-account:", + now.UnixMilli(), + expiresAt.UnixMilli(), + maxStickyBindingsPerAccount, + ).Uint64() + if err != nil { + return 0, err + } + return value, nil +} + +func (s *Store) Set(ctx context.Context, key string, accountID uint64, expiresAt time.Time) error { + ttl := time.Until(expiresAt) + if ttl <= 0 { + return nil + } + id := strconv.FormatUint(accountID, 10) + bindingKey := s.key("sticky", key) + accountSetPrefix := s.prefix + "sticky-account:" + accountSetKey := accountSetPrefix + id + now := time.Now().UTC() + return setStickyScript.Run(ctx, s.client, []string{bindingKey, accountSetKey}, id, ttl.Milliseconds(), accountSetPrefix, now.UnixMilli(), expiresAt.UnixMilli(), maxStickyBindingsPerAccount).Err() +} + +func (s *Store) DeleteByAccount(ctx context.Context, accountID uint64) error { + id := strconv.FormatUint(accountID, 10) + return deleteStickyByAccountScript.Run(ctx, s.client, []string{s.key("sticky-account", id)}, id).Err() +} + +func (s *Store) DeleteByAccounts(ctx context.Context, accountIDs []uint64) error { + seen := make(map[uint64]struct{}, len(accountIDs)) + ids := make([]uint64, 0, len(accountIDs)) + for _, accountID := range accountIDs { + if accountID == 0 { + continue + } + if _, exists := seen[accountID]; exists { + continue + } + seen[accountID] = struct{}{} + ids = append(ids, accountID) + } + for start := 0; start < len(ids); start += stickyDeletePipelineSize { + end := min(start+stickyDeletePipelineSize, len(ids)) + _, err := s.client.Pipelined(ctx, func(pipe redisclient.Pipeliner) error { + for _, accountID := range ids[start:end] { + id := strconv.FormatUint(accountID, 10) + // EVAL avoids a NOSCRIPT fallback round trip inside the pipeline. Each + // script remains bounded to one account so bulk maintenance cannot + // monopolize the shared Redis event loop with one large Lua call. + deleteStickyByAccountScript.Eval(ctx, pipe, []string{s.key("sticky-account", id)}, id) + } + return nil + }) + if err != nil { + return err + } + } + return nil +} + +func (s *Store) ScheduleQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error { + if value.AccountID == 0 || value.Mode == "" || value.DueAt.IsZero() { + return fmt.Errorf("额度恢复事件无效") + } + member := strconv.FormatUint(value.AccountID, 10) + ":" + value.Mode + result, err := scheduleQuotaRecoveryScript.Run(ctx, s.client, []string{s.key("quota-recovery", "events"), s.key("quota-recovery", "attempts"), s.key("quota-recovery", "claims")}, member, value.DueAt.UnixMilli(), max(0, value.Attempts), maxQuotaRecoveryEvents).Int() + if err != nil { + return err + } + if result == 0 { + return fmt.Errorf("额度恢复队列已满") + } + return nil +} + +func (s *Store) MarkQuotaRefreshDirty(ctx context.Context, accountID uint64, mode string, ttl time.Duration) (uint64, error) { + mode = strings.TrimSpace(mode) + if accountID == 0 || mode == "" || ttl <= 0 { + return 0, fmt.Errorf("quota refresh identity is invalid") + } + member := strconv.FormatUint(accountID, 10) + ":" + mode + now := time.Now().UTC() + expiresAt := now.Add(ttl) + generation, err := markQuotaRefreshDirtyScript.Run(ctx, s.client, + []string{s.key("quota-refresh", "generations"), s.key("quota-refresh", "dirty"), s.key("quota-refresh", "expiry")}, + member, expiresAt.UnixMilli(), maxQuotaRefreshDirty, now.UnixMilli(), + ).Uint64() + if err != nil { + return 0, err + } + if generation == 0 { + return 0, fmt.Errorf("quota refresh dirty set is full") + } + return generation, nil +} + +func (s *Store) QuotaRefreshGeneration(ctx context.Context, accountID uint64, mode string) (uint64, bool, error) { + member := strconv.FormatUint(accountID, 10) + ":" + strings.TrimSpace(mode) + values, err := quotaRefreshStateScript.Run(ctx, s.client, + []string{s.key("quota-refresh", "generations"), s.key("quota-refresh", "dirty"), s.key("quota-refresh", "expiry")}, member, time.Now().UTC().UnixMilli(), + ).StringSlice() + if err != nil { + return 0, false, err + } + if len(values) != 2 { + return 0, false, fmt.Errorf("quota refresh state response is invalid") + } + generation, err := strconv.ParseUint(values[0], 10, 64) + return generation, values[1] == "1", err +} + +func (s *Store) ClearQuotaRefreshDirty(ctx context.Context, accountID uint64, mode string, generation uint64) (bool, error) { + member := strconv.FormatUint(accountID, 10) + ":" + strings.TrimSpace(mode) + result, err := clearQuotaRefreshDirtyScript.Run(ctx, s.client, + []string{s.key("quota-refresh", "generations"), s.key("quota-refresh", "dirty"), s.key("quota-refresh", "expiry")}, + member, generation, time.Now().UTC().UnixMilli(), + ).Int() + return result == 1, err +} + +func (s *Store) ListQuotaRefreshDirty(ctx context.Context, now time.Time, limit int) ([]repository.QuotaRefreshDirty, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + values, err := listQuotaRefreshDirtyScript.Run(ctx, s.client, + []string{s.key("quota-refresh", "generations"), s.key("quota-refresh", "dirty"), s.key("quota-refresh", "expiry")}, + now.UnixMilli(), limit, + ).StringSlice() + if err != nil { + return nil, err + } + result := make([]repository.QuotaRefreshDirty, 0, min(limit, len(values)/2)) + for index := 0; index+1 < len(values); index += 2 { + member := values[index] + separator := strings.IndexByte(member, ':') + if separator <= 0 || separator == len(member)-1 { + continue + } + accountID, parseErr := strconv.ParseUint(member[:separator], 10, 64) + generation, generationErr := strconv.ParseUint(values[index+1], 10, 64) + if parseErr != nil || generationErr != nil || accountID == 0 || generation == 0 { + continue + } + result = append(result, repository.QuotaRefreshDirty{AccountID: accountID, Mode: member[separator+1:], Generation: generation}) + } + return result, nil +} + +func (s *Store) EnsureQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error { + if value.AccountID == 0 || value.Mode == "" || value.DueAt.IsZero() { + return fmt.Errorf("额度恢复事件无效") + } + member := strconv.FormatUint(value.AccountID, 10) + ":" + value.Mode + result, err := ensureQuotaRecoveryScript.Run(ctx, s.client, []string{s.key("quota-recovery", "events"), s.key("quota-recovery", "attempts")}, member, value.DueAt.UnixMilli(), max(0, value.Attempts), maxQuotaRecoveryEvents).Int() + if err != nil { + return err + } + if result == 0 { + return fmt.Errorf("额度恢复队列已满") + } + return nil +} + +func (s *Store) ClaimDueQuotaRecoveries(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]account.QuotaRecoveryEvent, error) { + if limit <= 0 { + limit = 100 + } + claimToken, err := randomToken() + if err != nil { + return nil, err + } + values, err := claimQuotaRecoveryScript.Run(ctx, s.client, []string{s.key("quota-recovery", "events"), s.key("quota-recovery", "attempts"), s.key("quota-recovery", "claims")}, now.UnixMilli(), limit, now.Add(lease).UnixMilli(), claimToken).StringSlice() + if err != nil { + return nil, err + } + result := make([]account.QuotaRecoveryEvent, 0, len(values)/3) + for index := 0; index+2 < len(values); index += 3 { + raw := values[index] + idText, mode, ok := strings.Cut(raw, ":") + id, parseErr := strconv.ParseUint(idText, 10, 64) + attempts, attemptsErr := strconv.Atoi(values[index+1]) + if ok && parseErr == nil && id > 0 && mode != "" { + if attemptsErr != nil || attempts < 0 { + attempts = 0 + } + result = append(result, account.QuotaRecoveryEvent{AccountID: id, Mode: mode, DueAt: now, Attempts: attempts, ClaimToken: values[index+2]}) + } + } + return result, nil +} + +func (s *Store) AckQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error { + member := strconv.FormatUint(value.AccountID, 10) + ":" + value.Mode + result, err := ackQuotaRecoveryScript.Run(ctx, s.client, []string{s.key("quota-recovery", "events"), s.key("quota-recovery", "attempts"), s.key("quota-recovery", "claims")}, member, value.ClaimToken).Int() + if err != nil { + return err + } + if result == 0 { + return repository.ErrConflict + } + return nil +} + +func (s *Store) RescheduleQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error { + member := strconv.FormatUint(value.AccountID, 10) + ":" + value.Mode + result, err := rescheduleQuotaRecoveryScript.Run(ctx, s.client, []string{s.key("quota-recovery", "events"), s.key("quota-recovery", "attempts"), s.key("quota-recovery", "claims")}, member, value.DueAt.UnixMilli(), max(0, value.Attempts), value.ClaimToken).Int() + if err != nil { + return err + } + if result == 0 { + return repository.ErrConflict + } + return nil +} + +func (s *Store) Create(ctx context.Context, value account.DeviceSession) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + ttl := time.Until(value.ExpiresAt) + if ttl <= 0 { + return repository.ErrNotFound + } + now := time.Now().UTC() + result, err := createDeviceSessionScript.Run(ctx, s.client, []string{s.key("device", value.ID), s.key("device-index", "sessions")}, payload, now.UnixMilli(), ttl.Milliseconds(), maxDeviceSessions, value.ExpiresAt.UnixMilli()).Int() + if err != nil { + return err + } + if result != 1 { + return repository.ErrConflict + } + return nil +} + +func (s *Store) GetDevice(ctx context.Context, id string, now time.Time) (account.DeviceSession, error) { + payload, err := s.client.Get(ctx, s.key("device", id)).Bytes() + if errors.Is(err, redisclient.Nil) { + return account.DeviceSession{}, repository.ErrNotFound + } + if err != nil { + return account.DeviceSession{}, err + } + var value account.DeviceSession + if err := json.Unmarshal(payload, &value); err != nil { + return account.DeviceSession{}, err + } + if !now.Before(value.ExpiresAt) { + _ = deleteDeviceSessionScript.Run(ctx, s.client, []string{s.key("device", id), s.key("device-index", "sessions")}).Err() + return account.DeviceSession{}, repository.ErrNotFound + } + return value, nil +} + +func (s *Store) Update(ctx context.Context, value account.DeviceSession) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + ttl := time.Until(value.ExpiresAt) + if ttl <= 0 { + return repository.ErrNotFound + } + result, err := updateDeviceSessionScript.Run(ctx, s.client, []string{s.key("device", value.ID), s.key("device-index", "sessions")}, payload, ttl.Milliseconds(), value.ExpiresAt.UnixMilli()).Int() + if err != nil { + return err + } + if result != 1 { + return repository.ErrNotFound + } + return nil +} + +func (s *Store) Delete(ctx context.Context, id string) error { + return deleteDeviceSessionScript.Run(ctx, s.client, []string{s.key("device", id), s.key("device-index", "sessions")}).Err() +} + +func (s *Store) acquireLock(ctx context.Context, key string, ttl time.Duration) (func(), bool, error) { + token, err := randomToken() + if err != nil { + return nil, false, err + } + redisKey := s.key("lock", key) + acquired, err := s.client.SetNX(ctx, redisKey, token, ttl).Result() + if err != nil || !acquired { + return nil, acquired, err + } + var once sync.Once + return func() { + once.Do(func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = releaseLockScript.Run(releaseCtx, s.client, []string{redisKey}, token).Err() + }) + }, true, nil +} + +func randomToken() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +// DeviceSessionStore 适配 DeviceSessionRepository,避免与 StickySessionRepository 的 Get 签名冲突。 +type DeviceSessionStore struct{ store *Store } + +func NewDeviceSessionStore(store *Store) *DeviceSessionStore { + return &DeviceSessionStore{store: store} +} +func (s *DeviceSessionStore) Create(ctx context.Context, value account.DeviceSession) error { + return s.store.Create(ctx, value) +} +func (s *DeviceSessionStore) Get(ctx context.Context, id string, now time.Time) (account.DeviceSession, error) { + return s.store.GetDevice(ctx, id, now) +} +func (s *DeviceSessionStore) Update(ctx context.Context, value account.DeviceSession) error { + return s.store.Update(ctx, value) +} +func (s *DeviceSessionStore) Delete(ctx context.Context, id string) error { + return s.store.Delete(ctx, id) +} + +// ConcurrencyLimiter 适配 ConcurrencyLimiter,避免与 DistributedLock 的 Acquire 签名冲突。 +type ConcurrencyLimiter struct{ store *Store } + +func NewConcurrencyLimiter(store *Store) *ConcurrencyLimiter { + return &ConcurrencyLimiter{store: store} +} +func (l *ConcurrencyLimiter) Acquire(ctx context.Context, key string, limit int) (func(), bool, error) { + return l.store.acquireConcurrency(ctx, key, limit) +} +func (l *ConcurrencyLimiter) Current(ctx context.Context, key string) (int, error) { + return l.store.Current(ctx, key) +} +func (l *ConcurrencyLimiter) CurrentMany(ctx context.Context, keys []string) (map[string]int, error) { + return l.store.CurrentMany(ctx, keys) +} + +// LockStore 适配 DistributedLock。 +type LockStore struct{ store *Store } + +func NewLockStore(store *Store) *LockStore { return &LockStore{store: store} } +func (l *LockStore) Acquire(ctx context.Context, key string, ttl time.Duration) (func(), bool, error) { + return l.store.acquireLock(ctx, strings.TrimSpace(key), ttl) +} diff --git a/backend/internal/infra/runtime/redis/store_integration_test.go b/backend/internal/infra/runtime/redis/store_integration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b0eac3b20d98b0b8fd531b83750b352576d1ec03 --- /dev/null +++ b/backend/internal/infra/runtime/redis/store_integration_test.go @@ -0,0 +1,471 @@ +package redis + +import ( + "context" + "errors" + "net" + "os" + "strconv" + "sync" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" + redisclient "github.com/redis/go-redis/v9" +) + +func TestRedisRuntimeStoreIntegration(t *testing.T) { + address := os.Getenv("TEST_REDIS_ADDRESS") + if address == "" { + t.Skip("TEST_REDIS_ADDRESS is not configured") + } + database := 0 + if rawDatabase := os.Getenv("TEST_REDIS_DATABASE"); rawDatabase != "" { + parsed, parseErr := strconv.Atoi(rawDatabase) + if parseErr != nil || parsed < 0 { + t.Fatalf("TEST_REDIS_DATABASE = %q", rawDatabase) + } + database = parsed + } + ctx := context.Background() + keyPrefix := "grok2api:test:" + time.Now().UTC().Format("150405.000000") + ":" + store, err := Open(ctx, Config{ + Address: address, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), Database: database, + KeyPrefix: keyPrefix, ConcurrencyLease: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + defer store.Close() + defer cleanupRedisTestPrefix(t, ctx, store.client, keyPrefix) + + if allowed, err := store.Allow(ctx, "key", 1, time.Now()); err != nil || !allowed { + t.Fatalf("first rate allowance = %v, err = %v", allowed, err) + } + if allowed, err := store.Allow(ctx, "key", 1, time.Now()); err != nil || allowed { + t.Fatalf("second rate allowance = %v, err = %v", allowed, err) + } + + limiter := NewConcurrencyLimiter(store) + release, acquired, err := limiter.Acquire(ctx, "account:1", 1) + if err != nil || !acquired { + t.Fatalf("concurrency acquire = %v, err = %v", acquired, err) + } + if _, acquired, err := limiter.Acquire(ctx, "account:1", 1); err != nil || acquired { + t.Fatalf("duplicate concurrency acquire = %v, err = %v", acquired, err) + } + release() + releaseFailure := &failOnceRedisCommandHook{} + store.client.AddHook(releaseFailure) + release, acquired, err = limiter.Acquire(ctx, "account:release-retry", 1) + if err != nil || !acquired { + t.Fatalf("release retry acquire = %v, err = %v", acquired, err) + } + releaseFailure.arm("evalsha") + release() + releaseDeadline := time.Now().Add(2 * time.Second) + for { + current, currentErr := limiter.Current(ctx, "account:release-retry") + if currentErr != nil { + t.Fatal(currentErr) + } + if current == 0 { + break + } + if time.Now().After(releaseDeadline) { + t.Fatalf("failed concurrency release was not reconciled: current=%d", current) + } + time.Sleep(25 * time.Millisecond) + } + concurrencyKey := store.key("concurrency", "snapshot") + now := time.Now().UTC() + if err := store.client.ZAdd(ctx, concurrencyKey, + redisclient.Z{Score: float64(now.Add(-time.Minute).UnixMilli()), Member: "expired"}, + redisclient.Z{Score: float64(now.Add(time.Minute).UnixMilli()), Member: "active"}, + ).Err(); err != nil { + t.Fatal(err) + } + concurrency, err := store.CurrentMany(ctx, []string{"snapshot"}) + if err != nil || concurrency["snapshot"] != 1 { + t.Fatalf("read-only concurrency snapshot = %#v, err = %v", concurrency, err) + } + if remaining, err := store.client.ZCard(ctx, concurrencyKey).Result(); err != nil || remaining != 2 { + t.Fatalf("concurrency snapshot mutated lease set: remaining=%d err=%v", remaining, err) + } + + expiresAt := time.Now().UTC().Add(time.Minute) + if err := store.Set(ctx, "sticky", 42, expiresAt); err != nil { + t.Fatal(err) + } + if id, ok, err := store.Get(ctx, "sticky", time.Now().UTC()); err != nil || !ok || id != 42 { + t.Fatalf("sticky = %d, %v, %v", id, ok, err) + } + if id, err := store.Bind(ctx, "sticky", 7, time.Now().UTC(), time.Now().UTC().Add(2*time.Minute)); err != nil || id != 42 { + t.Fatalf("atomic sticky bind = %d, err = %v", id, err) + } + if err := store.Set(ctx, "sticky", 7, time.Now().UTC().Add(time.Minute)); err != nil { + t.Fatal(err) + } + if err := store.DeleteByAccount(ctx, 7); err != nil { + t.Fatal(err) + } + if _, ok, err := store.Get(ctx, "sticky", time.Now().UTC()); err != nil || ok { + t.Fatalf("deleted sticky remains available: ok=%v err=%v", ok, err) + } + batchIDs := make([]uint64, 0, stickyDeletePipelineSize+2) + batchKeys := make([]string, 0, stickyDeletePipelineSize+2) + for index := range stickyDeletePipelineSize + 2 { + accountID := uint64(100 + index) + key := "sticky-batch-" + strconv.Itoa(index) + batchIDs = append(batchIDs, accountID) + batchKeys = append(batchKeys, key) + if err := store.Set(ctx, key, accountID, time.Now().UTC().Add(time.Minute)); err != nil { + t.Fatal(err) + } + } + if err := store.Set(ctx, "sticky-batch-keep", 9, time.Now().UTC().Add(time.Minute)); err != nil { + t.Fatal(err) + } + batchIDs = append(batchIDs, batchIDs[0], 0) + if err := store.DeleteByAccounts(ctx, batchIDs); err != nil { + t.Fatal(err) + } + for _, key := range batchKeys { + if _, ok, err := store.Get(ctx, key, time.Now().UTC()); err != nil || ok { + t.Fatalf("batch-deleted sticky %q remains: ok=%v err=%v", key, ok, err) + } + } + if id, ok, err := store.Get(ctx, "sticky-batch-keep", time.Now().UTC()); err != nil || !ok || id != 9 { + t.Fatalf("unrelated batch sticky = %d, ok=%v err=%v", id, ok, err) + } + + observedAt := time.Now().UTC() + if err := store.SetObservedModelState(ctx, 42, repository.ObservedModelState{Model: "grok-4.5-build-free", ObservedAt: observedAt}, time.Minute); err != nil { + t.Fatal(err) + } + if value, ok, err := store.GetObservedModelState(ctx, 42); err != nil || !ok || value.Model != "grok-4.5-build-free" || !value.ObservedAt.Equal(observedAt.Truncate(time.Millisecond)) { + t.Fatalf("observed model state = %#v, ok=%v, err=%v", value, ok, err) + } + if err := store.SetObservedModelState(ctx, 42, repository.ObservedModelState{Model: "stale-model", ObservedAt: observedAt.Add(-time.Second)}, time.Minute); err != nil { + t.Fatal(err) + } + if value, ok, err := store.GetObservedModelState(ctx, 42); err != nil || !ok || value.Model != "grok-4.5-build-free" { + t.Fatalf("stale observed model state replaced value = %#v, ok=%v, err=%v", value, ok, err) + } + + const bindWorkers = 16 + start := make(chan struct{}) + results := make(chan uint64, bindWorkers) + errors := make(chan error, bindWorkers) + var bindGroup sync.WaitGroup + for index := range bindWorkers { + bindGroup.Add(1) + go func(accountID uint64) { + defer bindGroup.Done() + <-start + id, err := store.Bind(ctx, "sticky-race", accountID, time.Now().UTC(), time.Now().UTC().Add(time.Minute)) + results <- id + errors <- err + }(uint64(index + 1)) + } + close(start) + bindGroup.Wait() + close(results) + close(errors) + var winner uint64 + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + for id := range results { + if winner == 0 { + winner = id + } + if id != winner { + t.Fatalf("concurrent bind returned multiple accounts: first=%d current=%d", winner, id) + } + } + if winner == 0 { + t.Fatal("concurrent bind did not select an account") + } + if err := store.DeleteByAccount(ctx, winner); err != nil { + t.Fatal(err) + } + + deviceStore := NewDeviceSessionStore(store) + session := account.DeviceSession{ID: "device", DeviceCode: "code", ExpiresAt: expiresAt} + if err := deviceStore.Create(ctx, session); err != nil { + t.Fatal(err) + } + if _, err := deviceStore.Get(ctx, session.ID, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if err := deviceStore.Delete(ctx, session.ID); err != nil { + t.Fatal(err) + } + + lock := NewLockStore(store) + unlock, acquired, err := lock.Acquire(ctx, "refresh:1", time.Minute) + if err != nil || !acquired { + t.Fatalf("lock acquire = %v, err = %v", acquired, err) + } + if _, acquired, err := lock.Acquire(ctx, "refresh:1", time.Minute); err != nil || acquired { + t.Fatalf("duplicate lock acquire = %v, err = %v", acquired, err) + } + unlock() + + dueAt := time.Now().UTC().Add(-time.Second) + event := account.QuotaRecoveryEvent{AccountID: 42, Mode: "fast", DueAt: dueAt, Attempts: 3} + if err := store.ScheduleQuotaRecovery(ctx, event); err != nil { + t.Fatal(err) + } + claimed, err := store.ClaimDueQuotaRecoveries(ctx, time.Now().UTC(), 10, time.Minute) + if err != nil || len(claimed) != 1 || claimed[0].Attempts != 3 { + t.Fatalf("claimed quota recoveries = %#v, err = %v", claimed, err) + } + claimed[0].Attempts++ + claimed[0].DueAt = time.Now().UTC().Add(-time.Second) + if err := store.RescheduleQuotaRecovery(ctx, claimed[0]); err != nil { + t.Fatal(err) + } + claimed, err = store.ClaimDueQuotaRecoveries(ctx, time.Now().UTC(), 10, time.Minute) + if err != nil || len(claimed) != 1 || claimed[0].Attempts != 4 { + t.Fatalf("rescheduled quota recoveries = %#v, err = %v", claimed, err) + } + if err := store.AckQuotaRecovery(ctx, claimed[0]); err != nil { + t.Fatal(err) + } + + firstGeneration, err := store.MarkQuotaRefreshDirty(ctx, 42, "fast", 200*time.Millisecond) + if err != nil || firstGeneration != 1 { + t.Fatalf("first quota refresh generation = %d, err = %v", firstGeneration, err) + } + if cleared, err := store.ClearQuotaRefreshDirty(ctx, 42, "fast", firstGeneration); err != nil || !cleared { + t.Fatalf("clear first quota refresh generation = %v, err = %v", cleared, err) + } + if generation, dirty, err := store.QuotaRefreshGeneration(ctx, 42, "fast"); err != nil || generation != firstGeneration || dirty { + t.Fatalf("cleared quota refresh state = generation %d, dirty %v, err %v", generation, dirty, err) + } + secondGeneration, err := store.MarkQuotaRefreshDirty(ctx, 42, "fast", 200*time.Millisecond) + if err != nil || secondGeneration != 2 { + t.Fatalf("second quota refresh generation = %d, err = %v", secondGeneration, err) + } + if cleared, err := store.ClearQuotaRefreshDirty(ctx, 42, "fast", firstGeneration); err != nil || cleared { + t.Fatalf("stale quota refresh clear = %v, err = %v", cleared, err) + } + + member := "42:fast" + generationKey := store.key("quota-refresh", "generations") + dirtyKey := store.key("quota-refresh", "dirty") + expiryKey := store.key("quota-refresh", "expiry") + if _, err := store.MarkQuotaRefreshDirty(ctx, 43, "fast", 2*time.Second); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + if generation, dirty, err := store.QuotaRefreshGeneration(ctx, 42, "fast"); err != nil || generation != 0 || dirty { + t.Fatalf("expired quota refresh state = generation %d, dirty %v, err %v", generation, dirty, err) + } + if exists, err := store.client.HExists(ctx, generationKey, member).Result(); err != nil || exists { + t.Fatalf("expired generation retained = %v, err = %v", exists, err) + } + if score, err := store.client.ZScore(ctx, dirtyKey, member).Result(); err != redisclient.Nil { + t.Fatalf("expired dirty member score = %f, err = %v", score, err) + } + if score, err := store.client.ZScore(ctx, expiryKey, member).Result(); err != redisclient.Nil { + t.Fatalf("expired expiry member score = %f, err = %v", score, err) + } + + if _, err := store.MarkQuotaRefreshDirty(ctx, 44, "fast", 2*time.Hour); err != nil { + t.Fatal(err) + } + if _, err := store.MarkQuotaRefreshDirty(ctx, 45, "fast", time.Minute); err != nil { + t.Fatal(err) + } + if ttl, err := store.client.PTTL(ctx, generationKey).Result(); err != nil || ttl < 90*time.Minute { + t.Fatalf("short-lived member shortened generation key TTL: ttl = %s, err = %v", ttl, err) + } + + listenerCtx, cancelListener := context.WithCancel(ctx) + notified := make(chan struct{}, 1) + listenerDone := make(chan error, 1) + go func() { + listenerDone <- store.ListenSettingsChanges(listenerCtx, func(context.Context) error { + select { + case notified <- struct{}{}: + default: + } + return nil + }) + }() + deadline := time.NewTimer(3 * time.Second) + publishTicker := time.NewTicker(25 * time.Millisecond) + defer deadline.Stop() + defer publishTicker.Stop() + for { + select { + case <-publishTicker.C: + if err := store.PublishSettingsChanged(ctx); err != nil { + t.Fatal(err) + } + case <-notified: + cancelListener() + if err := <-listenerDone; err != nil { + t.Fatal(err) + } + return + case <-deadline.C: + cancelListener() + t.Fatal("settings change notification was not delivered") + } + } +} + +type failOnceRedisCommandHook struct { + mu sync.Mutex + command string +} + +func (h *failOnceRedisCommandHook) arm(command string) { + h.mu.Lock() + h.command = command + h.mu.Unlock() +} + +func (h *failOnceRedisCommandHook) DialHook(next redisclient.DialHook) redisclient.DialHook { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return next(ctx, network, address) + } +} + +func (h *failOnceRedisCommandHook) ProcessHook(next redisclient.ProcessHook) redisclient.ProcessHook { + return func(ctx context.Context, command redisclient.Cmder) error { + h.mu.Lock() + fail := h.command != "" && command.Name() == h.command + if fail { + h.command = "" + } + h.mu.Unlock() + if fail { + return errors.New("injected Redis command failure") + } + return next(ctx, command) + } +} + +func (h *failOnceRedisCommandHook) ProcessPipelineHook(next redisclient.ProcessPipelineHook) redisclient.ProcessPipelineHook { + return next +} + +func cleanupRedisTestPrefix(t *testing.T, ctx context.Context, client *redisclient.Client, prefix string) { + t.Helper() + var cursor uint64 + for { + keys, next, err := client.Scan(ctx, cursor, prefix+"*", 256).Result() + if err != nil { + t.Errorf("scan Redis test keys: %v", err) + return + } + if len(keys) > 0 { + if err := client.Unlink(ctx, keys...).Err(); err != nil { + t.Errorf("delete Redis test keys: %v", err) + return + } + } + cursor = next + if cursor == 0 { + return + } + } +} + +func TestRedisInvalidationBusIntegration(t *testing.T) { + address := os.Getenv("TEST_REDIS_ADDRESS") + if address == "" { + t.Skip("TEST_REDIS_ADDRESS is not configured") + } + database := 0 + if rawDatabase := os.Getenv("TEST_REDIS_DATABASE"); rawDatabase != "" { + parsed, err := strconv.Atoi(rawDatabase) + if err != nil || parsed < 0 { + t.Fatalf("TEST_REDIS_DATABASE = %q", rawDatabase) + } + database = parsed + } + ctx := context.Background() + keyPrefix := "grok2api:invalidation-test:" + time.Now().UTC().Format("150405.000000") + ":" + store, err := Open(ctx, Config{ + Address: address, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), Database: database, + KeyPrefix: keyPrefix, ConcurrencyLease: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + defer store.Close() + defer cleanupRedisTestPrefix(t, ctx, store.client, keyPrefix) + + listenerCtx, cancel := context.WithCancel(ctx) + defer cancel() + received := make(chan repository.InvalidationEvent, 4) + done := make(chan error, 1) + go func() { + done <- store.ListenInvalidations(listenerCtx, func(_ context.Context, event repository.InvalidationEvent) error { + received <- event + return nil + }) + }() + + event := repository.InvalidationEvent{Kind: repository.InvalidationAccountStateChanged, Provider: account.ProviderBuild, SourceInstance: "instance-a"} + deadline := time.NewTimer(3 * time.Second) + ticker := time.NewTicker(25 * time.Millisecond) + defer deadline.Stop() + defer ticker.Stop() + var first repository.InvalidationEvent + waiting := true + for waiting { + select { + case <-ticker.C: + if err := store.PublishInvalidation(ctx, event); err != nil { + t.Fatal(err) + } + case first = <-received: + waiting = false + case <-deadline.C: + t.Fatal("invalidation notification was not delivered") + } + } + if first.Revision == 0 || first.Layer() != repository.InvalidationLayerBase { + t.Fatalf("first invalidation = %#v", first) + } + if err := store.client.Publish(ctx, store.key("events", "invalidation"), "not-json").Err(); err != nil { + t.Fatal(err) + } + if err := store.PublishInvalidation(ctx, event); err != nil { + t.Fatal(err) + } + select { + case second := <-received: + if second.Revision <= first.Revision { + t.Fatalf("revisions did not advance: first=%d second=%d", first.Revision, second.Revision) + } + case <-time.After(time.Second): + t.Fatal("second invalidation notification was not delivered") + } + clientKeyEvent := repository.InvalidationEvent{Kind: repository.InvalidationClientKeyChanged, ClientKeyID: 42, SourceInstance: "instance-a"} + if err := store.PublishInvalidation(ctx, clientKeyEvent); err != nil { + t.Fatal(err) + } + select { + case clientKeyInvalidation := <-received: + if clientKeyInvalidation.Layer() != repository.InvalidationLayerClientKey || clientKeyInvalidation.ClientKeyID != 42 || clientKeyInvalidation.Revision == 0 { + t.Fatalf("client-key invalidation = %#v", clientKeyInvalidation) + } + case <-time.After(time.Second): + t.Fatal("client-key invalidation notification was not delivered") + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/infra/runtime/redis/store_test.go b/backend/internal/infra/runtime/redis/store_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a40c45ce5ee8907986e5fe4fb5e9349e6ac987d6 --- /dev/null +++ b/backend/internal/infra/runtime/redis/store_test.go @@ -0,0 +1,111 @@ +package redis + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + redisclient "github.com/redis/go-redis/v9" +) + +func TestRetryConcurrencyReleases(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + tests := []struct { + name string + pipelineError error + remaining int + outcomes map[string]int64 + }{ + {name: "successful retry removes live and expired entries", outcomes: map[string]int64{"expired": 1, "recovered": 1}}, + {name: "failed retry retains only live entries", pipelineError: errors.New("Redis unavailable"), remaining: 1, outcomes: map[string]int64{"expired": 1, "retry_failed": 1}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := redisclient.NewClient(&redisclient.Options{Addr: "unused:6379"}) + client.AddHook(releasePipelineHook{err: test.pipelineError}) + defer client.Close() + store := &Store{client: client} + now := time.Now().UTC() + pending := map[string]concurrencyReleaseRetry{ + "live": {redisKey: "concurrency:1", token: "live", expiresAt: now.Add(time.Minute)}, + "expired": {redisKey: "concurrency:2", token: "expired", expiresAt: now.Add(-time.Minute)}, + } + store.retryConcurrencyReleases(pending) + if len(pending) != test.remaining { + t.Fatalf("pending releases = %#v", pending) + } + if test.remaining == 1 { + if _, ok := pending["live"]; !ok { + t.Fatalf("live release was discarded: %#v", pending) + } + } + assertConcurrencyReleaseMetrics(t, registry.CollectAndReset(), test.outcomes) + }) + } +} + +func TestEnqueueConcurrencyReleaseRetryMetrics(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + store := &Store{ + concurrencyReleaseQueue: make(chan concurrencyReleaseRetry, 1), + concurrencyReleaseStop: make(chan struct{}), + } + value := concurrencyReleaseRetry{redisKey: "concurrency:1", token: "token", expiresAt: time.Now().Add(time.Minute)} + store.enqueueConcurrencyReleaseRetry(value) + store.enqueueConcurrencyReleaseRetry(value) + close(store.concurrencyReleaseStop) + store.enqueueConcurrencyReleaseRetry(value) + assertConcurrencyReleaseMetrics(t, registry.CollectAndReset(), map[string]int64{ + "queued": 1, "dropped": 1, "shutdown": 1, + }) +} + +func assertConcurrencyReleaseMetrics(t *testing.T, samples []perfmetrics.Sample, expected map[string]int64) { + t.Helper() + observed := make(map[string]int64) + for _, sample := range samples { + if sample.Name == "runtime_concurrency_release_total" { + observed[sample.Labels.Outcome] += sample.Total + } + } + for outcome, total := range expected { + if observed[outcome] != total { + t.Fatalf("metric %s = %d, want %d; samples=%#v", outcome, observed[outcome], total, samples) + } + } +} + +type releasePipelineHook struct{ err error } + +func (h releasePipelineHook) DialHook(next redisclient.DialHook) redisclient.DialHook { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return next(ctx, network, address) + } +} + +func (h releasePipelineHook) ProcessHook(next redisclient.ProcessHook) redisclient.ProcessHook { + return next +} + +func (h releasePipelineHook) ProcessPipelineHook(_ redisclient.ProcessPipelineHook) redisclient.ProcessPipelineHook { + return func(_ context.Context, commands []redisclient.Cmder) error { + for _, command := range commands { + command.SetErr(h.err) + if h.err == nil { + if value, ok := command.(*redisclient.IntCmd); ok { + value.SetVal(1) + } + } + } + return h.err + } +} diff --git a/backend/internal/infra/security/cipher.go b/backend/internal/infra/security/cipher.go new file mode 100644 index 0000000000000000000000000000000000000000..2624c5e60d70bb1af2e18e5c5dd46312bb1b27f8 --- /dev/null +++ b/backend/internal/infra/security/cipher.go @@ -0,0 +1,68 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +// Cipher 使用 AES-256-GCM 加密数据库中的 OAuth 凭据。 +type Cipher struct { + aead cipher.AEAD +} + +// NewCipher 从 Base64 编码的 32 字节密钥创建凭据加密器。 +func NewCipher(encodedKey string) (*Cipher, error) { + key, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil { + return nil, fmt.Errorf("解析凭据加密密钥: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("凭据加密密钥必须是 Base64 编码的 32 字节密钥") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Cipher{aead: aead}, nil +} + +// Encrypt 加密敏感明文并返回 Base64 字符串。 +func (c *Cipher) Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + nonce := make([]byte, c.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.RawStdEncoding.EncodeToString(sealed), nil +} + +// Decrypt 解密数据库中的 OAuth 凭据。 +func (c *Cipher) Decrypt(encoded string) (string, error) { + if encoded == "" { + return "", nil + } + data, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("解析加密凭据: %w", err) + } + if len(data) < c.aead.NonceSize() { + return "", fmt.Errorf("加密凭据长度无效") + } + nonce, ciphertext := data[:c.aead.NonceSize()], data[c.aead.NonceSize():] + plain, err := c.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("解密凭据: %w", err) + } + return string(plain), nil +} diff --git a/backend/internal/infra/security/cipher_test.go b/backend/internal/infra/security/cipher_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f3cd084f3d0216e7628dcd96e99b030b06a83fbf --- /dev/null +++ b/backend/internal/infra/security/cipher_test.go @@ -0,0 +1,32 @@ +package security + +import ( + "crypto/rand" + "encoding/base64" + "testing" +) + +func TestCipherRoundTrip(t *testing.T) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := NewCipher(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + encrypted, err := cipher.Encrypt("refresh-secret") + if err != nil { + t.Fatal(err) + } + if encrypted == "refresh-secret" { + t.Fatal("密文不应等于明文") + } + plain, err := cipher.Decrypt(encrypted) + if err != nil { + t.Fatal(err) + } + if plain != "refresh-secret" { + t.Fatalf("解密结果 = %q", plain) + } +} diff --git a/backend/internal/infra/security/password.go b/backend/internal/infra/security/password.go new file mode 100644 index 0000000000000000000000000000000000000000..92c5429e3a8fa21e34c6e09737f291b7f35a91b0 --- /dev/null +++ b/backend/internal/infra/security/password.go @@ -0,0 +1,14 @@ +package security + +import "golang.org/x/crypto/bcrypt" + +// HashPassword 使用 bcrypt 生成管理员密码哈希。 +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +// VerifyPassword 校验管理员密码。 +func VerifyPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/backend/internal/infra/security/token.go b/backend/internal/infra/security/token.go new file mode 100644 index 0000000000000000000000000000000000000000..3bd8993c27cde7589a501501ff81571f7baddcc4 --- /dev/null +++ b/backend/internal/infra/security/token.go @@ -0,0 +1,107 @@ +package security + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const clientKeyScheme = "g2a" + +type adminClaims struct { + AdminID uint64 `json:"adminId"` + SessionID uint64 `json:"sessionId"` + jwt.RegisteredClaims +} + +type AdminTokenIdentity struct { + AdminID uint64 + SessionID uint64 +} + +// TokenService 负责管理员 access token 和随机 refresh token。 +type TokenService struct { + secret []byte + issuer string +} + +func NewTokenService(secret string) *TokenService { + return &TokenService{secret: []byte(secret), issuer: "grok2api"} +} + +// CreateAccessToken 创建短期管理员 JWT。 +func (s *TokenService) CreateAccessToken(adminID, sessionID uint64, ttl time.Duration) (string, time.Time, error) { + now := time.Now().UTC() + expiresAt := now.Add(ttl) + claims := adminClaims{ + AdminID: adminID, SessionID: sessionID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: s.issuer, + Subject: fmt.Sprintf("%d", adminID), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(s.secret) + return signed, expiresAt, err +} + +// ParseAccessToken 校验管理员 JWT 并返回管理员 ID。 +func (s *TokenService) ParseAccessToken(raw string) (AdminTokenIdentity, error) { + claims := &adminClaims{} + token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("不支持的 JWT 签名算法") + } + return s.secret, nil + }, jwt.WithIssuer(s.issuer)) + if err != nil || !token.Valid || claims.AdminID == 0 || claims.SessionID == 0 { + return AdminTokenIdentity{}, fmt.Errorf("管理员令牌无效") + } + return AdminTokenIdentity{AdminID: claims.AdminID, SessionID: claims.SessionID}, nil +} + +// NewOpaqueToken 创建不可预测的 refresh token 或客户端 Key 密钥段。 +func NewOpaqueToken(bytesLength int) (string, error) { + buf := make([]byte, bytesLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// NewHexToken 创建只包含十六进制字符的随机标识,适合放在分隔格式中。 +func NewHexToken(bytesLength int) (string, error) { + buf := make([]byte, bytesLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// HashToken 返回不可逆的 SHA-256 十六进制摘要。 +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// FormatClientKey 生成 g2a__ 格式的客户端 Key。 +func FormatClientKey(prefix, secret string) string { + return clientKeyScheme + "_" + prefix + "_" + secret +} + +// SplitClientKey 解析 g2a__ 格式的客户端 Key。 +func SplitClientKey(raw string) (string, bool) { + parts := strings.SplitN(raw, "_", 3) + if len(parts) != 3 || parts[0] != clientKeyScheme || parts[1] == "" || parts[2] == "" { + return "", false + } + return parts[1], true +} diff --git a/backend/internal/infra/security/token_test.go b/backend/internal/infra/security/token_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5f7f26335102b8efd4aa521631b70996f2f75d38 --- /dev/null +++ b/backend/internal/infra/security/token_test.go @@ -0,0 +1,19 @@ +package security + +import "testing" + +func TestClientKeyFormat(t *testing.T) { + raw := FormatClientKey("abc123", "secret_value") + if raw != "g2a_abc123_secret_value" { + t.Fatalf("formatted key = %q", raw) + } + prefix, ok := SplitClientKey(raw) + if !ok || prefix != "abc123" { + t.Fatalf("SplitClientKey(%q) = %q, %v", raw, prefix, ok) + } + for _, value := range []string{"", "g2a_", "g2a__secret", "other_abc123_secret", "gbp_abc123_old_secret"} { + if _, ok := SplitClientKey(value); ok { + t.Fatalf("SplitClientKey(%q) unexpectedly succeeded", value) + } + } +} diff --git a/backend/internal/pkg/batch/doc.go b/backend/internal/pkg/batch/doc.go new file mode 100644 index 0000000000000000000000000000000000000000..71f0946232a61df4da29300f9cadcf2572b5913a --- /dev/null +++ b/backend/internal/pkg/batch/doc.go @@ -0,0 +1,2 @@ +// Package batch 提供批量任务的有界并发、热更新分类容量、集群共享租约和异常隔离能力。 +package batch diff --git a/backend/internal/pkg/batch/executor.go b/backend/internal/pkg/batch/executor.go new file mode 100644 index 0000000000000000000000000000000000000000..954400708ab51fc4d049af1116322063768a6844 --- /dev/null +++ b/backend/internal/pkg/batch/executor.go @@ -0,0 +1,383 @@ +package batch + +import ( + "context" + "fmt" + "math/rand/v2" + "runtime/debug" + "sync" + "sync/atomic" + "time" +) + +// PanicError 表示任务发生 panic;堆栈只用于服务端诊断,不应直接返回给客户端。 +type PanicError struct { + Value any + Stack []byte +} + +func (e *PanicError) Error() string { + return fmt.Sprintf("批量任务 panic: %v", e.Value) +} + +// Pool 在多个批量操作之间共享并发容量,避免并发请求叠加突破上游保护阈值。 +type Pool struct { + mu sync.Mutex + limit int + slots int + changed chan struct{} + parent *Pool + shared LeaseLimiter + key string + active atomic.Int64 + queued atomic.Int64 + peak atomic.Int64 + jitter atomic.Int64 +} + +type LeaseLimiter interface { + Acquire(ctx context.Context, key string, limit int) (release func(), acquired bool, err error) +} + +type PoolSnapshot struct { + Limit int + Active int + Queued int + Peak int +} + +func NewPool(limit int) *Pool { + if limit < 1 { + limit = 1 + } + return &Pool{limit: limit, changed: make(chan struct{})} +} + +func NewSharedPool(limit int, limiter LeaseLimiter, key string) *Pool { + pool := NewPool(limit) + pool.shared = limiter + pool.key = key + return pool +} + +// NewChildPool 创建分类并发池;任务先取得分类容量,再进入父级全局池。 +func NewChildPool(limit int, parent *Pool) *Pool { + pool := NewPool(limit) + pool.parent = parent + return pool +} + +// NewSharedChildPool 创建同时受分类集群租约和父级总容量约束的并发池。 +func NewSharedChildPool(limit int, limiter LeaseLimiter, key string, parent *Pool) *Pool { + pool := NewSharedPool(limit, limiter, key) + pool.parent = parent + return pool +} + +// UpdateLimit 热更新并发上限;降低上限不会中断正在执行的任务。 +func (p *Pool) UpdateLimit(limit int) { + if p == nil { + return + } + if limit < 1 { + limit = 1 + } + p.mu.Lock() + p.limit = limit + p.signalLocked() + p.mu.Unlock() + p.peak.Store(p.active.Load()) +} + +// UpdateJitter 热更新任务进入并发池前的随机延迟上限;零表示关闭。 +func (p *Pool) UpdateJitter(maximum time.Duration) { + if p == nil { + return + } + if maximum < 0 { + maximum = 0 + } + p.jitter.Store(int64(maximum)) +} + +// Do 等待共享执行容量并隔离任务 panic,调用方仍通过 context 控制排队和执行生命周期。 +func (p *Pool) Do(ctx context.Context, work func(context.Context) error) (err error) { + if p == nil { + return invoke(ctx, work) + } + if err := ctx.Err(); err != nil { + return err + } + if err := p.waitJitter(ctx); err != nil { + return err + } + p.queued.Add(1) + started := false + defer func() { + if !started { + p.queued.Add(-1) + } + }() + if err := p.acquireSlot(ctx); err != nil { + return err + } + defer p.releaseSlot() + var releaseShared func() + if p.shared != nil { + for { + release, acquired, acquireErr := p.shared.Acquire(ctx, p.key, p.Limit()) + if acquireErr != nil { + return acquireErr + } + if acquired { + releaseShared = release + break + } + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + } + defer func() { + if releaseShared != nil { + releaseShared() + } + }() + run := func(workCtx context.Context) error { + started = true + p.queued.Add(-1) + p.begin() + defer p.end() + return invoke(workCtx, work) + } + if p.parent != nil { + return p.parent.Do(ctx, run) + } + return run(ctx) +} + +func (p *Pool) waitJitter(ctx context.Context) error { + maximum := p.jitter.Load() + if maximum <= 0 || p.Limit() <= 1 { + return nil + } + delay := time.Duration(rand.Int64N(maximum)) + if delay == 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (p *Pool) acquireSlot(ctx context.Context) error { + for { + p.mu.Lock() + if p.slots < p.limit { + p.slots++ + p.mu.Unlock() + return nil + } + changed := p.changed + p.mu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + case <-changed: + } + } +} + +func (p *Pool) releaseSlot() { + p.mu.Lock() + p.slots-- + p.signalLocked() + p.mu.Unlock() +} + +func (p *Pool) signalLocked() { + close(p.changed) + p.changed = make(chan struct{}) +} + +func (p *Pool) begin() { + current := p.active.Add(1) + for { + peak := p.peak.Load() + if current <= peak || p.peak.CompareAndSwap(peak, current) { + break + } + } +} + +func (p *Pool) end() { + p.active.Add(-1) +} + +// Limit 返回当前并发上限。 +func (p *Pool) Limit() int { + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return p.limit +} + +func (p *Pool) Snapshot() PoolSnapshot { + if p == nil { + return PoolSnapshot{} + } + return PoolSnapshot{Limit: p.Limit(), Active: int(p.active.Load()), Queued: int(p.queued.Load()), Peak: int(p.peak.Load())} +} + +// Do 隔离单个任务 panic,适用于长驻 Worker 和后台任务监督器。 +func Do(ctx context.Context, work func(context.Context) error) error { + return invoke(ctx, work) +} + +type Options struct { + Workers int + QueueSize int + Pool *Pool +} + +type Result[T any] struct { + Value T + Err error + Completed bool +} + +type Summary struct { + Total int + Submitted int + Completed int + Succeeded int + Failed int + Panicked int + Canceled bool + Duration time.Duration +} + +type indexedItem[T any] struct { + index int + value T +} + +// Map 以稳定输入顺序返回结果;单项失败和 panic 不会中断其他已提交任务。 +func Map[T, R any](ctx context.Context, items []T, options Options, work func(context.Context, T) (R, error)) ([]Result[R], Summary, error) { + return MapObserved(ctx, items, options, work, nil) +} + +// MapObserved 在任务释放共享容量后通知结果观察者,适合连接下游有界流水线。 +func MapObserved[T, R any](ctx context.Context, items []T, options Options, work func(context.Context, T) (R, error), observe func(index int, result Result[R])) ([]Result[R], Summary, error) { + startedAt := time.Now() + results := make([]Result[R], len(items)) + summary := Summary{Total: len(items)} + if len(items) == 0 { + summary.Duration = time.Since(startedAt) + return results, summary, nil + } + workers := options.Workers + if workers < 1 { + workers = 1 + } + workers = min(workers, len(items)) + queueSize := options.QueueSize + if queueSize < 1 { + queueSize = workers * 2 + } + queueSize = min(queueSize, len(items)) + jobs := make(chan indexedItem[T], queueSize) + var wait sync.WaitGroup + wait.Add(workers) + for range workers { + go func() { + defer wait.Done() + for { + select { + case <-ctx.Done(): + return + case job, ok := <-jobs: + if !ok { + return + } + var value R + err := options.Pool.Do(ctx, func(workCtx context.Context) error { + var workErr error + value, workErr = work(workCtx, job.value) + return workErr + }) + execution := Result[R]{Value: value, Err: err, Completed: true} + if observe != nil { + observeErr := invoke(ctx, func(context.Context) error { + observe(job.index, execution) + return nil + }) + if execution.Err == nil && observeErr != nil { + execution.Err = observeErr + } + } + results[job.index] = execution + } + } + }() + } + +sendLoop: + for index, item := range items { + if ctx.Err() != nil { + break + } + select { + case jobs <- indexedItem[T]{index: index, value: item}: + summary.Submitted++ + case <-ctx.Done(): + break sendLoop + } + } + close(jobs) + wait.Wait() + for _, result := range results { + if !result.Completed { + continue + } + summary.Completed++ + if result.Err == nil { + summary.Succeeded++ + continue + } + summary.Failed++ + if _, ok := result.Err.(*PanicError); ok { + summary.Panicked++ + } + } + summary.Canceled = ctx.Err() != nil + summary.Duration = time.Since(startedAt) + return results, summary, ctx.Err() +} + +// Run 执行只关心成功或失败的批量任务。 +func Run[T any](ctx context.Context, items []T, options Options, work func(context.Context, T) error) ([]Result[struct{}], Summary, error) { + return Map(ctx, items, options, func(workCtx context.Context, item T) (struct{}, error) { + return struct{}{}, work(workCtx, item) + }) +} + +func invoke(ctx context.Context, work func(context.Context) error) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = &PanicError{Value: recovered, Stack: debug.Stack()} + } + }() + return work(ctx) +} diff --git a/backend/internal/pkg/batch/executor_test.go b/backend/internal/pkg/batch/executor_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b818dad270b66f3d874cba5ff66e6d93e7166b29 --- /dev/null +++ b/backend/internal/pkg/batch/executor_test.go @@ -0,0 +1,401 @@ +package batch + +import ( + "context" + "errors" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestSharedPoolBoundsConcurrentExecutors(t *testing.T) { + pool := NewPool(3) + var active atomic.Int64 + var peak atomic.Int64 + work := func(context.Context, int) error { + current := active.Add(1) + for { + value := peak.Load() + if current <= value || peak.CompareAndSwap(value, current) { + break + } + } + time.Sleep(5 * time.Millisecond) + active.Add(-1) + return nil + } + var wait sync.WaitGroup + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + _, summary, err := Run(context.Background(), make([]int, 10), Options{Workers: 10, Pool: pool}, work) + if err != nil || summary.Succeeded != 10 { + t.Errorf("summary = %#v, err = %v", summary, err) + } + }() + } + wait.Wait() + if peak.Load() != 3 || pool.Snapshot().Peak != 3 { + t.Fatalf("peak = %d, pool = %#v", peak.Load(), pool.Snapshot()) + } +} + +type leaseLimiterStub struct { + mu sync.Mutex + current map[string]int +} + +func (s *leaseLimiterStub) Acquire(_ context.Context, key string, limit int) (func(), bool, error) { + s.mu.Lock() + if s.current == nil { + s.current = make(map[string]int) + } + if s.current[key] >= limit { + s.mu.Unlock() + return nil, false, nil + } + s.current[key]++ + s.mu.Unlock() + return func() { + s.mu.Lock() + s.current[key]-- + s.mu.Unlock() + }, true, nil +} + +func TestDistributedLeaseBoundsSeparateProcessPools(t *testing.T) { + limiter := &leaseLimiterStub{} + pools := []*Pool{NewSharedPool(2, limiter, "bulk"), NewSharedPool(2, limiter, "bulk")} + var active atomic.Int64 + var peak atomic.Int64 + var wait sync.WaitGroup + for _, pool := range pools { + pool := pool + wait.Add(1) + go func() { + defer wait.Done() + _, _, err := Run(context.Background(), make([]int, 4), Options{Workers: 2, Pool: pool}, func(context.Context, int) error { + current := active.Add(1) + for { + value := peak.Load() + if current <= value || peak.CompareAndSwap(value, current) { + break + } + } + time.Sleep(5 * time.Millisecond) + active.Add(-1) + return nil + }) + if err != nil { + t.Errorf("run: %v", err) + } + }() + } + wait.Wait() + if peak.Load() != 2 { + t.Fatalf("distributed peak = %d", peak.Load()) + } +} + +func TestSharedChildPoolBoundsCategoryAcrossProcesses(t *testing.T) { + limiter := &leaseLimiterStub{} + parents := []*Pool{NewSharedPool(3, limiter, "global"), NewSharedPool(3, limiter, "global")} + children := []*Pool{ + NewSharedChildPool(1, limiter, "sync", parents[0]), + NewSharedChildPool(1, limiter, "sync", parents[1]), + } + var active atomic.Int64 + var peak atomic.Int64 + var wait sync.WaitGroup + for _, pool := range children { + wait.Add(1) + go func() { + defer wait.Done() + _, _, err := Run(context.Background(), make([]int, 2), Options{Workers: 2, Pool: pool}, func(context.Context, int) error { + current := active.Add(1) + for { + value := peak.Load() + if current <= value || peak.CompareAndSwap(value, current) { + break + } + } + time.Sleep(5 * time.Millisecond) + active.Add(-1) + return nil + }) + if err != nil { + t.Errorf("run: %v", err) + } + }() + } + wait.Wait() + if peak.Load() != 1 { + t.Fatalf("category peak = %d", peak.Load()) + } +} + +func TestChildPoolsBoundConcurrentRequestsByCategoryAndGlobalLimit(t *testing.T) { + global := NewPool(3) + refresh := NewChildPool(2, global) + syncPool := NewChildPool(2, global) + + var globalActive atomic.Int64 + var globalPeak atomic.Int64 + var refreshActive atomic.Int64 + var refreshPeak atomic.Int64 + var syncActive atomic.Int64 + var syncPeak atomic.Int64 + trackPeak := func(active, peak *atomic.Int64) { + current := active.Add(1) + for { + value := peak.Load() + if current <= value || peak.CompareAndSwap(value, current) { + return + } + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + start := make(chan struct{}) + work := func(active, peak *atomic.Int64) func(context.Context, int) error { + return func(workCtx context.Context, _ int) error { + trackPeak(&globalActive, &globalPeak) + trackPeak(active, peak) + defer globalActive.Add(-1) + defer active.Add(-1) + select { + case <-start: + return nil + case <-workCtx.Done(): + return workCtx.Err() + } + } + } + + var wait sync.WaitGroup + for _, operation := range []struct { + pool *Pool + active *atomic.Int64 + peak *atomic.Int64 + }{ + {pool: refresh, active: &refreshActive, peak: &refreshPeak}, + {pool: refresh, active: &refreshActive, peak: &refreshPeak}, + {pool: syncPool, active: &syncActive, peak: &syncPeak}, + {pool: syncPool, active: &syncActive, peak: &syncPeak}, + } { + operation := operation + wait.Add(1) + go func() { + defer wait.Done() + _, summary, err := Run(ctx, make([]int, 3), Options{Workers: 3, Pool: operation.pool}, work(operation.active, operation.peak)) + if err != nil || summary.Succeeded != 3 { + t.Errorf("summary = %#v, err = %v", summary, err) + } + }() + } + + deadline := time.After(time.Second) + for globalPeak.Load() < 3 { + select { + case <-deadline: + t.Fatal("并发任务未填满全局容量") + default: + runtime.Gosched() + } + } + close(start) + wait.Wait() + + if globalPeak.Load() != 3 { + t.Fatalf("global peak = %d", globalPeak.Load()) + } + if refreshPeak.Load() > 2 || syncPeak.Load() > 2 { + t.Fatalf("refresh peak = %d, sync peak = %d", refreshPeak.Load(), syncPeak.Load()) + } +} + +func TestMapIsolatesFailureAndPanic(t *testing.T) { + results, summary, err := Map(context.Background(), []int{1, 2, 3}, Options{Workers: 3}, func(_ context.Context, value int) (int, error) { + switch value { + case 2: + return 0, errors.New("failed") + case 3: + panic("broken") + default: + return value * 2, nil + } + }) + if err != nil { + t.Fatal(err) + } + if results[0].Value != 2 || results[0].Err != nil || results[1].Err == nil { + t.Fatalf("results = %#v", results) + } + var panicErr *PanicError + if !errors.As(results[2].Err, &panicErr) || len(panicErr.Stack) == 0 { + t.Fatalf("panic result = %#v", results[2]) + } + if summary.Succeeded != 1 || summary.Failed != 2 || summary.Panicked != 1 { + t.Fatalf("summary = %#v", summary) + } +} + +func TestMapObservedReleasesPoolBeforeStartingDownstreamWork(t *testing.T) { + pool := NewPool(1) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var downstreamErr error + _, summary, err := MapObserved(ctx, []int{1}, Options{Workers: 1, Pool: pool}, func(context.Context, int) (int, error) { + return 42, nil + }, func(_ int, result Result[int]) { + if result.Err != nil { + downstreamErr = result.Err + return + } + downstreamErr = pool.Do(ctx, func(context.Context) error { return nil }) + }) + if err != nil || downstreamErr != nil || summary.Succeeded != 1 { + t.Fatalf("summary = %#v, err = %v, downstream = %v", summary, err, downstreamErr) + } +} + +func TestMapStopsSubmittingAfterCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + _, summary, err := Run(ctx, make([]int, 100), Options{Workers: 2, QueueSize: 1}, func(context.Context, int) error { + cancel() + return nil + }) + if !errors.Is(err, context.Canceled) || !summary.Canceled || summary.Submitted >= summary.Total { + t.Fatalf("summary = %#v, err = %v", summary, err) + } +} + +func TestPoolHotResizeAndChildLimit(t *testing.T) { + global := NewPool(3) + child := NewChildPool(1, global) + started := make(chan struct{}, 3) + release := make(chan struct{}) + done := make(chan error, 3) + for range 3 { + go func() { + done <- child.Do(context.Background(), func(context.Context) error { + started <- struct{}{} + <-release + return nil + }) + }() + } + <-started + select { + case <-started: + t.Fatal("child exceeded its initial limit") + case <-time.After(20 * time.Millisecond): + } + child.UpdateLimit(3) + for range 2 { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("waiting work did not observe the increased limit") + } + } + child.UpdateLimit(1) + close(release) + for range 3 { + if err := <-done; err != nil { + t.Fatal(err) + } + } + if child.Snapshot().Limit != 1 || child.Snapshot().Peak != 3 || global.Snapshot().Peak != 3 { + t.Fatalf("child = %#v, global = %#v", child.Snapshot(), global.Snapshot()) + } +} + +func TestChildSnapshotSeparatesQueuedFromActiveWork(t *testing.T) { + global := NewPool(1) + child := NewChildPool(1, global) + blockerStarted := make(chan struct{}) + releaseBlocker := make(chan struct{}) + blockerDone := make(chan error, 1) + go func() { + blockerDone <- global.Do(context.Background(), func(context.Context) error { + close(blockerStarted) + <-releaseBlocker + return nil + }) + }() + <-blockerStarted + + childStarted := make(chan struct{}) + childDone := make(chan error, 1) + go func() { + childDone <- child.Do(context.Background(), func(context.Context) error { + close(childStarted) + return nil + }) + }() + + deadline := time.After(time.Second) + for { + snapshot := child.Snapshot() + if snapshot.Queued == 1 { + if snapshot.Active != 0 { + t.Fatalf("waiting child snapshot = %#v", snapshot) + } + break + } + select { + case <-deadline: + t.Fatal("child task was not reported as queued") + default: + runtime.Gosched() + } + } + + close(releaseBlocker) + if err := <-blockerDone; err != nil { + t.Fatal(err) + } + select { + case <-childStarted: + case <-time.After(time.Second): + t.Fatal("queued child task did not start") + } + if err := <-childDone; err != nil { + t.Fatal(err) + } + if snapshot := child.Snapshot(); snapshot.Active != 0 || snapshot.Queued != 0 || snapshot.Peak != 1 { + t.Fatalf("completed child snapshot = %#v", snapshot) + } +} + +func TestPoolJitterHonorsCancellationBeforeWork(t *testing.T) { + pool := NewPool(2) + pool.UpdateJitter(time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + called := false + err := pool.Do(ctx, func(context.Context) error { + called = true + return nil + }) + if !errors.Is(err, context.Canceled) || called { + t.Fatalf("err = %v, called = %t", err, called) + } +} + +func TestPoolJitterIsSkippedForSerialExecution(t *testing.T) { + pool := NewPool(1) + pool.UpdateJitter(time.Hour) + startedAt := time.Now() + if err := pool.Do(context.Background(), func(context.Context) error { return nil }); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond { + t.Fatalf("serial execution was delayed by %s", elapsed) + } +} diff --git a/backend/internal/pkg/neterror/classify.go b/backend/internal/pkg/neterror/classify.go new file mode 100644 index 0000000000000000000000000000000000000000..362befe8ad5db7ced49b734ba1504d31d3f108fa --- /dev/null +++ b/backend/internal/pkg/neterror/classify.go @@ -0,0 +1,22 @@ +package neterror + +import ( + "errors" + "net" + "strings" +) + +const responseHeaderTimeoutMarker = "timeout awaiting response headers" + +// IsResponseHeaderTimeout identifies the HTTP/1.1 and HTTP/2 timeout values +// returned by the Go transport while waiting for the first response headers. +func IsResponseHeaderTimeout(err error) bool { + if err == nil { + return false + } + var networkError net.Error + if !errors.As(err, &networkError) || !networkError.Timeout() { + return false + } + return strings.Contains(strings.ToLower(err.Error()), responseHeaderTimeoutMarker) +} diff --git a/backend/internal/pkg/neterror/classify_test.go b/backend/internal/pkg/neterror/classify_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1f13f28afb5f4802965be957aba16c7fd12c0a77 --- /dev/null +++ b/backend/internal/pkg/neterror/classify_test.go @@ -0,0 +1,30 @@ +package neterror + +import ( + "context" + "errors" + "net/url" + "testing" +) + +type timeoutError string + +func (e timeoutError) Error() string { return string(e) } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +func TestIsResponseHeaderTimeout(t *testing.T) { + wrapped := &url.Error{Op: "Post", URL: "https://example.test/v1/responses", Err: timeoutError("http2: timeout awaiting response headers")} + if !IsResponseHeaderTimeout(wrapped) { + t.Fatal("HTTP/2 response-header timeout was not recognized") + } + for _, err := range []error{ + context.DeadlineExceeded, + timeoutError("TLS handshake timeout"), + errors.New("timeout awaiting response headers"), + } { + if IsResponseHeaderTimeout(err) { + t.Fatalf("unexpected response-header timeout classification for %v", err) + } + } +} diff --git a/backend/internal/pkg/perfmetrics/registry.go b/backend/internal/pkg/perfmetrics/registry.go new file mode 100644 index 0000000000000000000000000000000000000000..de845a5a532418d4edb1e6625fb3c35c431027b3 --- /dev/null +++ b/backend/internal/pkg/perfmetrics/registry.go @@ -0,0 +1,205 @@ +package perfmetrics + +import ( + "sort" + "sync" + "time" +) + +// Labels intentionally exposes only bounded dimensions. Callers must use +// controlled enums and must never include account, request, token, model, or +// proxy identifiers. +type Labels struct { + Subsystem string + Operation string + Provider string + Plane string + Stage string + Ordinal string + Outcome string +} + +type Sample struct { + Name string + Labels Labels + Count uint64 + Total int64 + Maximum int64 + Gauge int64 + HasGauge bool +} + +type metricKey struct { + name string + labels Labels +} + +type metricValue struct { + count uint64 + total int64 + maximum int64 + gauge int64 + hasGauge bool +} + +const registryShardCount = 32 + +type registryShard struct { + mu sync.Mutex + values map[metricKey]metricValue +} + +type Registry struct { + shards [registryShardCount]registryShard + dynamicMu sync.RWMutex + dynamicGauges map[metricKey]func() int64 +} + +func NewRegistry() *Registry { + registry := &Registry{} + for index := range registry.shards { + registry.shards[index].values = make(map[metricKey]metricValue) + } + registry.dynamicGauges = make(map[metricKey]func() int64) + return registry +} + +var Default = NewRegistry() + +func (r *Registry) Inc(name string, labels Labels) { + r.Add(name, labels, 1) +} + +func (r *Registry) Add(name string, labels Labels, delta int64) { + if r == nil || name == "" || delta == 0 { + return + } + key := metricKey{name: name, labels: labels} + shard := r.shard(key) + shard.mu.Lock() + value := shard.values[key] + value.count++ + value.total += delta + if delta > value.maximum { + value.maximum = delta + } + shard.values[key] = value + shard.mu.Unlock() +} + +func (r *Registry) ObserveDuration(name string, labels Labels, duration time.Duration) { + r.Add(name, labels, max(0, duration.Microseconds())) +} + +func (r *Registry) SetGauge(name string, labels Labels, value int64) { + if r == nil || name == "" { + return + } + key := metricKey{name: name, labels: labels} + shard := r.shard(key) + shard.mu.Lock() + current := shard.values[key] + current.gauge = value + current.hasGauge = true + shard.values[key] = current + shard.mu.Unlock() +} + +// RegisterDynamicGauge exposes a bounded gauge whose value is read only when +// metrics are collected. The callback must be non-blocking and side-effect +// free; it is intended for atomic runtime state such as active request bytes. +func (r *Registry) RegisterDynamicGauge(name string, labels Labels, read func() int64) { + if r == nil || name == "" || read == nil { + return + } + key := metricKey{name: name, labels: labels} + r.dynamicMu.Lock() + if r.dynamicGauges == nil { + r.dynamicGauges = make(map[metricKey]func() int64) + } + r.dynamicGauges[key] = read + r.dynamicMu.Unlock() +} + +// CollectAndReset returns interval counters while retaining the latest gauge +// values for the next collection window. +func (r *Registry) CollectAndReset() []Sample { + if r == nil { + return nil + } + result := make([]Sample, 0, registryShardCount) + for index := range r.shards { + shard := &r.shards[index] + shard.mu.Lock() + for key, value := range shard.values { + result = append(result, Sample{ + Name: key.name, Labels: key.labels, Count: value.count, + Total: value.total, Maximum: value.maximum, + Gauge: value.gauge, HasGauge: value.hasGauge, + }) + if value.hasGauge { + value.count = 0 + value.total = 0 + value.maximum = 0 + shard.values[key] = value + } else { + delete(shard.values, key) + } + } + shard.mu.Unlock() + } + r.dynamicMu.RLock() + dynamic := make(map[metricKey]func() int64, len(r.dynamicGauges)) + for key, read := range r.dynamicGauges { + dynamic[key] = read + } + r.dynamicMu.RUnlock() + for key, read := range dynamic { + value := Sample{Name: key.name, Labels: key.labels, Gauge: read(), HasGauge: true} + found := false + for index := range result { + if result[index].Name == key.name && result[index].Labels == key.labels { + result[index].Gauge = value.Gauge + result[index].HasGauge = true + found = true + break + } + } + if !found { + result = append(result, value) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].Name != result[j].Name { + return result[i].Name < result[j].Name + } + return labelsKey(result[i].Labels) < labelsKey(result[j].Labels) + }) + return result +} + +func (r *Registry) shard(key metricKey) *registryShard { + hash := uint64(1469598103934665603) + hash = hashMetricString(hash, key.name) + hash = hashMetricString(hash, key.labels.Subsystem) + hash = hashMetricString(hash, key.labels.Operation) + hash = hashMetricString(hash, key.labels.Provider) + hash = hashMetricString(hash, key.labels.Plane) + hash = hashMetricString(hash, key.labels.Stage) + hash = hashMetricString(hash, key.labels.Ordinal) + hash = hashMetricString(hash, key.labels.Outcome) + return &r.shards[hash%registryShardCount] +} + +func hashMetricString(hash uint64, value string) uint64 { + for index := 0; index < len(value); index++ { + hash ^= uint64(value[index]) + hash *= 1099511628211 + } + hash ^= 0xff + return hash * 1099511628211 +} + +func labelsKey(value Labels) string { + return value.Subsystem + "\x00" + value.Operation + "\x00" + value.Provider + "\x00" + value.Plane + "\x00" + value.Stage + "\x00" + value.Ordinal + "\x00" + value.Outcome +} diff --git a/backend/internal/pkg/perfmetrics/registry_test.go b/backend/internal/pkg/perfmetrics/registry_test.go new file mode 100644 index 0000000000000000000000000000000000000000..eff86d55ad411d6ff2efaefe0bae13d9f5ffd663 --- /dev/null +++ b/backend/internal/pkg/perfmetrics/registry_test.go @@ -0,0 +1,54 @@ +package perfmetrics + +import ( + "testing" + "time" +) + +func TestCollectAndResetRetainsGaugeAndClearsIntervalCounters(t *testing.T) { + registry := NewRegistry() + labels := Labels{Subsystem: "gateway", Operation: "responses", Provider: "grok_build", Stage: "upstream", Outcome: "success"} + registry.ObserveDuration("stage_duration_us", labels, 3*time.Millisecond) + registry.ObserveDuration("stage_duration_us", labels, 5*time.Millisecond) + registry.SetGauge("queue_depth", Labels{Subsystem: "audit"}, 7) + + first := registry.CollectAndReset() + if len(first) != 2 { + t.Fatalf("first sample count = %d", len(first)) + } + if first[1].Count != 2 || first[1].Total != 8000 || first[1].Maximum != 5000 { + t.Fatalf("duration sample = %#v", first[1]) + } + second := registry.CollectAndReset() + if len(second) != 1 || !second[0].HasGauge || second[0].Gauge != 7 || second[0].Count != 0 { + t.Fatalf("second samples = %#v", second) + } +} + +func TestCollectAndResetReadsDynamicGaugeAtCollectionTime(t *testing.T) { + registry := NewRegistry() + labels := Labels{Subsystem: "http"} + var value int64 = 7 + registry.RegisterDynamicGauge("active_bytes", labels, func() int64 { return value }) + + first := registry.CollectAndReset() + if len(first) != 1 || !first[0].HasGauge || first[0].Gauge != 7 { + t.Fatalf("first dynamic gauge sample = %#v", first) + } + value = 11 + second := registry.CollectAndReset() + if len(second) != 1 || !second[0].HasGauge || second[0].Gauge != 11 { + t.Fatalf("second dynamic gauge sample = %#v", second) + } +} + +func BenchmarkRegistryParallel(b *testing.B) { + registry := NewRegistry() + labels := Labels{Subsystem: "gateway", Operation: "responses", Provider: "grok_build", Stage: "upstream", Outcome: "success"} + b.ReportAllocs() + b.RunParallel(func(worker *testing.PB) { + for worker.Next() { + registry.ObserveDuration("stage_duration_us", labels, time.Millisecond) + } + }) +} diff --git a/backend/internal/pkg/reasoningreplay/capture.go b/backend/internal/pkg/reasoningreplay/capture.go new file mode 100644 index 0000000000000000000000000000000000000000..b4a41226d074877ff6424b6b1f4d7d63b9a4eead --- /dev/null +++ b/backend/internal/pkg/reasoningreplay/capture.go @@ -0,0 +1,196 @@ +package reasoningreplay + +import ( + "bytes" + "context" + "encoding/json" + "io" + "sort" + "strings" + "time" +) + +func (r *ReasoningReplay) CaptureBody(body io.ReadCloser, model, sessionKey string, streaming, compact bool) io.ReadCloser { + if !r.Enabled() || body == nil || strings.TrimSpace(sessionKey) == "" || strings.TrimSpace(model) == "" { + return body + } + return &replayCaptureBody{ + inner: body, + replay: r, + model: model, + session: sessionKey, + streaming: streaming, + compact: compact, + } +} + +type replayCaptureBody struct { + inner io.ReadCloser + replay *ReasoningReplay + model string + session string + streaming bool + compact bool + buf bytes.Buffer + truncated bool + sawEOF bool + readErr error + done bool +} + +func (b *replayCaptureBody) Read(p []byte) (int, error) { + n, err := b.inner.Read(p) + if n > 0 && !b.truncated { + if b.buf.Len()+n > maxReplayCaptureBytes { + b.truncated = true + b.buf.Reset() + } else { + _, _ = b.buf.Write(p[:n]) + } + } + if err == io.EOF { + b.sawEOF = true + } else if err != nil { + b.readErr = err + } + return n, err +} + +func (b *replayCaptureBody) Close() error { + if b.done { + return nil + } + b.done = true + closeErr := b.inner.Close() + if b.truncated || b.buf.Len() == 0 { + return closeErr + } + payload := b.buf.Bytes() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if b.compact { + if b.sawEOF && b.readErr == nil { + b.replay.Clear(ctx, b.model, b.session) + } + return closeErr + } + if b.streaming { + if completed, ok := extractCompletedPayloadFromSSE(payload); ok { + b.replay.StoreFromCompleted(ctx, b.model, b.session, completed) + } + return closeErr + } + if !b.sawEOF || b.readErr != nil { + return closeErr + } + b.replay.StoreFromCompleted(ctx, b.model, b.session, payload) + return closeErr +} + +func extractCompletedPayloadFromSSE(data []byte) ([]byte, bool) { + lines := bytes.Split(data, []byte("\n")) + var last []byte + itemsByIndex := map[int][]byte{} + var fallbackItems [][]byte + for _, line := range lines { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + value := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + if bytes.Equal(value, []byte("[DONE]")) || len(value) == 0 { + continue + } + var typed struct { + Type string `json:"type"` + Response json.RawMessage `json:"response"` + Output json.RawMessage `json:"output"` + Item json.RawMessage `json:"item"` + Index *int `json:"output_index"` + } + if json.Unmarshal(value, &typed) != nil { + continue + } + switch strings.TrimSpace(typed.Type) { + case "response.output_item.done": + if len(typed.Item) == 0 || !json.Valid(typed.Item) { + continue + } + item := append([]byte(nil), typed.Item...) + if typed.Index != nil { + itemsByIndex[*typed.Index] = item + } else { + fallbackItems = append(fallbackItems, item) + } + case "response.completed", "response.done": + if len(typed.Response) > 0 { + last = patchCompletedOutput(value, itemsByIndex, fallbackItems) + } else if len(typed.Output) > 0 { + last = append([]byte(nil), value...) + } + default: + if len(typed.Output) > 0 && typed.Type == "" { + last = append([]byte(nil), value...) + } + } + } + return last, len(last) > 0 +} + +func patchCompletedOutput(event []byte, itemsByIndex map[int][]byte, fallbackItems [][]byte) []byte { + if len(itemsByIndex) == 0 && len(fallbackItems) == 0 { + return append([]byte(nil), event...) + } + var root map[string]json.RawMessage + if json.Unmarshal(event, &root) != nil { + return append([]byte(nil), event...) + } + responseRaw, ok := root["response"] + if !ok { + return append([]byte(nil), event...) + } + var response map[string]json.RawMessage + if json.Unmarshal(responseRaw, &response) != nil { + return append([]byte(nil), event...) + } + var existing []json.RawMessage + if raw, exists := response["output"]; exists && json.Unmarshal(raw, &existing) == nil && len(existing) > 0 { + return append([]byte(nil), event...) + } + indexes := make([]int, 0, len(itemsByIndex)) + for index := range itemsByIndex { + indexes = append(indexes, index) + } + sort.Ints(indexes) + output := make([]json.RawMessage, 0, len(indexes)+len(fallbackItems)) + for _, index := range indexes { + output = append(output, json.RawMessage(itemsByIndex[index])) + } + for _, item := range fallbackItems { + output = append(output, json.RawMessage(item)) + } + encodedOutput, err := json.Marshal(output) + if err != nil { + return append([]byte(nil), event...) + } + response["output"] = encodedOutput + encodedResponse, err := json.Marshal(response) + if err != nil { + return append([]byte(nil), event...) + } + root["response"] = encodedResponse + patched, err := json.Marshal(root) + if err != nil { + return append([]byte(nil), event...) + } + return patched +} + +// StoreFromCompletedPayload 供已缓冲完整 JSON 的路径直接调用。 +func (r *ReasoningReplay) StoreFromCompletedPayload(ctx context.Context, model, sessionKey string, payload []byte, compact bool) { + if compact { + r.Clear(ctx, model, sessionKey) + return + } + r.StoreFromCompleted(ctx, model, sessionKey, payload) +} diff --git a/backend/internal/pkg/reasoningreplay/input.go b/backend/internal/pkg/reasoningreplay/input.go new file mode 100644 index 0000000000000000000000000000000000000000..9a3643c37bc280d90f661f9d69e3dd4c9bb6b02f --- /dev/null +++ b/backend/internal/pkg/reasoningreplay/input.go @@ -0,0 +1,361 @@ +package reasoningreplay + +import ( + "encoding/json" + "strings" +) + +func filterReplayItemsForInput(body []byte, items [][]byte) [][]byte { + var payload struct { + Input []json.RawMessage `json:"input"` + } + if json.Unmarshal(body, &payload) != nil || len(payload.Input) == 0 { + return nil + } + inputItems := make([]map[string]json.RawMessage, 0, len(payload.Input)) + for _, raw := range payload.Input { + var item map[string]json.RawMessage + if json.Unmarshal(raw, &item) == nil { + inputItems = append(inputItems, item) + } + } + lastAssistant, hasLastAssistant := lastAssistantMessage(inputItems) + cachedAssistant, hasCachedAssistant := replayAssistantMessage(items) + assistantMatches := hasLastAssistant && hasCachedAssistant && assistantContentEqual(lastAssistant, cachedAssistant) + if hasLastAssistant && hasCachedAssistant && !assistantMatches { + return nil + } + existingCalls := map[string]bool{} + existingOutputs := map[string]string{} + existingEncrypted := map[string]bool{} + for _, item := range inputItems { + var typeName string + _ = json.Unmarshal(item["type"], &typeName) + typeName = strings.TrimSpace(typeName) + switch typeName { + case "reasoning": + var enc string + _ = json.Unmarshal(item["encrypted_content"], &enc) + if enc != "" { + existingEncrypted[enc] = true + } + case "function_call_output", "custom_tool_call_output": + var callID string + _ = json.Unmarshal(item["call_id"], &callID) + for _, candidate := range comparableReplayCallIDs(callID) { + existingOutputs[candidate] = callID + } + case "function_call", "custom_tool_call": + var callID string + _ = json.Unmarshal(item["call_id"], &callID) + for _, key := range replayToolCallKeys(typeName, callID) { + existingCalls[key] = true + } + } + } + filtered := make([][]byte, 0, len(items)) + for _, item := range items { + var typed struct { + Type string `json:"type"` + EncryptedContent string `json:"encrypted_content"` + CallID string `json:"call_id"` + } + if json.Unmarshal(item, &typed) != nil { + continue + } + switch strings.TrimSpace(typed.Type) { + case "reasoning": + if existingEncrypted[typed.EncryptedContent] { + continue + } + case "message": + if assistantMatches { + continue + } + case "function_call", "custom_tool_call": + keys := replayToolCallKeys(strings.TrimSpace(typed.Type), typed.CallID) + if len(keys) == 0 || anyReplayCallKeyExists(existingCalls, keys) { + continue + } + outputCallID := "" + for _, candidate := range comparableReplayCallIDs(typed.CallID) { + if value := existingOutputs[candidate]; value != "" { + outputCallID = value + break + } + } + if outputCallID == "" { + continue + } + for _, key := range keys { + existingCalls[key] = true + } + if outputCallID != typed.CallID { + item = rewriteReplayCallID(item, outputCallID) + } + default: + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func comparableReplayCallIDs(callID string) []string { + callID = strings.TrimSpace(callID) + if callID == "" { + return nil + } + const anthropicPrefix = "toolu_" + if strings.HasPrefix(callID, anthropicPrefix) { + upstreamID := strings.TrimPrefix(callID, anthropicPrefix) + if upstreamID != "" { + return []string{callID, upstreamID} + } + return []string{callID} + } + return []string{callID, anthropicPrefix + callID} +} + +func replayToolCallKeys(itemType, callID string) []string { + if itemType != "function_call" && itemType != "custom_tool_call" { + return nil + } + ids := comparableReplayCallIDs(callID) + keys := make([]string, 0, len(ids)) + for _, id := range ids { + keys = append(keys, itemType+"\x00"+id) + } + return keys +} + +func anyReplayCallKeyExists(existing map[string]bool, keys []string) bool { + for _, key := range keys { + if existing[key] { + return true + } + } + return false +} + +func rewriteReplayCallID(item []byte, callID string) []byte { + var raw map[string]json.RawMessage + if json.Unmarshal(item, &raw) != nil { + return item + } + encoded, err := json.Marshal(callID) + if err != nil { + return item + } + raw["call_id"] = encoded + updated, err := json.Marshal(raw) + if err != nil { + return item + } + return updated +} + +func lastAssistantMessage(items []map[string]json.RawMessage) (map[string]json.RawMessage, bool) { + for index := len(items) - 1; index >= 0; index-- { + item := items[index] + var typeName, role string + _ = json.Unmarshal(item["type"], &typeName) + _ = json.Unmarshal(item["role"], &role) + typeName = strings.TrimSpace(typeName) + if (typeName != "" && typeName != "message") || !strings.EqualFold(strings.TrimSpace(role), "assistant") { + continue + } + return item, true + } + return nil, false +} + +func replayAssistantMessage(items [][]byte) (map[string]json.RawMessage, bool) { + for _, item := range items { + var raw map[string]json.RawMessage + if json.Unmarshal(item, &raw) != nil { + continue + } + var typeName, role string + _ = json.Unmarshal(raw["type"], &typeName) + _ = json.Unmarshal(raw["role"], &role) + if strings.TrimSpace(typeName) == "message" && strings.EqualFold(strings.TrimSpace(role), "assistant") { + return raw, true + } + } + return nil, false +} + +func assistantContentEqual(left, right map[string]json.RawMessage) bool { + leftParts, leftOK := assistantParts(left["content"]) + rightParts, rightOK := assistantParts(right["content"]) + if !leftOK || !rightOK || len(leftParts) != len(rightParts) { + return false + } + for i := range leftParts { + if leftParts[i] != rightParts[i] { + return false + } + } + return true +} + +type assistantPart struct { + partType string + value string +} + +func assistantParts(raw json.RawMessage) ([]assistantPart, bool) { + if len(raw) == 0 { + return nil, false + } + var asString string + if json.Unmarshal(raw, &asString) == nil { + return []assistantPart{{partType: "output_text", value: asString}}, true + } + var parts []map[string]json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return nil, false + } + result := make([]assistantPart, 0, len(parts)) + for _, part := range parts { + var partType string + _ = json.Unmarshal(part["type"], &partType) + switch strings.TrimSpace(partType) { + case "output_text": + var text string + if json.Unmarshal(part["text"], &text) != nil { + return nil, false + } + result = append(result, assistantPart{partType: partType, value: text}) + case "refusal": + var refusal string + if json.Unmarshal(part["refusal"], &refusal) != nil { + return nil, false + } + result = append(result, assistantPart{partType: partType, value: refusal}) + default: + return nil, false + } + } + return result, len(result) > 0 +} + +func insertReplayItems(body []byte, replayItems [][]byte) ([]byte, bool) { + var payload map[string]json.RawMessage + if json.Unmarshal(body, &payload) != nil { + return body, false + } + inputRaw, ok := payload["input"] + if !ok { + return body, false + } + var input []json.RawMessage + if json.Unmarshal(inputRaw, &input) != nil { + return body, false + } + insertAt := replayInsertIndex(input, replayItems) + // 不基于不可信请求长度计算预分配容量,交由 append 安全扩容。 + next := make([]json.RawMessage, 0) + for i, item := range input { + if i == insertAt { + for _, replay := range replayItems { + next = append(next, json.RawMessage(replay)) + } + } + next = append(next, item) + } + if insertAt == len(input) { + for _, replay := range replayItems { + next = append(next, json.RawMessage(replay)) + } + } + encoded, err := json.Marshal(next) + if err != nil { + return body, false + } + payload["input"] = encoded + updated, err := json.Marshal(payload) + if err != nil { + return body, false + } + return updated, true +} + +func replayInsertIndex(input []json.RawMessage, replayItems [][]byte) int { + replayCallIDs := map[string]bool{} + for _, item := range replayItems { + var typed struct { + Type string `json:"type"` + CallID string `json:"call_id"` + } + if json.Unmarshal(item, &typed) != nil { + continue + } + if typed.Type == "function_call" || typed.Type == "custom_tool_call" { + for _, id := range comparableReplayCallIDs(typed.CallID) { + replayCallIDs[id] = true + } + } + } + if len(replayCallIDs) > 0 { + for index, raw := range input { + var typed struct { + Type string `json:"type"` + CallID string `json:"call_id"` + } + if json.Unmarshal(raw, &typed) != nil { + continue + } + if typed.Type != "function_call_output" && typed.Type != "custom_tool_call_output" { + continue + } + callIDs := comparableReplayCallIDs(typed.CallID) + if len(callIDs) == 0 { + return index + } + for _, callID := range callIDs { + if replayCallIDs[callID] { + return index + } + } + } + } + for index := len(input) - 1; index >= 0; index-- { + var typed struct { + Type string `json:"type"` + Role string `json:"role"` + } + if json.Unmarshal(input[index], &typed) != nil { + continue + } + typeName := strings.TrimSpace(typed.Type) + if (typeName == "" || typeName == "message") && strings.EqualFold(strings.TrimSpace(typed.Role), "assistant") { + return index + } + } + for index, raw := range input { + if shouldInsertReplayBefore(raw) { + return index + } + } + return len(input) +} + +func shouldInsertReplayBefore(raw json.RawMessage) bool { + var typed struct { + Type string `json:"type"` + Role string `json:"role"` + } + if json.Unmarshal(raw, &typed) != nil { + return true + } + typeName := strings.TrimSpace(typed.Type) + role := strings.ToLower(strings.TrimSpace(typed.Role)) + if role == "" || (typeName != "" && typeName != "message") { + return true + } + return role != "system" && role != "developer" +} + +// CaptureBody 包装上游响应,在流结束时抽取并写入回放缓存。 diff --git a/backend/internal/pkg/reasoningreplay/normalize.go b/backend/internal/pkg/reasoningreplay/normalize.go new file mode 100644 index 0000000000000000000000000000000000000000..84cddc9dfa0e45e20557cd1e8d216d82ea22b1d0 --- /dev/null +++ b/backend/internal/pkg/reasoningreplay/normalize.go @@ -0,0 +1,227 @@ +package reasoningreplay + +import ( + "encoding/base64" + "encoding/json" + "math" + "strings" +) + +func previousResponseIDPresent(body []byte) bool { + var payload struct { + PreviousResponseID string `json:"previous_response_id"` + } + if json.Unmarshal(body, &payload) != nil { + return false + } + return strings.TrimSpace(payload.PreviousResponseID) != "" +} + +func extractReplayItemsFromPayload(payload []byte) ([][]byte, bool) { + var root map[string]json.RawMessage + if json.Unmarshal(payload, &root) != nil { + return nil, false + } + outputRaw, outputExists := root["output"] + if !outputExists { + if respRaw, ok := root["response"]; ok { + var nested map[string]json.RawMessage + if json.Unmarshal(respRaw, &nested) != nil { + return nil, false + } + outputRaw, outputExists = nested["output"] + } + } + if !outputExists || len(outputRaw) == 0 { + return nil, false + } + var output []json.RawMessage + if json.Unmarshal(outputRaw, &output) != nil { + return nil, false + } + items := make([][]byte, 0, len(output)) + for _, item := range output { + var typed struct { + Type string `json:"type"` + } + if json.Unmarshal(item, &typed) != nil { + continue + } + switch strings.TrimSpace(typed.Type) { + case "reasoning", "message", "function_call", "custom_tool_call": + items = append(items, append([]byte(nil), item...)) + } + } + return items, true +} + +func normalizeReplayItems(items [][]byte) ([][]byte, bool) { + normalized := make([][]byte, 0, len(items)) + hasAnchor := false + for _, item := range items { + next, ok := normalizeReplayItem(item) + if !ok { + continue + } + normalized = append(normalized, next) + var typed struct { + Type string `json:"type"` + } + _ = json.Unmarshal(next, &typed) + switch strings.TrimSpace(typed.Type) { + case "reasoning", "function_call", "custom_tool_call": + hasAnchor = true + } + } + return normalized, hasAnchor && len(normalized) > 0 +} + +func normalizeReplayItem(item []byte) ([]byte, bool) { + var raw map[string]json.RawMessage + if json.Unmarshal(item, &raw) != nil { + return nil, false + } + var typeName string + _ = json.Unmarshal(raw["type"], &typeName) + switch strings.TrimSpace(typeName) { + case "reasoning": + return normalizeReasoningItem(raw) + case "message": + return normalizeAssistantMessageItem(raw) + case "function_call": + return normalizeFunctionCallItem(raw) + case "custom_tool_call": + return normalizeCustomToolCallItem(raw) + default: + return nil, false + } +} + +func normalizeReasoningItem(raw map[string]json.RawMessage) ([]byte, bool) { + var encrypted string + if json.Unmarshal(raw["encrypted_content"], &encrypted) != nil { + return nil, false + } + if !validGrokReplayEncryptedContent(encrypted) { + return nil, false + } + out := map[string]any{ + "type": "reasoning", + "summary": []any{}, + "content": nil, + "encrypted_content": encrypted, + } + data, err := json.Marshal(out) + return data, err == nil +} + +func validGrokReplayEncryptedContent(value string) bool { + if value == "" || value != strings.TrimSpace(value) || len(value) > maxReplayEncryptedLen { + return false + } + if strings.HasPrefix(value, "gAAAA") || strings.Contains(value, "=") { + return false + } + decoded, err := base64.RawStdEncoding.DecodeString(value) + if err != nil || len(decoded) < minReplayEncryptedDecodedLen { + return false + } + return replayByteEntropyRatio(decoded) >= minReplayEncryptedEntropy +} + +func replayByteEntropyRatio(value []byte) float64 { + if len(value) == 0 { + return 0 + } + var counts [256]int + for _, item := range value { + counts[item]++ + } + size := float64(len(value)) + entropy := 0.0 + for _, count := range counts { + if count == 0 { + continue + } + probability := float64(count) / size + entropy -= probability * math.Log2(probability) + } + maxSymbols := min(len(value), 256) + if maxSymbols <= 1 { + return 0 + } + return entropy / math.Log2(float64(maxSymbols)) +} + +func normalizeAssistantMessageItem(raw map[string]json.RawMessage) ([]byte, bool) { + var role string + _ = json.Unmarshal(raw["role"], &role) + if !strings.EqualFold(strings.TrimSpace(role), "assistant") { + return nil, false + } + var content []map[string]json.RawMessage + if json.Unmarshal(raw["content"], &content) != nil || len(content) == 0 { + return nil, false + } + parts := make([]map[string]any, 0, len(content)) + for _, part := range content { + var partType string + _ = json.Unmarshal(part["type"], &partType) + switch strings.TrimSpace(partType) { + case "output_text": + var text string + if json.Unmarshal(part["text"], &text) != nil { + continue + } + parts = append(parts, map[string]any{"type": "output_text", "text": text}) + case "refusal": + var refusal string + if json.Unmarshal(part["refusal"], &refusal) != nil { + continue + } + parts = append(parts, map[string]any{"type": "refusal", "refusal": refusal}) + } + } + if len(parts) == 0 { + return nil, false + } + data, err := json.Marshal(map[string]any{"type": "message", "role": "assistant", "content": parts}) + return data, err == nil +} + +func normalizeFunctionCallItem(raw map[string]json.RawMessage) ([]byte, bool) { + var callID, name, arguments string + _ = json.Unmarshal(raw["call_id"], &callID) + _ = json.Unmarshal(raw["name"], &name) + if json.Unmarshal(raw["arguments"], &arguments) != nil { + return nil, false + } + callID, name = strings.TrimSpace(callID), strings.TrimSpace(name) + if callID == "" || name == "" { + return nil, false + } + data, err := json.Marshal(map[string]any{"type": "function_call", "call_id": callID, "name": name, "arguments": arguments}) + return data, err == nil +} + +func normalizeCustomToolCallItem(raw map[string]json.RawMessage) ([]byte, bool) { + var callID, name string + _ = json.Unmarshal(raw["call_id"], &callID) + _ = json.Unmarshal(raw["name"], &name) + callID, name = strings.TrimSpace(callID), strings.TrimSpace(name) + if callID == "" || name == "" || len(raw["input"]) == 0 { + return nil, false + } + out := map[string]any{"type": "custom_tool_call", "status": "completed", "call_id": callID, "name": name} + var status string + if json.Unmarshal(raw["status"], &status) == nil && strings.TrimSpace(status) != "" { + out["status"] = status + } + var input any + if json.Unmarshal(raw["input"], &input) != nil { + return nil, false + } + out["input"] = input + data, err := json.Marshal(out) + return data, err == nil +} diff --git a/backend/internal/pkg/reasoningreplay/reasoning_replay.go b/backend/internal/pkg/reasoningreplay/reasoning_replay.go new file mode 100644 index 0000000000000000000000000000000000000000..8e6d72d59c2c816ee7449e2609e9bfce979568ed --- /dev/null +++ b/backend/internal/pkg/reasoningreplay/reasoning_replay.go @@ -0,0 +1,139 @@ +package reasoningreplay + +import ( + "context" + "log/slog" + "strings" + "sync/atomic" + "time" + + "github.com/chenyme/grok2api/backend/internal/repository" +) + +const ( + minReplayEncryptedDecodedLen = 50 + minReplayEncryptedEntropy = 0.85 + maxReplayEncryptedLen = 8 << 20 + maxReplayCaptureBytes = 8 << 20 + defaultReasoningReplayTTL = time.Hour +) + +// Config 控制服务端推理回放缓存。 +type Config struct { + Enabled bool + TTL time.Duration +} + +// ReasoningReplay 封装存储与 body 注入/抽取。 +type ReasoningReplay struct { + store repository.ReasoningReplayRepository + cfg atomic.Pointer[Config] + logger *slog.Logger + now func() time.Time +} + +func New(store repository.ReasoningReplayRepository, cfg Config, logger *slog.Logger) *ReasoningReplay { + if logger == nil { + logger = slog.Default() + } + replay := &ReasoningReplay{store: store, logger: logger, now: time.Now} + replay.UpdateConfig(cfg) + return replay +} + +func (r *ReasoningReplay) UpdateConfig(cfg Config) { + if r == nil { + return + } + if cfg.TTL <= 0 { + cfg.TTL = defaultReasoningReplayTTL + } + r.cfg.Store(&cfg) +} + +func (r *ReasoningReplay) Enabled() bool { + if r == nil || r.store == nil { + return false + } + cfg := r.cfg.Load() + return cfg != nil && cfg.Enabled +} + +// Apply 将缓存的上一轮 output items 注入 Responses body.input。 +func (r *ReasoningReplay) Apply(ctx context.Context, model, sessionKey string, body []byte) []byte { + if r == nil || r.store == nil || strings.TrimSpace(sessionKey) == "" || strings.TrimSpace(model) == "" || len(body) == 0 { + return body + } + cfg := r.cfg.Load() + if cfg == nil || !cfg.Enabled { + return body + } + if previousResponseIDPresent(body) { + r.logger.Debug("reasoning_replay_miss", "reason", "previous_response_id", "model", model) + return body + } + items, ok, err := r.store.Get(ctx, model, sessionKey, r.now().UTC(), cfg.TTL) + if err != nil { + r.logger.Warn("reasoning_replay_get_failed", "model", model, "error", err) + return body + } + if !ok || len(items) == 0 { + r.logger.Debug("reasoning_replay_miss", "reason", "not_found", "model", model) + return body + } + filtered := filterReplayItemsForInput(body, items) + if len(filtered) == 0 { + r.logger.Debug("reasoning_replay_miss", "reason", "filtered", "model", model) + return body + } + updated, ok := insertReplayItems(body, filtered) + if !ok { + r.logger.Debug("reasoning_replay_miss", "reason", "insert_failed", "model", model) + return body + } + r.logger.Debug("reasoning_replay_hit", "model", model, "injected", len(filtered)) + return updated +} + +// StoreFromCompleted 从完整 Responses JSON 写入回放缓存。 +func (r *ReasoningReplay) StoreFromCompleted(ctx context.Context, model, sessionKey string, payload []byte) { + if r == nil || r.store == nil || strings.TrimSpace(sessionKey) == "" || strings.TrimSpace(model) == "" { + return + } + cfg := r.cfg.Load() + if cfg == nil || !cfg.Enabled { + return + } + items, complete := extractReplayItemsFromPayload(payload) + if !complete { + r.logger.Debug("reasoning_replay_store_skipped", "model", model, "reason", "incomplete_payload") + return + } + normalized, ok := normalizeReplayItems(items) + if !ok { + if err := r.store.Delete(ctx, model, sessionKey); err != nil { + r.logger.Warn("reasoning_replay_delete_failed", "model", model, "reason", "no_anchor", "error", err) + } else { + r.logger.Debug("reasoning_replay_delete", "model", model, "reason", "no_anchor") + } + return + } + expiresAt := r.now().UTC().Add(cfg.TTL) + if err := r.store.Set(ctx, model, sessionKey, normalized, expiresAt); err != nil { + r.logger.Warn("reasoning_replay_store_failed", "model", model, "error", err) + return + } + r.logger.Debug("reasoning_replay_store", "model", model, "items", len(normalized)) +} + +// Clear 删除指定会话的回放缓存(compact 成功等)。 +func (r *ReasoningReplay) Clear(ctx context.Context, model, sessionKey string) { + if !r.Enabled() || strings.TrimSpace(sessionKey) == "" || strings.TrimSpace(model) == "" { + return + } + if err := r.store.Delete(ctx, model, sessionKey); err != nil { + r.logger.Warn("reasoning_replay_delete_failed", "model", model, "error", err) + return + } + r.logger.Debug("reasoning_replay_delete", "model", model, "reason", "explicit") +} diff --git a/backend/internal/pkg/reasoningreplay/reasoning_replay_test.go b/backend/internal/pkg/reasoningreplay/reasoning_replay_test.go new file mode 100644 index 0000000000000000000000000000000000000000..adc02b2c7323152970afedddf56c601b387e031c --- /dev/null +++ b/backend/internal/pkg/reasoningreplay/reasoning_replay_test.go @@ -0,0 +1,233 @@ +package reasoningreplay + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" +) + +func validEncrypted(seed byte) string { + buffer := make([]byte, 0, 256) + for index := 0; len(buffer) < 256; index++ { + digest := sha256.Sum256([]byte{seed, byte(index), byte(index >> 8)}) + buffer = append(buffer, digest[:]...) + } + return base64.RawStdEncoding.EncodeToString(buffer[:256]) +} + +func TestNormalizeAndStoreRoundTrip(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(1) + payload := []byte(`{"id":"resp_1","output":[{"type":"reasoning","encrypted_content":"` + enc + `","summary":[{"type":"summary_text","text":"think"}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}]}`) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-a", payload) + + body := []byte(`{"model":"grok-4.5","input":[{"type":"message","role":"user","content":"next"}]}`) + updated := replay.Apply(context.Background(), "grok-4.5", "session-a", body) + var got struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(updated, &got); err != nil { + t.Fatal(err) + } + if len(got.Input) != 3 { + t.Fatalf("input len = %d, body=%s", len(got.Input), updated) + } + if got.Input[0]["type"] != "reasoning" || got.Input[0]["encrypted_content"] != enc || got.Input[1]["role"] != "assistant" || got.Input[2]["role"] != "user" { + t.Fatalf("unexpected replay order: %s", updated) + } +} + +func TestApplyInsertsReasoningBeforeMatchingAssistant(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(14) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-order", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)) + body := []byte(`{"input":[{"type":"message","role":"user","content":"first"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]},{"type":"message","role":"user","content":"next"}]}`) + updated := replay.Apply(context.Background(), "grok-4.5", "session-order", body) + var got struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(updated, &got); err != nil { + t.Fatal(err) + } + if len(got.Input) != 4 || got.Input[1]["type"] != "reasoning" || got.Input[2]["role"] != "assistant" || got.Input[3]["role"] != "user" { + t.Fatalf("unexpected replay order: %s", updated) + } +} + +func TestApplyAlignsAnthropicVisibleToolCallID(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(15) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-toolu", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}]}`)) + body := []byte(`{"input":[{"type":"function_call_output","call_id":"toolu_call_1","output":"result"}]}`) + updated := replay.Apply(context.Background(), "grok-4.5", "session-toolu", body) + var got struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(updated, &got); err != nil { + t.Fatal(err) + } + if len(got.Input) != 3 || got.Input[0]["type"] != "reasoning" || got.Input[1]["type"] != "function_call" || got.Input[1]["call_id"] != "toolu_call_1" || got.Input[2]["type"] != "function_call_output" { + t.Fatalf("unexpected tool replay: %s", updated) + } +} + +func TestApplySkipsPreviousResponseID(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(2) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-b", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + body := []byte(`{"previous_response_id":"resp_old","input":[{"type":"message","role":"user","content":"x"}]}`) + updated := replay.Apply(context.Background(), "grok-4.5", "session-b", body) + if strings.Contains(string(updated), enc) { + t.Fatalf("should skip when previous_response_id present: %s", updated) + } +} + +func TestFilterRejectsAssistantMismatch(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(3) + payload := []byte(`{"output":[{"type":"reasoning","encrypted_content":"` + enc + `"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"cached-answer"}]}]}`) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-c", payload) + body := []byte(`{"input":[{"type":"message","role":"user","content":"q"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"different"}]},{"type":"message","role":"user","content":"next"}]}`) + updated := replay.Apply(context.Background(), "grok-4.5", "session-c", body) + if strings.Contains(string(updated), enc) { + t.Fatalf("mismatched assistant should block inject: %s", updated) + } +} + +func TestNoAnchorDeletesPrevious(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(4) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-d", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-d", []byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"only message"}]}]}`)) + body := []byte(`{"input":[{"type":"message","role":"user","content":"x"}]}`) + updated := replay.Apply(context.Background(), "grok-4.5", "session-d", body) + if strings.Contains(string(updated), enc) { + t.Fatalf("no-anchor completion should clear cache: %s", updated) + } +} + +func TestCaptureBodyStoresNonStream(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(5) + raw := []byte(`{"output":[{"type":"reasoning","encrypted_content":"` + enc + `"}]}`) + body := io.NopCloser(strings.NewReader(string(raw))) + wrapped := replay.CaptureBody(body, "grok-4.5", "session-e", false, false) + data, err := io.ReadAll(wrapped) + if err != nil { + t.Fatal(err) + } + if err := wrapped.Close(); err != nil { + t.Fatal(err) + } + if string(data) != string(raw) { + t.Fatalf("body mutated: %s", data) + } + updated := replay.Apply(context.Background(), "grok-4.5", "session-e", []byte(`{"input":[{"type":"message","role":"user","content":"n"}]}`)) + if !strings.Contains(string(updated), enc) { + t.Fatalf("capture did not store: %s", updated) + } +} + +func TestIncompletePayloadRetainsPreviousReplay(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(16) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-partial", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-partial", []byte(`{"output":[`)) + updated := replay.Apply(context.Background(), "grok-4.5", "session-partial", []byte(`{"input":[{"type":"message","role":"user","content":"next"}]}`)) + if !strings.Contains(string(updated), enc) { + t.Fatalf("incomplete payload deleted previous replay: %s", updated) + } +} + +func TestCaptureBodyClosedBeforeEOFRetainsPreviousReplay(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(17) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-short-read", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + wrapped := replay.CaptureBody(io.NopCloser(strings.NewReader(`{"output":[]}`)), "grok-4.5", "session-short-read", false, false) + buffer := make([]byte, 4) + if _, err := wrapped.Read(buffer); err != nil { + t.Fatal(err) + } + if err := wrapped.Close(); err != nil { + t.Fatal(err) + } + updated := replay.Apply(context.Background(), "grok-4.5", "session-short-read", []byte(`{"input":[{"type":"message","role":"user","content":"next"}]}`)) + if !strings.Contains(string(updated), enc) { + t.Fatalf("short read deleted previous replay: %s", updated) + } +} + +func TestUpdateConfigConcurrentWithReplay(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(18) + payload := []byte(`{"output":[{"type":"reasoning","encrypted_content":"` + enc + `"}]}`) + body := []byte(`{"input":[{"type":"message","role":"user","content":"next"}]}`) + done := make(chan struct{}) + go func() { + defer close(done) + for index := 0; index < 1000; index++ { + replay.UpdateConfig(Config{Enabled: index%2 == 0, TTL: time.Hour}) + } + }() + for index := 0; index < 1000; index++ { + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-config", payload) + _ = replay.Apply(context.Background(), "grok-4.5", "session-config", body) + } + <-done +} + +func TestNormalizeReasoningRejectsForeignEncryptedContent(t *testing.T) { + for _, value := range []string{ + "gAAAAABcodex-shaped-signature-with-padding==", + base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte("a"), 128)), + } { + if validGrokReplayEncryptedContent(value) { + t.Fatalf("foreign encrypted content accepted: %q", value) + } + } + if value := validEncrypted(19); !validGrokReplayEncryptedContent(value) { + t.Fatal("valid Grok-shaped encrypted content rejected") + } +} + +func TestDisabledNoOp(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + replay := New(store, Config{Enabled: false, TTL: time.Hour}, slog.Default()) + enc := validEncrypted(6) + replay.StoreFromCompleted(context.Background(), "grok-4.5", "session-f", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + body := []byte(`{"input":[{"type":"message","role":"user","content":"x"}]}`) + if updated := replay.Apply(context.Background(), "grok-4.5", "session-f", body); string(updated) != string(body) { + t.Fatalf("disabled should no-op: %s", updated) + } +} + +func TestMemoryTTLExpire(t *testing.T) { + store := memory.NewReasoningReplayStore(100) + now := time.Now().UTC() + if err := store.Set(context.Background(), "m", "s", [][]byte{[]byte(`{"type":"reasoning"}`)}, now.Add(10*time.Millisecond)); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + if _, ok, err := store.Get(context.Background(), "m", "s", time.Now().UTC(), time.Hour); err != nil || ok { + t.Fatalf("expected expired miss ok=%v err=%v", ok, err) + } +} diff --git a/backend/internal/pkg/reasoningreplay/scenario_test.go b/backend/internal/pkg/reasoningreplay/scenario_test.go new file mode 100644 index 0000000000000000000000000000000000000000..74caacd4d966358aae8c814725f49be44d0d7d9e --- /dev/null +++ b/backend/internal/pkg/reasoningreplay/scenario_test.go @@ -0,0 +1,272 @@ +package reasoningreplay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory" +) + +// 模拟真实 Grok Build Responses 完成体 + Claude Code 式第二轮(不带 encrypted)。 +func TestScenario_RealTwoTurnHit(t *testing.T) { + store := memory.NewReasoningReplayStore(1024) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + ctx := context.Background() + + // 与 resolvePromptCacheIdentity 形态接近的隔离 key + session := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + model := "grok-4.5" + enc := validEncrypted(13) + + // Turn1 上游成功响应(非流式) + turn1Upstream := fmt.Sprintf(`{ + "id": "resp_turn1", + "object": "response", + "model": "%s", + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "用户在问互斥锁"}], + "content": null, + "encrypted_content": %q + }, + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "content": [{"type": "output_text", "text": "互斥锁用于保护共享资源,同一时刻只允许一个线程进入临界区。"}] + } + ], + "usage": {"input_tokens": 20, "output_tokens": 30, "total_tokens": 50} + }`, model, enc) + + // 模拟 adapter CaptureBody:读完再 Close + body := io.NopCloser(bytes.NewReader([]byte(turn1Upstream))) + captured := replay.CaptureBody(body, model, session, false, false) + if _, err := io.Copy(io.Discard, captured); err != nil { + t.Fatal(err) + } + if err := captured.Close(); err != nil { + t.Fatal(err) + } + + // Turn2 客户端故意不带 encrypted_content,只带明文历史 + turn2Client := []byte(`{ + "model": "grok-4.5", + "prompt_cache_key": "client-visible-key", + "input": [ + {"type": "message", "role": "user", "content": "用一句话解释什么是互斥锁"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "互斥锁用于保护共享资源,同一时刻只允许一个线程进入临界区。"}]}, + {"type": "message", "role": "user", "content": "那死锁呢?一句话"} + ] + }`) + + // 模拟 adapter:inject prompt_cache_key 之后 Apply + outbound := replay.Apply(ctx, model, session, turn2Client) + + var got struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(outbound, &got); err != nil { + t.Fatal(err) + } + + // 硬标准:出站 input 含客户端没发的 encrypted_content + if strings.Contains(string(turn2Client), enc) { + t.Fatal("precondition failed: client body should not contain encrypted") + } + if !strings.Contains(string(outbound), enc) { + t.Fatalf("HIT failed: outbound missing encrypted\nclient=%s\noutbound=%s", turn2Client, outbound) + } + + // reasoning 应插在最后一条 user 之前(最后一个 assistant 之后) + var types []string + for _, item := range got.Input { + types = append(types, fmt.Sprint(item["type"])) + } + // 期望至少包含 reasoning + hasReasoning := false + for _, typ := range types { + if typ == "reasoning" { + hasReasoning = true + break + } + } + if !hasReasoning { + t.Fatalf("types=%v", types) + } + t.Logf("PASS real two-turn HIT; input types=%v", types) +} + +func TestScenario_SSEStreamCompletedStores(t *testing.T) { + store := memory.NewReasoningReplayStore(256) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + ctx := context.Background() + enc := validEncrypted(7) + session, model := "sse-session", "grok-4.5" + + // 真实 SSE:完整 output items 在 done 事件中,completed.output 为空。 + sse := strings.Join([]string{ + `event: response.output_item.done`, + fmt.Sprintf(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":%q}}`, enc), + ``, + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_sse","output":[]}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + wrapped := replay.CaptureBody(io.NopCloser(strings.NewReader(sse)), model, session, true, false) + if _, err := io.Copy(io.Discard, wrapped); err != nil { + t.Fatal(err) + } + if err := wrapped.Close(); err != nil { + t.Fatal(err) + } + + body := []byte(`{"input":[{"type":"message","role":"user","content":"continue"}]}`) + out := replay.Apply(ctx, model, session, body) + if !strings.Contains(string(out), enc) { + t.Fatalf("SSE completed should store; out=%s", out) + } + t.Log("PASS SSE stream store+apply HIT") +} + +func TestScenario_TenantIsolation(t *testing.T) { + store := memory.NewReasoningReplayStore(256) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + ctx := context.Background() + encA, encB := validEncrypted(8), validEncrypted(9) + model := "grok-4.5" + + replay.StoreFromCompleted(ctx, model, "tenant-A-key", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+encA+`"}]}`)) + replay.StoreFromCompleted(ctx, model, "tenant-B-key", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+encB+`"}]}`)) + + outA := replay.Apply(ctx, model, "tenant-A-key", []byte(`{"input":[{"type":"message","role":"user","content":"x"}]}`)) + outB := replay.Apply(ctx, model, "tenant-B-key", []byte(`{"input":[{"type":"message","role":"user","content":"x"}]}`)) + + if !strings.Contains(string(outA), encA) || strings.Contains(string(outA), encB) { + t.Fatalf("A isolation failed: %s", outA) + } + if !strings.Contains(string(outB), encB) || strings.Contains(string(outB), encA) { + t.Fatalf("B isolation failed: %s", outB) + } + t.Log("PASS tenant isolation") +} + +func TestScenario_CompactClearsCache(t *testing.T) { + store := memory.NewReasoningReplayStore(64) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + ctx := context.Background() + enc := validEncrypted(10) + model, session := "grok-4.5", "compact-sess" + + replay.StoreFromCompleted(ctx, model, session, []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + + // compact 成功响应经过 CaptureBody(compact=true) + compactBody := `{"id":"resp_compact","output":[{"type":"compaction","encrypted_content":"opaque"}]}` + wrapped := replay.CaptureBody(io.NopCloser(strings.NewReader(compactBody)), model, session, false, true) + _, _ = io.Copy(io.Discard, wrapped) + _ = wrapped.Close() + + out := replay.Apply(ctx, model, session, []byte(`{"input":[{"type":"message","role":"user","content":"after compact"}]}`)) + if strings.Contains(string(out), enc) { + t.Fatalf("compact should clear replay: %s", out) + } + t.Log("PASS compact clears") +} + +func TestScenario_ToolCallInjectOnlyWithMatchingOutput(t *testing.T) { + store := memory.NewReasoningReplayStore(64) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + ctx := context.Background() + enc := validEncrypted(11) + model, session := "grok-4.5", "tool-sess" + + // 上一轮:reasoning + function_call(合法 JSON) + payload, err := json.Marshal(map[string]any{ + "output": []any{ + map[string]any{"type": "reasoning", "encrypted_content": enc}, + map[string]any{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": `{"q":"x"}`}, + }, + }) + if err != nil { + t.Fatal(err) + } + replay.StoreFromCompleted(ctx, model, session, payload) + stored, ok, storeErr := store.Get(ctx, model, session, time.Now().UTC(), time.Hour) + if storeErr != nil || !ok || len(stored) == 0 { + t.Fatalf("store miss: ok=%v err=%v items=%d payload=%s", ok, storeErr, len(stored), payload) + } + t.Logf("stored %d items", len(stored)) + + // 客户端带回 tool 结果,但没带 function_call 与 reasoning + body, err := json.Marshal(map[string]any{ + "input": []any{ + map[string]any{"type": "message", "role": "user", "content": "查一下"}, + map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "result"}, + }, + }) + if err != nil { + t.Fatal(err) + } + out := replay.Apply(ctx, model, session, body) + if !strings.Contains(string(out), enc) { + t.Fatalf("should inject reasoning: stored=%d out=%s", len(stored), out) + } + if !strings.Contains(string(out), `"type":"function_call"`) && !strings.Contains(string(out), `"type": "function_call"`) { + // json.Marshal 可能无空格 + if !strings.Contains(string(out), "function_call") || !strings.Contains(string(out), "lookup") { + t.Fatalf("should inject function_call before output: %s", out) + } + } + + // 无 matching output 时 function_call 不应注入(filter 要求有 output) + body2, _ := json.Marshal(map[string]any{ + "input": []any{map[string]any{"type": "message", "role": "user", "content": "no tool result"}}, + }) + out2 := replay.Apply(ctx, model, session, body2) + if strings.Contains(string(out2), "lookup") { + t.Fatalf("function_call without output must not inject: %s", out2) + } + // reasoning 仍应可注入 + if !strings.Contains(string(out2), enc) { + t.Fatalf("reasoning should still inject without tool output: %s", out2) + } + t.Log("PASS tool-call filter rules") +} + +func TestScenario_WrongSessionMiss(t *testing.T) { + store := memory.NewReasoningReplayStore(32) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + ctx := context.Background() + enc := validEncrypted(12) + replay.StoreFromCompleted(ctx, "grok-4.5", "sess-1", []byte(`{"output":[{"type":"reasoning","encrypted_content":"`+enc+`"}]}`)) + out := replay.Apply(ctx, "grok-4.5", "sess-2", []byte(`{"input":[{"type":"message","role":"user","content":"x"}]}`)) + if strings.Contains(string(out), enc) { + t.Fatal("wrong session must miss") + } + t.Log("PASS wrong session miss") +} + +func TestScenario_ConfigDefaultsEnableReplay(t *testing.T) { + // 与 config.defaultConfig 一致:Enabled 默认 true + store := memory.NewReasoningReplayStore(16) + replay := New(store, Config{Enabled: true, TTL: time.Hour}, slog.Default()) + if !replay.Enabled() { + t.Fatal("expected enabled") + } + t.Log("PASS default enabled") +} diff --git a/backend/internal/pkg/resultcache/cache.go b/backend/internal/pkg/resultcache/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..e98b59e1185cc31020dc14c388a703a4e48a751b --- /dev/null +++ b/backend/internal/pkg/resultcache/cache.go @@ -0,0 +1,126 @@ +package resultcache + +import ( + "context" + "errors" + "sync" + "time" +) + +type entry[V any] struct { + value V + expiresAt time.Time + storedAt time.Time +} + +type flight[V any] struct { + done chan struct{} + value V + err error +} + +// Cache 保存少量短生命周期计算结果;达到容量时淘汰最早写入的条目。 +type Cache[K comparable, V any] struct { + mu sync.Mutex + ttl time.Duration + maxSize int + values map[K]entry[V] + loads map[K]*flight[V] +} + +func New[K comparable, V any](maxSize int, ttl time.Duration) *Cache[K, V] { + if maxSize < 1 { + maxSize = 1 + } + if ttl <= 0 { + ttl = time.Second + } + return &Cache[K, V]{ttl: ttl, maxSize: maxSize, values: make(map[K]entry[V], maxSize), loads: make(map[K]*flight[V])} +} + +func (c *Cache[K, V]) Get(key K, now time.Time) (V, bool) { + c.mu.Lock() + defer c.mu.Unlock() + value, ok := c.values[key] + if !ok { + var zero V + return zero, false + } + if !now.Before(value.expiresAt) { + delete(c.values, key) + var zero V + return zero, false + } + return value.value, true +} + +// Load 合并同一键的并发加载;等待者可独立取消,不受首个请求生命周期拖累。 +func (c *Cache[K, V]) Load(ctx context.Context, key K, now time.Time, loader func() (V, error)) (V, error) { + if value, ok := c.Get(key, now); ok { + return value, nil + } + c.mu.Lock() + if pending, ok := c.loads[key]; ok { + c.mu.Unlock() + select { + case <-pending.done: + return pending.value, pending.err + case <-ctx.Done(): + var zero V + return zero, ctx.Err() + } + } + pending := &flight[V]{done: make(chan struct{})} + c.loads[key] = pending + c.mu.Unlock() + + defer func() { + if recovered := recover(); recovered != nil { + c.mu.Lock() + pending.err = errors.New("缓存加载异常中断") + delete(c.loads, key) + close(pending.done) + c.mu.Unlock() + panic(recovered) + } + }() + pending.value, pending.err = loader() + c.mu.Lock() + if pending.err == nil { + c.setLocked(key, pending.value, now) + } + delete(c.loads, key) + close(pending.done) + c.mu.Unlock() + return pending.value, pending.err +} + +func (c *Cache[K, V]) Set(key K, value V, now time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.setLocked(key, value, now) +} + +// Delete 失效指定键的已缓存结果;正在执行的加载不被中断。 +func (c *Cache[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.values, key) +} + +func (c *Cache[K, V]) setLocked(key K, value V, now time.Time) { + if _, exists := c.values[key]; !exists && len(c.values) >= c.maxSize { + var oldestKey K + var oldestAt time.Time + found := false + for candidateKey, candidate := range c.values { + if !found || candidate.storedAt.Before(oldestAt) { + oldestKey, oldestAt, found = candidateKey, candidate.storedAt, true + } + } + if found { + delete(c.values, oldestKey) + } + } + c.values[key] = entry[V]{value: value, expiresAt: now.Add(c.ttl), storedAt: now} +} diff --git a/backend/internal/pkg/resultcache/cache_test.go b/backend/internal/pkg/resultcache/cache_test.go new file mode 100644 index 0000000000000000000000000000000000000000..fe546cbd32c06947f538ebe692938bd9bf9b32db --- /dev/null +++ b/backend/internal/pkg/resultcache/cache_test.go @@ -0,0 +1,97 @@ +package resultcache + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestCacheExpiresAndEvictsOldestEntry(t *testing.T) { + now := time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC) + cache := New[string, int](2, time.Minute) + cache.Set("first", 1, now) + cache.Set("second", 2, now.Add(time.Second)) + cache.Set("third", 3, now.Add(2*time.Second)) + + if _, ok := cache.Get("first", now.Add(3*time.Second)); ok { + t.Fatal("oldest cache entry was not evicted") + } + if value, ok := cache.Get("third", now.Add(3*time.Second)); !ok || value != 3 { + t.Fatalf("third entry = %d, %v", value, ok) + } + if _, ok := cache.Get("second", now.Add(2*time.Minute)); ok { + t.Fatal("expired cache entry was returned") + } +} + +func TestCacheDeleteInvalidatesStoredValue(t *testing.T) { + now := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) + cache := New[string, int](2, time.Minute) + cache.Set("account-risk", 1, now) + cache.Delete("account-risk") + + if _, ok := cache.Get("account-risk", now); ok { + t.Fatal("deleted cache entry was returned") + } +} + +func TestCacheCoalescesConcurrentLoads(t *testing.T) { + now := time.Now() + cache := New[string, int](2, time.Minute) + var calls atomic.Int32 + start := make(chan struct{}) + var wait sync.WaitGroup + results := make([]int, 2) + wait.Add(2) + for index := range results { + go func() { + defer wait.Done() + <-start + value, err := cache.Load(context.Background(), "shared", now, func() (int, error) { + calls.Add(1) + time.Sleep(10 * time.Millisecond) + return 42, nil + }) + if err != nil { + t.Errorf("load: %v", err) + } + results[index] = value + }() + } + close(start) + wait.Wait() + if calls.Load() != 1 || results[0] != 42 || results[1] != 42 { + t.Fatalf("calls = %d, results = %#v", calls.Load(), results) + } +} + +func TestCacheWaiterHonorsCancellation(t *testing.T) { + cache := New[string, int](2, time.Minute) + loaderStarted := make(chan struct{}) + releaseLoader := make(chan struct{}) + loaderDone := make(chan struct{}) + go func() { + defer close(loaderDone) + _, _ = cache.Load(context.Background(), "shared", time.Now(), func() (int, error) { + close(loaderStarted) + <-releaseLoader + return 42, nil + }) + }() + <-loaderStarted + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := cache.Load(ctx, "shared", time.Now(), func() (int, error) { + t.Fatal("canceled waiter unexpectedly became the loader") + return 0, nil + }); !errors.Is(err, context.Canceled) { + t.Fatalf("load error = %v, want context canceled", err) + } + + close(releaseLoader) + <-loaderDone +} diff --git a/backend/internal/pkg/signerurl/policy.go b/backend/internal/pkg/signerurl/policy.go new file mode 100644 index 0000000000000000000000000000000000000000..4d35f5d8738cc7c95fa5c8891d615f0cbb2ef2e3 --- /dev/null +++ b/backend/internal/pkg/signerurl/policy.go @@ -0,0 +1,68 @@ +package signerurl + +import ( + "fmt" + "net/netip" + "net/url" + "strconv" + "strings" +) + +const MaxLength = 2048 + +// Validate 接受公网 HTTPS:443,或管理员显式配置的内网 HTTP/HTTPS 地址。 +// 内网范围包括容器单标签服务名、localhost、.local/.internal 和私有地址。 +func Validate(value string) error { + raw := strings.TrimSpace(value) + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Contains(raw, "#") || len(raw) > MaxLength { + return fmt.Errorf("签名 URL 必须是无凭据、查询参数和片段的完整地址") + } + if port := parsed.Port(); port != "" { + value, portErr := strconv.Atoi(port) + if portErr != nil || value < 1 || value > 65535 { + return fmt.Errorf("签名 URL 端口无效") + } + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + internal := IsInternalHost(host) + switch strings.ToLower(parsed.Scheme) { + case "http": + if internal { + return nil + } + case "https": + if internal || parsed.Port() == "" || parsed.Port() == "443" { + return nil + } + } + return fmt.Errorf("公网签名 URL 必须使用 HTTPS:443;HTTP 和自定义端口仅允许可信内网地址") +} + +func IsInternalHost(host string) bool { + host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + if address, err := netip.ParseAddr(host); err == nil { + address = address.Unmap() + return address.IsLoopback() || address.IsPrivate() || address.IsLinkLocalUnicast() + } + if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") { + return true + } + return !strings.Contains(host, ".") && validServiceLabel(host) +} + +func validServiceLabel(value string) bool { + if len(value) < 1 || len(value) > 63 || !asciiAlphaNumeric(value[0]) || !asciiAlphaNumeric(value[len(value)-1]) { + return false + } + for index := 1; index < len(value)-1; index++ { + if !asciiAlphaNumeric(value[index]) && value[index] != '-' && value[index] != '_' { + return false + } + } + return true +} + +func asciiAlphaNumeric(value byte) bool { + return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' +} diff --git a/backend/internal/pkg/signerurl/policy_test.go b/backend/internal/pkg/signerurl/policy_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6be5b1dc1f8efe09fb22e749c8462fca4585d921 --- /dev/null +++ b/backend/internal/pkg/signerurl/policy_test.go @@ -0,0 +1,36 @@ +package signerurl + +import "testing" + +func TestValidateAllowsPublicHTTPSAndTrustedInternalSigner(t *testing.T) { + for _, value := range []string{ + "https://grok.wodf.de/sign", + "http://grok-signer-go:8788/sign", + "http://localhost:8788/sign", + "http://host.docker.internal:8788/sign", + "http://127.0.0.1:8788/sign", + "http://10.0.0.8:8788/sign", + "https://grok-signer-go:8788/sign", + } { + if err := Validate(value); err != nil { + t.Fatalf("%q rejected: %v", value, err) + } + } +} + +func TestValidateRejectsUnsafeOrMalformedSigner(t *testing.T) { + for _, value := range []string{ + "http://grok.wodf.de/sign", + "https://grok.wodf.de:8443/sign", + "https://user:pass@grok.wodf.de/sign", + "https://grok.wodf.de/sign?token=value", + "https://grok.wodf.de/sign#fragment", + "ftp://grok-signer-go/sign", + "http://8.8.8.8:8788/sign", + "grok-signer-go:8788/sign", + } { + if err := Validate(value); err == nil { + t.Fatalf("unsafe URL %q accepted", value) + } + } +} diff --git a/backend/internal/repository/account.go b/backend/internal/repository/account.go new file mode 100644 index 0000000000000000000000000000000000000000..e6b2a890b7396af97c862fe0f3d9ef5519659f8d --- /dev/null +++ b/backend/internal/repository/account.go @@ -0,0 +1,162 @@ +package repository + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +// AccountUpdates 表示批量账号更新中允许持久化的字段。 +type AccountUpdates struct { + Enabled *bool + Priority *int + MaxConcurrent *int + MinimumRemaining *float64 +} + +type AccountUpsertResult struct { + ID uint64 + Created bool +} + +// CredentialRefreshFailure is the bounded diagnostic state persisted for the +// latest failed OAuth refresh. Response must already be redacted by the +// provider adapter before it reaches persistence. +type CredentialRefreshFailure struct { + Count int + RetryAt time.Time + Status int + Code string + Message string + Response string + Permanent bool +} + +// LinkedDeleteResolution is the server-side expansion of root deletes with optional linked peers. +type LinkedDeleteResolution struct { + RootIDs []uint64 + FinalIDs []uint64 + LinkedByProvider map[account.Provider]int + // RootGroups maps each root to its final peers and defines the media-skip group boundary. + RootGroups map[uint64][]uint64 + // PeerProviders records each peer provider for post-delete accounting. + PeerProviders map[uint64]account.Provider +} + +// LinkedDeleteOutcome contains actual rows deleted and root groups skipped atomically. +type LinkedDeleteOutcome struct { + Resolution LinkedDeleteResolution + DeletedIDs []uint64 + Deleted int64 + RootsDeleted int64 + LinkedDeletedByProvider map[account.Provider]int64 + // SkippedRoots identifies groups protected by queued or in-progress video jobs. + SkippedRoots []uint64 +} + +// CleanupPreview contains COUNT-only values for the cleanup confirmation dialog. +type CleanupPreview struct { + RootsByStatus map[string]int64 + RootCount int64 + LinkedByProvider map[account.Provider]int64 + Total int64 +} + +// ObservedModelWriter reports whether an observed model update changed the authoritative row. +type ObservedModelWriter interface { + UpdateObservedModelIfNewer(ctx context.Context, id uint64, model string, observedAt time.Time) (bool, error) +} + +// RoutingLayerRepository separates reusable account state from model overlays. +type RoutingLayerRepository interface { + ListRoutingAccountBases(ctx context.Context, provider account.Provider, quotaMode string) ([]account.RoutingAccountBase, error) + ListRoutingAccountOverlays(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel string) (account.RoutingOverlaySnapshot, error) +} + +// AccountRepository 定义 OAuth 账号和额度快照持久化能力。 +type AccountRepository interface { + List(ctx context.Context, query AccountListQuery) ([]account.Credential, int64, error) + // ListProviderAccountBatch 以 ID 游标取一批账号;total 仅在 afterID 为 0 时返回。 + ListProviderAccountBatch(ctx context.Context, provider account.Provider, afterID uint64, limit int) ([]account.Credential, int64, error) + Summarize(ctx context.Context, now time.Time) ([]AccountSummary, error) + ListEnabled(ctx context.Context, provider account.Provider) ([]account.Credential, error) + ListEnabledAccountIDs(ctx context.Context, provider account.Provider, refreshableOnly bool) ([]uint64, error) + // ListEnabledCredentialRefreshAccountIDs includes enabled active and + // reauthRequired accounts so an explicit administrator refresh can retry a + // previously rejected refresh token once. + ListEnabledCredentialRefreshAccountIDs(ctx context.Context, provider account.Provider, refreshableOnly bool) ([]uint64, error) + CountProviderAccountsByIDs(ctx context.Context, provider account.Provider, ids []uint64) (int64, error) + // FilterMissingBuildConversionIDs 从指定账号中排除已经关联 Build 的 Web 账号。 + FilterMissingBuildConversionIDs(ctx context.Context, ids []uint64) ([]uint64, error) + // ListUnlinkedWebAccountIDs 以 ID 游标取未关联 Web 账号;total 仅在 afterID 为 0 时返回。 + ListUnlinkedWebAccountIDs(ctx context.Context, afterID uint64, limit int) ([]uint64, int64, error) + // ListMissingConsoleSyncAccounts 从指定账号中排除已有对应 Console 账号的 Web 账号。 + ListMissingConsoleSyncAccounts(ctx context.Context, ids []uint64) ([]account.Credential, error) + // ListMissingConsoleSyncBatch 以 ID 游标取缺少 Console 账号的 Web 账号;total/skipped 仅在 afterID 为 0 时返回。 + ListMissingConsoleSyncBatch(ctx context.Context, afterID uint64, limit int) ([]account.Credential, int64, int64, error) + HasActive(ctx context.Context, provider account.Provider) (bool, error) + ListRoutingCandidates(ctx context.Context, provider account.Provider, modelRouteID uint64, upstreamModel, quotaMode string) ([]account.RoutingCandidate, error) + GetCredentialMaterial(ctx context.Context, accountID uint64, provider account.Provider) (account.CredentialMaterial, error) + Get(ctx context.Context, id uint64) (account.Credential, error) + LinkWebToBuild(ctx context.Context, webAccountID, buildAccountID uint64) error + GetBillings(ctx context.Context, accountIDs []uint64) (map[uint64]account.Billing, error) + GetQuotaRecoveries(ctx context.Context, accountIDs []uint64) (map[uint64]account.QuotaRecovery, error) + UpsertByIdentity(ctx context.Context, value account.Credential) (account.Credential, bool, error) + Update(ctx context.Context, value account.Credential) (account.Credential, error) + UpdateMany(ctx context.Context, provider account.Provider, ids []uint64, updates AccountUpdates) (int64, error) + Delete(ctx context.Context, id uint64) error + DeleteMany(ctx context.Context, ids []uint64) (int64, error) + // ResolveLinkedDeleteIDs expands root account IDs with one-hop (or Build/Console two-hop via Web) + // peers from link tables for optional linked deletion. It never guesses by email/name/userId. + ResolveLinkedDeleteIDs(ctx context.Context, provider account.Provider, rootIDs []uint64, targets []account.Provider) (LinkedDeleteResolution, error) + // DeleteManyWithLinked locks roots, resolves linked peers, checks media jobs, and deletes + // the final set inside a single DB transaction (avoids resolve/delete TOCTOU). + // skipMedia=false rejects on active media; skipMedia=true skips the complete root group. + DeleteManyWithLinked(ctx context.Context, provider account.Provider, rootIDs []uint64, targets []account.Provider, skipMedia bool) (LinkedDeleteOutcome, error) + // DeleteAccountStatusBatchWithLinked selects at most limit roots by state and ID cursor, + // expands links, skips protected groups, and returns the candidate count and next cursor. + DeleteAccountStatusBatchWithLinked(ctx context.Context, provider account.Provider, status string, now time.Time, afterID uint64, limit int, targets []account.Provider) (LinkedDeleteOutcome, int, uint64, error) + // CountCleanupWithLinked returns root and linked-peer counts using SQL COUNT queries only. + CountCleanupWithLinked(ctx context.Context, provider account.Provider, statuses []string, now time.Time, targets []account.Provider) (CleanupPreview, error) + // ListAutoCleanReauthCandidates 以 ID 游标列出达到清理年龄的 reauthRequired 账号。 + ListAutoCleanReauthCandidates(ctx context.Context, markedBefore time.Time, includeDisabled bool, afterID uint64, limit int) ([]uint64, error) + // DeleteAutoCleanReauthCandidates 在事务内重新校验状态与年龄并跳过活动视频任务,返回实际删除 ID。 + DeleteAutoCleanReauthCandidates(ctx context.Context, markedBefore time.Time, includeDisabled bool, candidateIDs []uint64) ([]uint64, error) + UpdateTokens(ctx context.Context, id uint64, accessToken, refreshToken string, expiresAt time.Time) (account.Credential, error) + BackfillCredentialRefreshSchedules(ctx context.Context, now time.Time, limit int) (int, error) + ListCriticalCredentialRefreshIDs(ctx context.Context, now, expiresBefore time.Time, limit int) ([]uint64, error) + ListDueCredentialRefreshIDs(ctx context.Context, now time.Time, limit int) ([]uint64, error) + NextCredentialRefreshDueAt(ctx context.Context) (*time.Time, error) + UpdateCredentialRefreshFailure(ctx context.Context, id uint64, failure CredentialRefreshFailure) error + UpdateObservedModel(ctx context.Context, id uint64, model string, observedAt time.Time) error + UpdateHealth(ctx context.Context, id uint64, failureCount int, cooldownUntil *time.Time, lastError string, success bool) error + // MarkBuildAPIFallback 幂等写入 Build 账号的 XAI 推理回退标记;非 Build 账号返回错误。 + MarkBuildAPIFallback(ctx context.Context, id uint64, enabled bool) error + // MarkWebNSFWEnabled 幂等记录 Web 账号首次确认 NSFW 已开启的时间。 + MarkWebNSFWEnabled(ctx context.Context, id uint64, enabledAt time.Time) error + // MarkWebTermsAccepted 幂等记录 Web 账号已完整接受的产品协议版本与时间。 + MarkWebTermsAccepted(ctx context.Context, id uint64, version int, acceptedAt time.Time) error + // MarkWebBirthDateSet 幂等记录 Web 账号首次确认生日已设置的时间。 + MarkWebBirthDateSet(ctx context.Context, id uint64, setAt time.Time) error + UpsertModelQuotaBlock(ctx context.Context, value account.ModelQuotaBlock) error + PruneExpiredModelQuotaBlocks(ctx context.Context, now time.Time, limit int) (int64, error) + SaveBilling(ctx context.Context, value account.Billing) error + GetBilling(ctx context.Context, accountID uint64) (account.Billing, error) + GetQuotaRecovery(ctx context.Context, accountID uint64) (account.QuotaRecovery, error) + SaveQuotaRecovery(ctx context.Context, value account.QuotaRecovery) error + ClaimQuotaProbe(ctx context.Context, accountID uint64, now, leaseUntil time.Time) (bool, error) + ClearQuotaRecovery(ctx context.Context, accountID uint64) error + ResetQuotaState(ctx context.Context, provider account.Provider, accountIDs []uint64) error + ResetProviderQuotaState(ctx context.Context, provider account.Provider, activeOnly bool) (int64, error) + HasQuotaWindows(ctx context.Context, accountID uint64) (bool, error) + GetQuotaWindows(ctx context.Context, accountIDs []uint64) (map[uint64][]account.QuotaWindow, error) + ReplaceQuotaWindows(ctx context.Context, accountID uint64, tier account.WebTier, syncedAt time.Time, values []account.QuotaWindow) error + SaveQuotaWindows(ctx context.Context, accountID uint64, tier account.WebTier, syncedAt time.Time, values []account.QuotaWindow) error + UpsertManyByIdentity(ctx context.Context, values []account.Credential) ([]AccountUpsertResult, error) + DecrementQuotaWindow(ctx context.Context, accountID uint64, mode string, now time.Time) (bool, error) + ExhaustQuotaWindow(ctx context.Context, accountID uint64, mode string, resetAt *time.Time, now time.Time) error + ListDueQuotaWindows(ctx context.Context, now time.Time, limit int) ([]account.QuotaWindow, error) + ListQuotaRecoveryWindows(ctx context.Context, limit int) ([]account.QuotaWindow, error) + ListStaleWebQuotaAccountIDs(ctx context.Context, before time.Time, limit int) ([]uint64, error) +} diff --git a/backend/internal/repository/admin.go b/backend/internal/repository/admin.go new file mode 100644 index 0000000000000000000000000000000000000000..057e2243a6df6390c3db4a6691272a135b9b57a1 --- /dev/null +++ b/backend/internal/repository/admin.go @@ -0,0 +1,27 @@ +package repository + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/admin" +) + +// AdminRepository 定义管理员持久化能力。 +type AdminRepository interface { + Count(ctx context.Context) (int64, error) + Create(ctx context.Context, value admin.Admin) (admin.Admin, error) + GetByUsername(ctx context.Context, username string) (admin.Admin, error) + GetByID(ctx context.Context, id uint64) (admin.Admin, error) + UpdatePasswordAndRevokeSessions(ctx context.Context, id uint64, passwordHash string) error +} + +// AdminSessionRepository 定义管理员刷新会话持久化能力。 +type AdminSessionRepository interface { + Create(ctx context.Context, value admin.Session) (admin.Session, error) + GetByID(ctx context.Context, id uint64) (admin.Session, error) + GetByTokenHash(ctx context.Context, tokenHash string) (admin.Session, error) + Rotate(ctx context.Context, id uint64, expectedTokenHash, newTokenHash string, expiresAt time.Time) error + Revoke(ctx context.Context, id uint64) error + RevokeAllByAdmin(ctx context.Context, adminID uint64) error +} diff --git a/backend/internal/repository/audit.go b/backend/internal/repository/audit.go new file mode 100644 index 0000000000000000000000000000000000000000..a29e56d07f7b9b4980d2772445cd505675739c8c --- /dev/null +++ b/backend/internal/repository/audit.go @@ -0,0 +1,19 @@ +package repository + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/audit" +) + +// AuditRepository 定义请求元数据审计持久化能力。 +type AuditRepository interface { + Create(ctx context.Context, value audit.Record) error + CreateBatch(ctx context.Context, values []audit.Record) error + Get(ctx context.Context, id uint64) (audit.Record, error) + List(ctx context.Context, offset, limit int) ([]audit.Record, int64, error) + ListCursor(ctx context.Context, query AuditCursorQuery) ([]audit.Record, bool, error) + Summarize(ctx context.Context, query AuditSummaryQuery) (audit.Summary, error) + SumTokensByAccountsSince(ctx context.Context, accountIDs []uint64, since time.Time) (map[uint64]int64, error) +} diff --git a/backend/internal/repository/client_key.go b/backend/internal/repository/client_key.go new file mode 100644 index 0000000000000000000000000000000000000000..2ee537407741a3da8a1ac34c4be46642515d0856 --- /dev/null +++ b/backend/internal/repository/client_key.go @@ -0,0 +1,20 @@ +package repository + +import ( + "context" + + "github.com/chenyme/grok2api/backend/internal/domain/clientkey" +) + +// ClientKeyRepository 定义下游 API Key 持久化能力。 +type ClientKeyRepository interface { + List(ctx context.Context, query ClientKeyListQuery) ([]clientkey.Key, int64, error) + Create(ctx context.Context, value clientkey.Key) (clientkey.Key, error) + Get(ctx context.Context, id uint64) (clientkey.Key, error) + GetByPrefix(ctx context.Context, prefix string) (clientkey.Key, error) + Update(ctx context.Context, value clientkey.Key) (clientkey.Key, error) + UpdateManyEnabled(ctx context.Context, ids []uint64, enabled bool) (int64, error) + Delete(ctx context.Context, id uint64) error + DeleteMany(ctx context.Context, ids []uint64) (int64, error) + Touch(ctx context.Context, id uint64) error +} diff --git a/backend/internal/repository/dashboard.go b/backend/internal/repository/dashboard.go new file mode 100644 index 0000000000000000000000000000000000000000..d675d3f05c4f15b6fdf64ea7e2bf8b4e2a63af59 --- /dev/null +++ b/backend/internal/repository/dashboard.go @@ -0,0 +1,19 @@ +package repository + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/dashboard" +) + +// DashboardSnapshotWindow 定义主趋势、上一周期与活动热力图所需的时间边界。 +type DashboardSnapshotWindow struct { + BucketBoundaries []time.Time + ActivityBoundaries []time.Time +} + +// DashboardRepository 定义管理台概览所需的只读聚合查询。 +type DashboardRepository interface { + Snapshot(ctx context.Context, window DashboardSnapshotWindow, snapshotAt time.Time) (dashboard.Aggregate, error) +} diff --git a/backend/internal/repository/egress.go b/backend/internal/repository/egress.go new file mode 100644 index 0000000000000000000000000000000000000000..956dc2fe1084ff07bac6058b5ba020a09740d014 --- /dev/null +++ b/backend/internal/repository/egress.go @@ -0,0 +1,46 @@ +package repository + +import ( + "context" + + "github.com/chenyme/grok2api/backend/internal/domain/egress" +) + +type EgressRepository interface { + ListEgressNodes(ctx context.Context, scope egress.Scope, sort SortQuery) ([]egress.Node, error) + GetEgressNode(ctx context.Context, id uint64) (egress.Node, error) + CreateEgressNode(ctx context.Context, value egress.Node) (egress.Node, error) + UpdateEgressNode(ctx context.Context, value egress.Node) (egress.Node, error) + DeleteEgressNode(ctx context.Context, id uint64) error +} + +// EgressNodePageRepository is the bounded management-list contract. Runtime +// routing repositories only need EgressRepository's full-list operations. +type EgressNodePageRepository interface { + ListEgressNodePage(ctx context.Context, input EgressNodeListQuery) ([]egress.Node, int64, error) +} + +type EgressNodeCleanupPreview struct { + Nodes int64 + BoundAccounts int64 + SubscriptionManaged int64 +} + +// EgressNodeUnhealthyCleaner provides an atomic cleanup path for nodes whose +// latest IPv4 and IPv6 probes both failed. +type EgressNodeUnhealthyCleaner interface { + PreviewUnhealthyEgressNodes(context.Context) (EgressNodeCleanupPreview, error) + DeleteUnhealthyEgressNodes(context.Context) ([]uint64, error) +} + +type EgressNodeListFilter struct { + Scope egress.Scope + Enabled *bool + ProbeStatus egress.ProbeStatus + Assignment string +} + +type EgressNodeListQuery struct { + Page PageQuery + Filter EgressNodeListFilter +} diff --git a/backend/internal/repository/errors.go b/backend/internal/repository/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..fb7627a9abd87d6fd229c5c4f94925623238f0d0 --- /dev/null +++ b/backend/internal/repository/errors.go @@ -0,0 +1,43 @@ +package repository + +import ( + "errors" + "fmt" +) + +var ( + ErrNotFound = errors.New("repository: not found") + ErrConflict = errors.New("repository: conflict") + ErrLimitExceeded = errors.New("repository: limit exceeded") + ErrInvalidRecord = errors.New("repository: invalid record") + ErrAccountPoolMismatch = errors.New("repository: account pool mismatch") + ErrEgressFallbackInUse = errors.New("repository: egress fallback node in use") +) + +// InvalidBatchRecordError identifies a deterministic invalid record without +// classifying transient database failures as record-local errors. +type InvalidBatchRecordError struct { + Index int + Err error +} + +func (e *InvalidBatchRecordError) Error() string { + if e == nil { + return ErrInvalidRecord.Error() + } + if e.Err == nil { + return fmt.Sprintf("%s at batch index %d", ErrInvalidRecord, e.Index) + } + return fmt.Sprintf("%s at batch index %d: %v", ErrInvalidRecord, e.Index, e.Err) +} + +func (e *InvalidBatchRecordError) Unwrap() error { + if e == nil || e.Err == nil { + return ErrInvalidRecord + } + return e.Err +} + +func (e *InvalidBatchRecordError) Is(target error) bool { + return target == ErrInvalidRecord +} diff --git a/backend/internal/repository/list.go b/backend/internal/repository/list.go new file mode 100644 index 0000000000000000000000000000000000000000..e7ac0f3f3b8812b026725ca1dda7d2c3ef7d536b --- /dev/null +++ b/backend/internal/repository/list.go @@ -0,0 +1,158 @@ +package repository + +import "time" + +const ( + // DefaultPageSize is the default size for admin list endpoints. + DefaultPageSize = 20 + // MaxPageSize bounds an individual admin query. It is intentionally finite + // so large page requests cannot turn into unbounded database or JSON work. + MaxPageSize = 2000 + // DefaultCursorPageSize is the default batch size for cursor-based audit reads. + DefaultCursorPageSize = 50 +) + +// NormalizePage applies the common bounded pagination contract used by all +// admin list endpoints. Keeping this in one place prevents transport and +// application layers from returning different effective page sizes. +func NormalizePage(page, pageSize, defaultPageSize int) (int, int) { + if page < 1 { + page = 1 + } + if defaultPageSize < 1 || defaultPageSize > MaxPageSize { + defaultPageSize = DefaultPageSize + } + if pageSize < 1 { + pageSize = defaultPageSize + } + if pageSize > MaxPageSize { + pageSize = MaxPageSize + } + return page, pageSize +} + +type SortDirection string + +const ( + SortAscending SortDirection = "asc" + SortDescending SortDirection = "desc" +) + +// SortQuery 只携带应用层验证后的领域字段名,持久化层负责映射为固定 SQL 表达式。 +type SortQuery struct { + Field string + Direction SortDirection +} + +func IsValidSort(sort SortQuery, fields ...string) bool { + if sort.Field == "" { + return sort.Direction == "" + } + if sort.Direction != SortAscending && sort.Direction != SortDescending { + return false + } + for _, field := range fields { + if sort.Field == field { + return true + } + } + return false +} + +// PageQuery 表示管理端页码列表的稳定查询边界。 +type PageQuery struct { + Offset int + Limit int + Search string + Sort SortQuery +} + +type AccountListFilter struct { + Provider string + QuotaType string + Status string + Egress string + Refreshable *bool + // Agreement filters grok_web accounts by NSFW and current terms acceptance. + Agreement string + // Association filters accounts by link-table bindings: grok_web supports + // build/console/all values; grok_build and grok_console support webLinked/webUnlinked. + Association string + AccountIDs []uint64 + RestrictIDs bool + ExcludeIDs []uint64 + AfterID uint64 + ThroughID uint64 + Now time.Time +} + +type AccountListQuery struct { + Page PageQuery + Filter AccountListFilter +} + +type AccountSummary struct { + Provider string + Total int64 + Available int64 + Cooldown int64 + WaitingReset int64 + Probing int64 + Disabled int64 + ReauthRequired int64 +} + +type ModelListFilter struct { + Provider string + Providers []string + Tiers []string + Enabled *bool + ActiveScope bool +} + +type ModelListQuery struct { + Page PageQuery + Filter ModelListFilter +} + +type ClientKeyListFilter struct { + Status string + ModelScope string + Now time.Time +} + +type ClientKeyListQuery struct { + Page PageQuery + Filter ClientKeyListFilter +} + +type AuditListFilter struct { + Model string + Status string + Mode string + Key string + Account string +} + +type AuditCursorQuery struct { + Cursor *SortCursor + Limit int + Search string + Start time.Time + End time.Time + Sort SortQuery + Filter AuditListFilter +} + +// SortCursor 是复合游标的持久化边界。Value 类型由 SortQuery.Field 对应的固定映射决定。 +type SortCursor struct { + ID uint64 + Value any +} + +type AuditSummaryQuery struct { + Search string + Start time.Time + End time.Time + Filter AuditListFilter +} diff --git a/backend/internal/repository/list_test.go b/backend/internal/repository/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1cfc4655760b2ab8a32dc4b60c5dab58c85d44a8 --- /dev/null +++ b/backend/internal/repository/list_test.go @@ -0,0 +1,30 @@ +package repository + +import "testing" + +func TestNormalizePageBoundsAdminPageSize(t *testing.T) { + tests := []struct { + name string + pageSize int + wantPage, wantSize int + }{ + {name: "invalid page and size", pageSize: 0, wantPage: 1, wantSize: DefaultPageSize}, + {name: "supported large page", pageSize: 2000, wantPage: 1, wantSize: 2000}, + {name: "caps oversized page", pageSize: 5000, wantPage: 1, wantSize: MaxPageSize}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + page, size := NormalizePage(0, test.pageSize, DefaultPageSize) + if page != test.wantPage || size != test.wantSize { + t.Fatalf("NormalizePage() = (%d, %d), want (%d, %d)", page, size, test.wantPage, test.wantSize) + } + }) + } +} + +func TestNormalizePageSanitizesInvalidDefault(t *testing.T) { + _, size := NormalizePage(1, 0, MaxPageSize+1) + if size != DefaultPageSize { + t.Fatalf("NormalizePage() default size = %d, want %d", size, DefaultPageSize) + } +} diff --git a/backend/internal/repository/media.go b/backend/internal/repository/media.go new file mode 100644 index 0000000000000000000000000000000000000000..2a2dbf6c2d51c95b517f68fffab18caa806ba55e --- /dev/null +++ b/backend/internal/repository/media.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "io" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/media" +) + +// MediaAssetListQuery 表示管理端媒体资源列表的查询条件。 +type MediaAssetListQuery struct { + Page PageQuery +} + +// MediaJobListFilter 表示视频任务列表允许使用的业务筛选条件。 +type MediaJobListFilter struct { + Status string +} + +// MediaJobListQuery 表示管理端视频任务列表的查询条件。 +type MediaJobListQuery struct { + Page PageQuery + Filter MediaJobListFilter +} + +// MediaAssetStats 表示媒体资源的聚合统计结果。 +type MediaAssetStats struct { + TotalImages int64 + TotalBytes int64 +} + +// MediaUploadTicket 表示上游视频 PUT 的一次性票据元数据(不含明文 token)。 +type MediaUploadTicket struct { + TokenHash string + AssetID string + JobID string + MaxBytes int64 + AllowedMIME string + ExpiresAt time.Time + ConsumedAt *time.Time + CreatedAt time.Time +} + +// MediaJobStats 表示各状态视频任务的聚合统计结果。 +type MediaJobStats struct { + TotalJobs int64 + Completed int64 + Failed int64 + InProgress int64 + Queued int64 +} + +type MediaJobRepository interface { + CreateMediaJob(ctx context.Context, value media.Job) error + GetMediaJob(ctx context.Context, id string, clientKeyID uint64) (media.Job, error) + GetMediaJobsByIDs(ctx context.Context, ids []string) ([]media.Job, error) + UpdateMediaJob(ctx context.Context, value media.Job) error + DeleteMediaJob(ctx context.Context, id string) error + ListMediaJobs(ctx context.Context, query MediaJobListQuery) ([]media.Job, int64, error) + SummarizeMediaJobs(ctx context.Context) (MediaJobStats, error) + ListRecoverableMediaJobs(ctx context.Context, limit int) ([]media.Job, error) + ListUnrecordedTerminalMediaJobs(ctx context.Context, limit int) ([]media.Job, error) + TryClaimMediaJob(ctx context.Context, id string, now, leaseUntil time.Time, claimToken string) (media.Job, bool, error) + MarkMediaJobUsageRecorded(ctx context.Context, id string, recordedAt time.Time) error +} + +// MediaAssetRepository 定义媒体资源元数据持久化能力。 +type MediaAssetRepository interface { + CreateMediaAsset(ctx context.Context, value media.Asset) error + GetMediaAsset(ctx context.Context, id string) (media.Asset, error) + ListMediaAssets(ctx context.Context, query MediaAssetListQuery) ([]media.Asset, int64, error) + SummarizeMediaAssets(ctx context.Context) (MediaAssetStats, error) + TotalMediaAssetBytes(ctx context.Context) (int64, error) + // ListOldestMediaAssets 按 created_at ASC, id ASC 分页;offset 用于跳过已扫描的受保护资产。 + ListOldestMediaAssets(ctx context.Context, offset, limit int) ([]media.Asset, error) + DeleteMediaAsset(ctx context.Context, id string) error + // ListActiveVideoAssetIDs 返回进行中任务或未消费票据绑定的资产 ID,清理时必须跳过。 + ListProtectedMediaAssetIDs(ctx context.Context) (map[string]struct{}, error) +} + +// MediaUploadTicketRepository 定义视频上传票据的持久化能力。 +type MediaUploadTicketRepository interface { + CreateUploadTicket(ctx context.Context, ticket MediaUploadTicket) error + GetUploadTicketByHash(ctx context.Context, tokenHash string) (MediaUploadTicket, error) + // ConsumeUploadTicket 原子消费票据;已消费或过期返回 false。 + ConsumeUploadTicket(ctx context.Context, tokenHash string, now time.Time) (MediaUploadTicket, bool, error) + // ReleaseUploadTicket 在尚未登记资产时撤销消费,允许同票据重试。 + // 仅当票据当前为已消费状态时清除 consumed_at;返回是否成功释放。 + ReleaseUploadTicket(ctx context.Context, tokenHash string) (bool, error) + // DeleteUploadTicketByHash 按 token_hash 精确删除票据;行不存在时幂等成功。 + // 用于签发过程中 bind 失败后的补偿回滚,不得按 job/asset 批量删除。 + DeleteUploadTicketByHash(ctx context.Context, tokenHash string) error + // DeleteUploadTicketsByJobID 撤销指定任务尚存的上传入口;行不存在时幂等成功。 + DeleteUploadTicketsByJobID(ctx context.Context, jobID string) error + DeleteExpiredUploadTickets(ctx context.Context, before time.Time, limit int) (int64, error) + BindJobResultAsset(ctx context.Context, jobID, assetID string) error +} + +// MediaObjectStorage 定义媒体二进制对象的存取边界。 +type MediaObjectStorage interface { + SaveImage(ctx context.Context, id, mimeType string, data []byte) (string, error) + SaveVideo(ctx context.Context, id, mimeType string, data []byte) (string, error) + BeginVideoUpload(ctx context.Context, id, mimeType string) (tempPath, storageKey string, err error) + CommitVideoUpload(ctx context.Context, tempPath, storageKey string) error + AbortVideoUpload(ctx context.Context, tempPath string) error + Open(ctx context.Context, storageKey string) (io.ReadCloser, error) + Delete(ctx context.Context, storageKey string) error +} diff --git a/backend/internal/repository/model.go b/backend/internal/repository/model.go new file mode 100644 index 0000000000000000000000000000000000000000..63bef5da83d68e5909f922408e2791839bfee850 --- /dev/null +++ b/backend/internal/repository/model.go @@ -0,0 +1,33 @@ +package repository + +import ( + "context" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +// ModelRepository 定义公开模型路由持久化能力。 +type ModelRepository interface { + List(ctx context.Context, query ModelListQuery) ([]model.Route, int64, error) + ListEnabled(ctx context.Context) ([]model.Route, error) + ListEnabledForScope(ctx context.Context, filter ModelListFilter) ([]model.Route, error) + ListConfiguredEnabled(ctx context.Context) ([]model.Route, error) + Get(ctx context.Context, id uint64) (model.Route, error) + GetByPublicID(ctx context.Context, publicID string) (model.Route, error) + GetByPublicIDCandidates(ctx context.Context, publicID string) ([]model.Route, error) + GetByProviderUpstream(ctx context.Context, provider account.Provider, upstreamModel string) (model.Route, error) + UpsertDiscovered(ctx context.Context, provider account.Provider, upstreamModels []string) error + UpsertRoutes(ctx context.Context, values []model.Route) error + ReplaceProviderRoutes(ctx context.Context, provider account.Provider, values []model.Route) error + ReplaceAccountCapabilities(ctx context.Context, accountID uint64, upstreamModels []string, syncedAt time.Time) error + MarkAccountCapabilitySyncFailed(ctx context.Context, accountID uint64, attemptedAt time.Time, message string) error + HasSuccessfulAccountSync(ctx context.Context, accountID uint64) (bool, error) + ListStaleAccountSyncIDs(ctx context.Context, before time.Time, limit int) ([]uint64, error) + Create(ctx context.Context, value model.Route, accountIDs []uint64) (model.Route, error) + Update(ctx context.Context, value model.Route, accountIDs *[]uint64) (model.Route, error) + Delete(ctx context.Context, id uint64) error + DeleteMany(ctx context.Context, ids []uint64) (int64, error) + UpdateManyEnabled(ctx context.Context, ids []uint64, enabled bool) (int64, error) +} diff --git a/backend/internal/repository/response.go b/backend/internal/repository/response.go new file mode 100644 index 0000000000000000000000000000000000000000..87edd8f54d96f96e249fc28360d58c92e7b7f55c --- /dev/null +++ b/backend/internal/repository/response.go @@ -0,0 +1,25 @@ +package repository + +import ( + "context" + "time" + + inferencedomain "github.com/chenyme/grok2api/backend/internal/domain/inference" +) + +type ResponseCleanupResult struct { + OwnershipDeleted int64 + WebStateDeleted int64 + HasMore bool +} + +// ResponseRepository 定义 Responses 资源与上游账号归属的持久化边界。 +type ResponseRepository interface { + Save(ctx context.Context, value inferencedomain.ResponseOwnership) error + Get(ctx context.Context, responseID string, clientKeyID uint64, now time.Time) (inferencedomain.ResponseOwnership, error) + Delete(ctx context.Context, responseID string, clientKeyID uint64) error + DeleteExpired(ctx context.Context, now time.Time, ownershipLimit, webStateLimit int) (ResponseCleanupResult, error) + SaveWebState(ctx context.Context, value inferencedomain.WebResponseState) error + GetWebState(ctx context.Context, responseID string, now time.Time) (inferencedomain.WebResponseState, error) + DeleteWebState(ctx context.Context, responseID string) error +} diff --git a/backend/internal/repository/runtime.go b/backend/internal/repository/runtime.go new file mode 100644 index 0000000000000000000000000000000000000000..56ab7c8f6f03c9c5f4814209e8e315c676519506 --- /dev/null +++ b/backend/internal/repository/runtime.go @@ -0,0 +1,186 @@ +package repository + +import ( + "context" + "strconv" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +// AccountConcurrencyKey 返回账号推理租约使用的统一运行态键。 +// 账号维护任务必须复用该键判断是否仍有请求占用,避免复制协议字符串后发生漂移。 +func AccountConcurrencyKey(accountID uint64) string { + return "account:" + strconv.FormatUint(accountID, 10) +} + +// RateLimiter 定义客户端 RPM 限制边界。 +type RateLimiter interface { + Allow(ctx context.Context, key string, limit int, now time.Time) (bool, error) +} + +// ConcurrencyLimiter 定义客户端和账号并发租约边界。 +type ConcurrencyLimiter interface { + Acquire(ctx context.Context, key string, limit int) (release func(), acquired bool, err error) + Current(ctx context.Context, key string) (int, error) +} + +// ConcurrencySnapshotReader 批量读取并发租约快照;调度器会优先使用它减少远程运行态往返。 +type ConcurrencySnapshotReader interface { + CurrentMany(ctx context.Context, keys []string) (map[string]int, error) +} + +// StickySessionRepository 定义有过期时间的会话账号粘滞状态。 +type StickySessionRepository interface { + Get(ctx context.Context, affinityKey string, now time.Time) (uint64, bool, error) + // Bind 原子保留已有有效绑定并刷新有效期;仅在绑定不存在或已过期时采用 proposedAccountID。 + Bind(ctx context.Context, affinityKey string, proposedAccountID uint64, now, expiresAt time.Time) (accountID uint64, err error) + // Set 强制替换绑定,仅用于原账号已经确定不再适合当前请求时重新绑定。 + Set(ctx context.Context, affinityKey string, accountID uint64, expiresAt time.Time) error + DeleteByAccount(ctx context.Context, accountID uint64) error +} + +// StickySessionBatchDeleter removes bindings for many accounts using bounded remote +// batches or a single in-memory scan. +type StickySessionBatchDeleter interface { + DeleteByAccounts(ctx context.Context, accountIDs []uint64) error +} + +// ReasoningReplayRepository 保存无状态多轮所需的上一轮可回放 output items。 +// key 边界为 model + sessionKey;sessionKey 应使用已隔离的 PromptCacheKey。 +type ReasoningReplayRepository interface { + Get(ctx context.Context, model, sessionKey string, now time.Time, ttl time.Duration) (items [][]byte, ok bool, err error) + Set(ctx context.Context, model, sessionKey string, items [][]byte, expiresAt time.Time) error + Delete(ctx context.Context, model, sessionKey string) error +} + +// ObservedModelState is the latest model signal shared by runtime instances. +type ObservedModelState struct { + Model string + ObservedAt time.Time +} + +// ObservedModelStateRepository coordinates duplicate model observations across instances. +// Implementations must treat this state as an optimization; the account database remains authoritative. +type ObservedModelStateRepository interface { + GetObservedModelState(ctx context.Context, accountID uint64) (ObservedModelState, bool, error) + SetObservedModelState(ctx context.Context, accountID uint64, value ObservedModelState, ttl time.Duration) error +} + +// DeviceSessionRepository 定义短期 Device OAuth 会话状态。 +type DeviceSessionRepository interface { + Create(ctx context.Context, value account.DeviceSession) error + Get(ctx context.Context, id string, now time.Time) (account.DeviceSession, error) + Update(ctx context.Context, value account.DeviceSession) error + Delete(ctx context.Context, id string) error +} + +// DistributedLock 定义跨实例的短期互斥租约,用于避免同一账号维护任务被并发执行。 +type DistributedLock interface { + Acquire(ctx context.Context, key string, ttl time.Duration) (release func(), acquired bool, err error) +} + +// SettingsChangeBus 在多实例之间传递运行设置已变更的通知,设置内容仍以数据库为准。 +type SettingsChangeBus interface { + PublishSettingsChanged(ctx context.Context) error + ListenSettingsChanges(ctx context.Context, handler func(context.Context) error) error +} + +type InvalidationKind string + +const ( + InvalidationRouteChanged InvalidationKind = "route_changed" + InvalidationModelBindingChanged InvalidationKind = "model_binding_changed" + InvalidationAccountStateChanged InvalidationKind = "account_state_changed" + InvalidationAccountCredentialChanged InvalidationKind = "account_credential_changed" + InvalidationAccountCapabilityChanged InvalidationKind = "account_capability_changed" + InvalidationAccountBillingChanged InvalidationKind = "account_billing_changed" + InvalidationAccountQuotaChanged InvalidationKind = "account_quota_changed" + InvalidationAccountRecoveryChanged InvalidationKind = "account_recovery_changed" + InvalidationAccountModelQuotaChanged InvalidationKind = "account_model_quota_changed" + InvalidationClientKeyChanged InvalidationKind = "client_key_changed" +) + +type InvalidationLayer string + +const ( + InvalidationLayerRoute InvalidationLayer = "route" + InvalidationLayerBase InvalidationLayer = "account_base" + InvalidationLayerOverlay InvalidationLayer = "account_overlay" + InvalidationLayerClientKey InvalidationLayer = "client_key" +) + +type InvalidationEvent struct { + Kind InvalidationKind `json:"kind"` + Provider account.Provider `json:"provider,omitempty"` + AccountID uint64 `json:"accountId,omitempty"` + ClientKeyID uint64 `json:"clientKeyId,omitempty"` + UpstreamModel string `json:"upstreamModel,omitempty"` + Revision uint64 `json:"revision,omitempty"` + SourceInstance string `json:"sourceInstance,omitempty"` + PublishedAt time.Time `json:"publishedAt,omitempty"` +} + +func (e InvalidationEvent) Layer() InvalidationLayer { + switch e.Kind { + case InvalidationRouteChanged: + return InvalidationLayerRoute + case InvalidationModelBindingChanged, InvalidationAccountCapabilityChanged, InvalidationAccountModelQuotaChanged: + return InvalidationLayerOverlay + case InvalidationAccountStateChanged, InvalidationAccountCredentialChanged, InvalidationAccountBillingChanged, InvalidationAccountQuotaChanged, InvalidationAccountRecoveryChanged: + return InvalidationLayerBase + case InvalidationClientKeyChanged: + return InvalidationLayerClientKey + default: + return "" + } +} + +func (e InvalidationEvent) Valid() bool { + layer := e.Layer() + if layer == "" { + return false + } + if layer == InvalidationLayerClientKey { + return e.Provider == "" && e.AccountID == 0 && e.UpstreamModel == "" + } + switch e.Provider { + case "", account.ProviderBuild, account.ProviderWeb, account.ProviderConsole: + return true + default: + return false + } +} + +type InvalidationObserver func(context.Context, InvalidationEvent) + +// InvalidationBus distributes cache invalidation metadata only. Authoritative +// account, route, credential, quota, and billing data remain in the database. +type InvalidationBus interface { + PublishInvalidation(ctx context.Context, event InvalidationEvent) error + ListenInvalidations(ctx context.Context, handler func(context.Context, InvalidationEvent) error) error +} + +// QuotaRecoveryQueue 保存分模式额度的到期探测事件,支持多实例原子认领。 +type QuotaRecoveryQueue interface { + ScheduleQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error + EnsureQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error + ClaimDueQuotaRecoveries(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]account.QuotaRecoveryEvent, error) + AckQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error + RescheduleQuotaRecovery(ctx context.Context, value account.QuotaRecoveryEvent) error +} + +type QuotaRefreshDirty struct { + AccountID uint64 + Mode string + Generation uint64 +} + +// QuotaRefreshCoordinator preserves successful-request refresh signals across +// local queue pressure and coordinates trailing refreshes between instances. +type QuotaRefreshCoordinator interface { + MarkQuotaRefreshDirty(ctx context.Context, accountID uint64, mode string, ttl time.Duration) (uint64, error) + QuotaRefreshGeneration(ctx context.Context, accountID uint64, mode string) (generation uint64, dirty bool, err error) + ClearQuotaRefreshDirty(ctx context.Context, accountID uint64, mode string, generation uint64) (bool, error) + ListQuotaRefreshDirty(ctx context.Context, now time.Time, limit int) ([]QuotaRefreshDirty, error) +} diff --git a/backend/internal/repository/settings.go b/backend/internal/repository/settings.go new file mode 100644 index 0000000000000000000000000000000000000000..0eb8e66797e6f91679509e5d5d45be8d5b624cf4 --- /dev/null +++ b/backend/internal/repository/settings.go @@ -0,0 +1,14 @@ +package repository + +import ( + "context" + "time" + + settingsdomain "github.com/chenyme/grok2api/backend/internal/domain/settings" +) + +// RuntimeSettingsRepository 定义运行设置的单实例持久化边界。 +type RuntimeSettingsRepository interface { + Get(ctx context.Context) (settingsdomain.Config, time.Time, uint64, bool, error) + Save(ctx context.Context, value settingsdomain.Config, expectedRevision uint64) (time.Time, uint64, error) +} diff --git a/backend/internal/shared/response/response.go b/backend/internal/shared/response/response.go new file mode 100644 index 0000000000000000000000000000000000000000..a37a8c197a5ce904bc20210d61a7d6c6aa7e5516 --- /dev/null +++ b/backend/internal/shared/response/response.go @@ -0,0 +1,33 @@ +package response + +import "github.com/gin-gonic/gin" + +type successEnvelope struct { + Data any `json:"data"` +} + +type errorEnvelope struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"requestId,omitempty"` +} + +// Success 返回 Admin API 统一成功包裹。 +func Success(c *gin.Context, status int, data any) { + c.JSON(status, successEnvelope{Data: data}) +} + +// Error 返回 Admin API 稳定错误码和请求 ID。 +func Error(c *gin.Context, status int, code, message string) { + requestID, _ := c.Get("requestId") + c.AbortWithStatusJSON(status, errorEnvelope{Error: errorBody{Code: code, Message: message, RequestID: stringValue(requestID)}}) +} + +func stringValue(value any) string { + result, _ := value.(string) + return result +} diff --git a/backend/internal/transport/http/account/handler.go b/backend/internal/transport/http/account/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..6fb2e530b8c99502a415755508a672f7a125b9e0 --- /dev/null +++ b/backend/internal/transport/http/account/handler.go @@ -0,0 +1,1519 @@ +package account + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + accountsyncapp "github.com/chenyme/grok2api/backend/internal/application/accountsync" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type accountSynchronizer interface { + Sync(ctx context.Context, accountIDs ...uint64) accountsyncapp.Result + SyncStream(ctx context.Context, accountIDs <-chan uint64) accountsyncapp.Result +} + +type accountSyncProgressor interface { + SyncStreamObserved(ctx context.Context, accountIDs <-chan uint64, observer func(completed, total int)) accountsyncapp.Result +} + +type accountModelSynchronizer interface { + SyncModels(ctx context.Context, accountID uint64) error +} + +const ( + maxAccountImportBytes = 30 << 20 + maxAccountImportFiles = 1000 + accountSyncQueueCapacity = 20 + accountEventHeartbeatInterval = 15 * time.Second + accountEventWriteTimeout = 30 * time.Second +) + +type Handler struct { + service *accountapp.Service + sync accountSynchronizer +} + +type accountSyncPipeline struct { + ctx context.Context + cancel context.CancelFunc + ids chan uint64 + done chan accountsyncapp.Result + progress func(completed, total int) + progressMu sync.Mutex + queued atomic.Int64 + completed atomic.Int64 +} + +func NewHandler(service *accountapp.Service, sync accountSynchronizer) *Handler { + return &Handler{service: service, sync: sync} +} + +func (h *Handler) startSyncPipeline(parent context.Context, progress func(completed, total int)) *accountSyncPipeline { + ctx, cancel := context.WithCancel(parent) + pipeline := &accountSyncPipeline{ctx: ctx, cancel: cancel, progress: progress} + if h.sync == nil { + return pipeline + } + pipeline.ids = make(chan uint64, accountSyncQueueCapacity) + pipeline.done = make(chan accountsyncapp.Result, 1) + go func() { + if observed, ok := h.sync.(accountSyncProgressor); ok && progress != nil { + pipeline.done <- observed.SyncStreamObserved(ctx, pipeline.ids, func(completed, _ int) { + pipeline.completed.Store(int64(completed)) + pipeline.reportProgress() + }) + return + } + pipeline.done <- h.sync.SyncStream(ctx, pipeline.ids) + }() + return pipeline +} + +func (p *accountSyncPipeline) Observe(accountID uint64) error { + if p.ids == nil { + return nil + } + p.queued.Add(1) + select { + case p.ids <- accountID: + return nil + case <-p.ctx.Done(): + p.queued.Add(-1) + return p.ctx.Err() + } +} + +func (p *accountSyncPipeline) Finish(abort bool) accountsyncapp.Result { + if abort { + p.cancel() + } + if p.ids != nil { + close(p.ids) + } + if !abort { + // 转换阶段结束后不再增加同步任务,先报告一次固定分母,避免前端看到总数跳变。 + p.reportProgress() + } + result := accountsyncapp.Result{} + if p.done != nil { + result = <-p.done + } + if !abort && p.ids != nil { + p.completed.Store(int64(result.Succeeded + result.Failed)) + p.reportProgress() + } + p.cancel() + return result +} + +// reportProgress 使用已进入同步流水线的任务数报告进度;转换结束后该分母保持固定。 +func (p *accountSyncPipeline) reportProgress() { + if p.ids == nil || p.progress == nil { + return + } + p.progressMu.Lock() + defer p.progressMu.Unlock() + p.progress(int(p.completed.Load()), int(p.queued.Load())) +} + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/accounts", h.list) + router.GET("/accounts/summary", h.summary) + router.GET("/accounts/export", h.exportCredentials) + router.POST("/accounts/export", h.exportSelectedCredentials) + router.GET("/accounts/:id", h.get) + router.POST("/accounts/device/start", h.startDevice) + router.POST("/accounts/device/:sessionId/poll", h.pollDevice) + router.POST("/accounts/import", h.importAuth) + router.POST("/accounts/web/import", h.importWebAuth) + router.POST("/accounts/console/import", h.importConsoleAuth) + router.POST("/accounts/web/convert-to-build", h.convertWebToBuild) + router.POST("/accounts/web/sync-to-console", h.syncWebToConsole) + router.POST("/accounts/web/run-scripts", h.runWebAccountScripts) + router.POST("/accounts/web/refresh-quotas", h.refreshAllWebQuotas) + router.POST("/accounts/web/:id/accept-terms", h.acceptWebTerms) + router.POST("/accounts/web/:id/birth-date", h.setWebBirthDate) + router.POST("/accounts/web/:id/nsfw", h.enableWebNSFW) + router.POST("/accounts/console/refresh-quotas", h.refreshAllConsoleQuotas) + router.POST("/accounts/refresh-billing", h.refreshAllBilling) + router.POST("/accounts/reset-quota", h.resetAllBuildQuota) + router.POST("/accounts/refresh-tokens", h.refreshAllTokens) + router.POST("/accounts/cleanup", h.cleanup) + router.POST("/accounts/cleanup-preview", h.cleanupPreview) + router.POST("/accounts/batch/refresh-billing", h.batchRefreshBilling) + router.POST("/accounts/batch/reset-quota", h.batchResetQuota) + router.POST("/accounts/batch/refresh-quotas", h.batchRefreshQuotas) + router.POST("/accounts/batch/refresh-tokens", h.batchRefreshTokens) + router.PATCH("/accounts/batch", h.batchUpdate) + router.POST("/accounts/deletion-preview", h.previewDeletion) + router.DELETE("/accounts", h.batchDelete) + router.PATCH("/accounts/:id", h.update) + router.DELETE("/accounts/:id", h.delete) + router.POST("/accounts/:id/refresh-token", h.refreshToken) + router.POST("/accounts/:id/refresh-billing", h.refreshBilling) + router.POST("/accounts/:id/refresh-quota", h.refreshWebQuota) +} + +type updateRequest struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + Priority *int `json:"priority"` + MaxConcurrent *int `json:"maxConcurrent"` + MinimumRemaining *float64 `json:"minimumRemaining"` + CloudflareCookies *string `json:"cloudflareCookies"` + ClearCloudflareCookies bool `json:"clearCloudflareCookies"` + BuildSuperEntitled *bool `json:"buildSuperEntitled"` + BuildRouteMode *accountdomain.BuildRouteMode `json:"buildRouteMode"` +} + +type batchUpdateRequest struct { + IDs []string `json:"ids" binding:"required"` + Provider string `json:"provider" binding:"required"` + Enabled *bool `json:"enabled"` + Priority *int `json:"priority"` + MaxConcurrent *int `json:"maxConcurrent"` + MinimumRemaining *float64 `json:"minimumRemaining"` +} + +type batchDeleteRequest struct { + IDs []string `json:"ids" binding:"required"` + Provider string `json:"provider" binding:"required"` + LinkedDeleteTargets []string `json:"linkedDeleteTargets"` +} + +type credentialExportRequest struct { + IDs []string `json:"ids" binding:"required"` + Provider string `json:"provider" binding:"required"` +} + +type deletionPreviewRequest struct { + IDs []string `json:"ids" binding:"required"` + Provider string `json:"provider" binding:"required"` + LinkedDeleteTargets []string `json:"linkedDeleteTargets"` +} + +type accountCleanupRequest struct { + Provider string `json:"provider" binding:"required"` + Statuses []accountapp.CleanupStatus `json:"statuses" binding:"required"` + LinkedDeleteTargets []string `json:"linkedDeleteTargets"` +} + +type buildConversionRequest struct { + IDs []string `json:"ids"` + All bool `json:"all"` + Strategy accountapp.BuildConversionStrategy `json:"strategy"` +} + +type webConsoleSyncRequest struct { + IDs []string `json:"ids"` + All bool `json:"all"` + Strategy accountapp.WebConsoleSyncStrategy `json:"strategy"` +} + +type buildConversionResponse struct { + Created int `json:"created"` + Linked int `json:"linked"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Synced int `json:"synced"` + SyncFailed int `json:"syncFailed"` +} + +type accountTaskProgressResponse struct { + Completed int `json:"completed"` + Total int `json:"total"` + Phase string `json:"phase,omitempty"` +} + +type accountBatchResponse struct { + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` +} + +type accountTokenRefreshResponse struct { + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` +} + +type accountImportResponse struct { + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Synced int `json:"synced"` + SyncFailed int `json:"syncFailed"` +} + +type accountResponse struct { + ID uint64 `json:"id,string"` + Provider string `json:"provider"` + AuthType string `json:"authType"` + WebTier string `json:"webTier,omitempty"` + WebTierSyncedAt *time.Time `json:"webTierSyncedAt,omitempty"` + WebNSFWEnabledAt *time.Time `json:"nsfwEnabledAt,omitempty"` + WebTermsAcceptedAt *time.Time `json:"termsAcceptedAt,omitempty"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` + TeamID string `json:"teamId,omitempty"` + Enabled bool `json:"enabled"` + AuthStatus string `json:"authStatus"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Refreshable bool `json:"refreshable"` + RefreshDueAt *time.Time `json:"refreshDueAt,omitempty"` + LastRefreshAt *time.Time `json:"lastRefreshAt,omitempty"` + RefreshFailures int `json:"refreshFailureCount"` + LastRefreshErrorStatus int `json:"lastRefreshErrorStatus,omitempty"` + LastRefreshError string `json:"lastRefreshErrorCode,omitempty"` + LastRefreshErrorMessage string `json:"lastRefreshErrorMessage,omitempty"` + LastRefreshErrorResponse string `json:"lastRefreshErrorResponse,omitempty"` + Priority int `json:"priority"` + MaxConcurrent int `json:"maxConcurrent"` + MinimumRemaining float64 `json:"minimumRemaining"` + FailureCount int `json:"failureCount"` + CooldownUntil *time.Time `json:"cooldownUntil,omitempty"` + LastError string `json:"lastError,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + LinkedAccountID uint64 `json:"linkedAccountId,omitempty,string"` + LinkedName string `json:"linkedAccountName,omitempty"` + LinkedProvider string `json:"linkedProvider,omitempty"` + LinkedAccounts []linkedAccountResponse `json:"linkedAccounts,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ObservedModel string `json:"observedModel,omitempty"` + ObservedModelAt *time.Time `json:"observedModelAt,omitempty"` + CloudflareCookieConfigured bool `json:"cloudflareCookieConfigured"` + BuildSuperEntitled bool `json:"buildSuperEntitled"` + BuildRouteMode string `json:"buildRouteMode"` + BuildBotFlagged bool `json:"buildBotFlagged"` + EgressNodeID uint64 `json:"egressNodeId,omitempty,string"` + EgressAssignmentMode string `json:"egressAssignmentMode,omitempty"` + ModelSyncFailed bool `json:"modelSyncFailed,omitempty"` + Billing *billingResponse `json:"billing,omitempty"` + Quota quotaResponse `json:"quota"` + QuotaWindows []quotaWindowResponse `json:"quotaWindows,omitempty"` +} + +type linkedAccountResponse struct { + ID uint64 `json:"id,string"` + Provider string `json:"provider"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type quotaWindowResponse struct { + Mode string `json:"mode"` + Remaining int `json:"remaining"` + Total int `json:"total"` + UsagePercent float64 `json:"usagePercent"` + Breakdown []quotaBreakdownResponse `json:"breakdown,omitempty"` + WindowSeconds int `json:"windowSeconds"` + ResetAt *time.Time `json:"resetAt,omitempty"` + SyncedAt *time.Time `json:"syncedAt,omitempty"` + Source string `json:"source"` +} + +type quotaBreakdownResponse struct { + ProductCode int `json:"productCode"` + UsagePercent float64 `json:"usagePercent"` +} + +type billingResponse struct { + PlanCode string `json:"planCode,omitempty"` + PlanName string `json:"planName,omitempty"` + MonthlyLimit float64 `json:"monthlyLimit"` + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + OnDemandCap float64 `json:"onDemandCap"` + OnDemandUsed float64 `json:"onDemandUsed"` + PrepaidBalance float64 `json:"prepaidBalance"` + CreditUsagePercent float64 `json:"creditUsagePercent"` + IsUnifiedBillingUser bool `json:"isUnifiedBillingUser"` + OnDemandEnabled *bool `json:"onDemandEnabled,omitempty"` + TopUpMethod string `json:"topUpMethod,omitempty"` + UsagePeriodType string `json:"usagePeriodType,omitempty"` + UsagePeriodStart string `json:"usagePeriodStart,omitempty"` + UsagePeriodEnd string `json:"usagePeriodEnd,omitempty"` + BillingPeriodStart string `json:"billingPeriodStart,omitempty"` + BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"` + History []billingHistoryResponse `json:"history,omitempty"` + SyncedAt time.Time `json:"syncedAt"` +} + +type billingHistoryResponse struct { + Year int `json:"year"` + Month int `json:"month"` + PeriodType string `json:"periodType,omitempty"` + PeriodStart string `json:"periodStart,omitempty"` + PeriodEnd string `json:"periodEnd,omitempty"` + IncludedUsed float64 `json:"includedUsed"` + OnDemandUsed float64 `json:"onDemandUsed"` + TotalUsed float64 `json:"totalUsed"` +} + +type quotaResponse struct { + Type string `json:"type"` + Source string `json:"source"` + Confidence string `json:"confidence"` + Unit string `json:"unit,omitempty"` + Used float64 `json:"used"` + Limit float64 `json:"limit"` + Remaining float64 `json:"remaining"` + UsagePercent float64 `json:"usagePercent"` + LimitKnown bool `json:"limitKnown"` + WindowHours int `json:"windowHours,omitempty"` + Observed bool `json:"observed"` + Confirmed bool `json:"confirmed"` + Status string `json:"status"` + PeriodStart string `json:"periodStart,omitempty"` + PeriodEnd string `json:"periodEnd,omitempty"` + ExhaustedAt *time.Time `json:"exhaustedAt,omitempty"` + NextProbeAt *time.Time `json:"nextProbeAt,omitempty"` + LastConfirmedAt *time.Time `json:"lastConfirmedAt,omitempty"` +} + +func (h *Handler) list(c *gin.Context) { + page, pageSize := pagination(c) + values, total, err := h.service.List(c.Request.Context(), page, pageSize, c.Query("search"), accountapp.ListFilter{ + Provider: c.Query("provider"), QuotaType: c.Query("type"), Status: c.Query("status"), Egress: c.Query("egress"), + Renewal: c.Query("renewal"), Risk: c.Query("risk"), Agreement: c.Query("agreement"), Association: c.Query("association"), + Sort: repository.SortQuery{Field: c.Query("sortBy"), Direction: repository.SortDirection(c.Query("sortOrder"))}, + }) + if errors.Is(err, accountapp.ErrInvalidFilter) { + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "accountListFailed", "读取账号失败") + return + } + items := make([]accountResponse, 0, len(values)) + for _, value := range values { + items = append(items, newAccountResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total}) +} + +func (h *Handler) summary(c *gin.Context) { + value, err := h.service.Summary(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusInternalServerError, "accountSummaryFailed", "读取账号统计失败") + return + } + build := value.Providers[string(accountdomain.ProviderBuild)] + web := value.Providers[string(accountdomain.ProviderWeb)] + console := value.Providers[string(accountdomain.ProviderConsole)] + response.Success(c, http.StatusOK, gin.H{ + "total": value.Total, "available": value.Available, "recovering": value.Recovering, "attention": value.Attention, "risk": value.Risk, + "providers": gin.H{ + string(accountdomain.ProviderBuild): gin.H{"total": build.Total, "available": build.Available}, + string(accountdomain.ProviderWeb): gin.H{"total": web.Total, "available": web.Available}, + string(accountdomain.ProviderConsole): gin.H{"total": console.Total, "available": console.Available}, + }, + "recovery": gin.H{"cooldown": value.Recovery.Cooldown, "waitingReset": value.Recovery.WaitingReset, "probing": value.Recovery.Probing}, + "issues": gin.H{"disabled": value.Issues.Disabled, "reauthRequired": value.Issues.ReauthRequired}, + }) +} + +func (h *Handler) batchUpdate(c *gin.Context) { + var request batchUpdateRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + updated, err := h.service.BatchUpdate(c.Request.Context(), accountdomain.Provider(request.Provider), ids, accountapp.UpdateInput{Enabled: request.Enabled, Priority: request.Priority, MaxConcurrent: request.MaxConcurrent, MinimumRemaining: request.MinimumRemaining}) + if err != nil { + h.writeServiceError(c, "accountBatchUpdateFailed", err, http.StatusInternalServerError, "批量更新账号失败") + return + } + response.Success(c, http.StatusOK, gin.H{"updated": updated}) +} + +func (h *Handler) batchDelete(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if !h.validateProviderIDs(c, ids, request.Provider) { + return + } + targets, err := parseLinkedDeleteTargets(request.LinkedDeleteTargets) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidLinkedDeleteTargets", err.Error()) + return + } + result, err := h.service.BatchDeleteWithLinked(c.Request.Context(), accountdomain.Provider(request.Provider), ids, targets) + if err != nil { + h.writeServiceError(c, "accountBatchDeleteFailed", err, http.StatusInternalServerError, "批量删除账号失败") + return + } + response.Success(c, http.StatusOK, newAccountDeleteResponse(result)) +} + +func (h *Handler) previewDeletion(c *gin.Context) { + var request deletionPreviewRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if !h.validateProviderIDs(c, ids, request.Provider) { + return + } + targets, err := parseLinkedDeleteTargets(request.LinkedDeleteTargets) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidLinkedDeleteTargets", err.Error()) + return + } + resolution, err := h.service.PreviewLinkedDelete(c.Request.Context(), accountdomain.Provider(request.Provider), ids, targets) + if err != nil { + h.writeServiceError(c, "accountDeletionPreviewFailed", err, http.StatusInternalServerError, "预览删除账号失败") + return + } + linked := gin.H{} + for provider, count := range resolution.LinkedByProvider { + linked[string(provider)] = count + } + response.Success(c, http.StatusOK, gin.H{ + "rootCount": len(resolution.RootIDs), + "linkedByProvider": linked, + "total": len(resolution.FinalIDs), + }) +} + +func (h *Handler) batchRefreshBilling(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if request.Provider != string(accountdomain.ProviderBuild) { + response.Error(c, http.StatusBadRequest, "invalidProvider", "仅 Grok Build 账号支持 Billing 同步") + return + } + if !h.validateProviderIDs(c, ids, request.Provider) { + return + } + succeeded, failed, err := h.service.BatchRefreshBilling(c.Request.Context(), ids) + if err != nil { + h.writeServiceError(c, "billingBatchRefreshFailed", err, http.StatusBadGateway, "批量同步 Billing 失败") + return + } + response.Success(c, http.StatusOK, gin.H{"succeeded": succeeded, "failed": failed}) +} + +func (h *Handler) batchResetQuota(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if request.Provider != string(accountdomain.ProviderBuild) { + response.Error(c, http.StatusBadRequest, "invalidProvider", "仅 Grok Build 账号支持手动重置额度状态") + return + } + reset, err := h.service.BatchResetQuotaState(c.Request.Context(), ids) + if err != nil { + h.writeServiceError(c, "quotaBatchResetFailed", err, http.StatusInternalServerError, "批量重置额度状态失败") + return + } + response.Success(c, http.StatusOK, gin.H{"reset": reset}) +} + +func (h *Handler) resetAllBuildQuota(c *gin.Context) { + reset, err := h.service.ResetAllBuildQuotaState(c.Request.Context()) + if err != nil { + h.writeServiceError(c, "quotaResetFailed", err, http.StatusInternalServerError, "重置全部 Grok Build 额度状态失败") + return + } + response.Success(c, http.StatusOK, gin.H{"reset": reset}) +} + +func (h *Handler) cleanup(c *gin.Context) { + var request accountCleanupRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + targets, err := parseLinkedDeleteTargets(request.LinkedDeleteTargets) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidLinkedDeleteTargets", err.Error()) + return + } + result, err := h.service.CleanupAccounts(c.Request.Context(), accountdomain.Provider(request.Provider), request.Statuses, targets) + if err != nil { + h.writeServiceError(c, "accountCleanupFailed", err, http.StatusInternalServerError, "清理账号失败") + return + } + byProvider := gin.H{} + for provider, count := range result.DeletedByProvider { + byProvider[string(provider)] = count + } + response.Success(c, http.StatusOK, gin.H{ + "deleted": result.Deleted, + "rootsDeleted": result.RootsDeleted, + "linkedDeleted": result.LinkedDeleted, + "skipped": result.Skipped, + "deletedByProvider": byProvider, + }) +} + +// cleanupPreview returns root and linked-peer counts for the cleanup dialog. +func (h *Handler) cleanupPreview(c *gin.Context) { + var request accountCleanupRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + targets, err := parseLinkedDeleteTargets(request.LinkedDeleteTargets) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidLinkedDeleteTargets", err.Error()) + return + } + preview, err := h.service.PreviewCleanup(c.Request.Context(), accountdomain.Provider(request.Provider), request.Statuses, targets) + if err != nil { + h.writeServiceError(c, "accountCleanupPreviewFailed", err, http.StatusInternalServerError, "预览清理账号失败") + return + } + rootsByStatus := gin.H{} + for status, count := range preview.RootsByStatus { + rootsByStatus[status] = count + } + linked := gin.H{} + for provider, count := range preview.LinkedByProvider { + linked[string(provider)] = count + } + response.Success(c, http.StatusOK, gin.H{ + "rootsByStatus": rootsByStatus, + "rootCount": preview.RootCount, + "linkedByProvider": linked, + "total": preview.Total, + }) +} + +func (h *Handler) batchRefreshQuotas(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + providerValue := accountdomain.Provider(request.Provider) + if !providerValue.IsValid() { + response.Error(c, http.StatusBadRequest, "invalidProvider", "账号来源无效") + return + } + if !h.validateProviderIDs(c, ids, request.Provider) { + return + } + var succeeded, failed int + if providerValue == accountdomain.ProviderBuild { + succeeded, failed, err = h.service.BatchRefreshBilling(c.Request.Context(), ids) + } else { + succeeded, failed, err = h.service.BatchRefreshQuota(c.Request.Context(), ids) + } + if err != nil { + h.writeServiceError(c, "quotaBatchRefreshFailed", err, http.StatusBadGateway, "批量同步账号额度失败") + return + } + response.Success(c, http.StatusOK, gin.H{"succeeded": succeeded, "failed": failed}) +} + +func (h *Handler) batchRefreshTokens(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if request.Provider != string(accountdomain.ProviderBuild) { + response.Error(c, http.StatusBadRequest, "invalidProvider", "仅 Grok Build 账号支持凭据刷新") + return + } + if !h.validateProviderIDs(c, ids, request.Provider) { + return + } + succeeded, failed, skipped, err := h.service.BatchRefreshTokens(c.Request.Context(), ids) + if err != nil { + h.writeServiceError(c, "tokenRefreshFailed", err, http.StatusBadGateway, "批量刷新账号凭据失败") + return + } + response.Success(c, http.StatusOK, gin.H{"succeeded": succeeded, "failed": failed, "skipped": skipped}) +} + +func (h *Handler) get(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + value, err := h.service.Get(c.Request.Context(), id) + if err != nil { + h.writeServiceError(c, "accountGetFailed", err, http.StatusInternalServerError, "读取账号失败") + return + } + response.Success(c, http.StatusOK, newAccountResponse(value)) +} + +func (h *Handler) startDevice(c *gin.Context) { + value, err := h.service.StartDeviceLogin(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusBadGateway, "deviceLoginStartFailed", "启动 Device OAuth 失败") + return + } + response.Success(c, http.StatusCreated, gin.H{"sessionId": value.SessionID, "userCode": value.UserCode, "verificationUri": value.VerificationURI, "verificationUriComplete": value.VerificationURIComplete, "intervalSeconds": int(value.Interval.Seconds()), "expiresAt": value.ExpiresAt}) +} + +func (h *Handler) pollDevice(c *gin.Context) { + value, err := h.service.PollDeviceLogin(c.Request.Context(), c.Param("sessionId")) + if errors.Is(err, accountapp.ErrDevicePending) { + response.Success(c, http.StatusAccepted, gin.H{"status": "pending"}) + return + } + if errors.Is(err, accountapp.ErrDeviceSlowDown) { + response.Error(c, http.StatusTooManyRequests, "devicePollTooFast", "轮询过快,请稍后重试") + return + } + if errors.Is(err, accountapp.ErrDeviceDenied) { + response.Error(c, http.StatusGone, "deviceLoginExpired", "Device OAuth 已拒绝或过期") + return + } + if err != nil { + response.Error(c, http.StatusBadGateway, "deviceLoginFailed", "Device OAuth 登录失败") + return + } + syncResult := h.syncInitial(c.Request.Context(), value.Credential.ID) + if refreshed, refreshErr := h.service.Get(c.Request.Context(), value.Credential.ID); refreshErr == nil { + value = refreshed + } + status := "succeeded" + if syncResult.Failed > 0 { + status = "syncFailed" + } + response.Success(c, http.StatusOK, gin.H{"status": status, "account": newAccountResponse(value), "synced": syncResult.Succeeded, "syncFailed": syncResult.Failed}) +} + +func (h *Handler) importAuth(c *gin.Context) { + h.importFile(c, accountdomain.ProviderBuild) +} + +func (h *Handler) importWebAuth(c *gin.Context) { + h.importFile(c, accountdomain.ProviderWeb) +} + +func (h *Handler) importConsoleAuth(c *gin.Context) { + h.importFile(c, accountdomain.ProviderConsole) +} + +func (h *Handler) convertWebToBuild(c *gin.Context) { + var request buildConversionRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "转换请求无效") + return + } + if request.All && len(request.IDs) > 0 { + response.Error(c, http.StatusBadRequest, "invalidRequest", "全部转换与指定账号不能同时提交") + return + } + if request.Strategy == "" { + request.Strategy = accountapp.BuildConversionMissing + } + if request.Strategy != accountapp.BuildConversionAll && request.Strategy != accountapp.BuildConversionMissing { + response.Error(c, http.StatusBadRequest, "invalidRequest", "转换策略无效") + return + } + var ids []uint64 + if !request.All { + var err error + ids, err = parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if !h.validateProviderIDs(c, ids, string(accountdomain.ProviderWeb)) { + return + } + } + h.streamWebToBuildConversion(c, request.All, ids, request.Strategy) +} + +func (h *Handler) syncWebToConsole(c *gin.Context) { + var request webConsoleSyncRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "同步请求无效") + return + } + if request.All && len(request.IDs) > 0 { + response.Error(c, http.StatusBadRequest, "invalidRequest", "全部同步与指定账号不能同时提交") + return + } + if request.Strategy == "" { + request.Strategy = accountapp.WebConsoleSyncAll + } + if request.Strategy != accountapp.WebConsoleSyncAll && request.Strategy != accountapp.WebConsoleSyncMissing { + response.Error(c, http.StatusBadRequest, "invalidRequest", "同步策略无效") + return + } + var ids []uint64 + if !request.All { + var err error + ids, err = parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + if !h.validateProviderIDs(c, ids, string(accountdomain.ProviderWeb)) { + return + } + } + h.streamWebToConsoleSync(c, request.All, ids, request.Strategy) +} + +func (h *Handler) runWebToConsoleSync(ctx context.Context, all bool, ids []uint64, strategy accountapp.WebConsoleSyncStrategy, progress accountapp.BatchProgressObserver, syncProgress func(completed, total int)) (accountapp.ImportResult, accountsyncapp.Result, error) { + pipeline := h.startSyncPipeline(ctx, syncProgress) + var ( + result accountapp.ImportResult + err error + ) + if all { + result, err = h.service.SyncAllWebAccountsToConsoleWithStrategy(pipeline.ctx, strategy, pipeline.Observe, progress) + } else { + result, err = h.service.SyncWebAccountsToConsoleWithStrategy(pipeline.ctx, ids, strategy, pipeline.Observe, progress) + } + syncResult := pipeline.Finish(err != nil) + return result, syncResult, err +} + +func (h *Handler) streamWebToConsoleSync(c *gin.Context, all bool, ids []uint64, strategy accountapp.WebConsoleSyncStrategy) { + stream := newAccountEventStream(c) + defer stream.Close() + var total atomic.Int64 + result, syncResult, err := h.runWebToConsoleSync(c.Request.Context(), all, ids, strategy, stream.PhaseProgressObserver("importing", &total), stream.SyncProgressObserver()) + if err != nil { + stream.WriteError("accountConsoleSyncFailed", "Grok Web 账号同步到 Console 失败") + return + } + _ = stream.Write("complete", accountImportResponse{Created: result.Created, Updated: result.Updated, Skipped: result.Skipped, Synced: syncResult.Succeeded, SyncFailed: syncResult.Failed}) +} + +func (h *Handler) runWebToBuildConversion(ctx context.Context, all bool, ids []uint64, strategy accountapp.BuildConversionStrategy, progress accountapp.BatchProgressObserver, syncProgress func(completed, total int)) (accountapp.BuildConversionResult, accountsyncapp.Result, error) { + pipeline := h.startSyncPipeline(ctx, syncProgress) + var ( + result accountapp.BuildConversionResult + err error + ) + if all { + result, err = h.service.ConvertAllWebAccountsToBuildWithStrategy(pipeline.ctx, strategy, pipeline.Observe, progress) + } else { + result, err = h.service.ConvertWebAccountsToBuildWithStrategy(pipeline.ctx, ids, strategy, pipeline.Observe, progress) + } + syncResult := pipeline.Finish(err != nil) + return result, syncResult, err +} + +func (h *Handler) streamWebToBuildConversion(c *gin.Context, all bool, ids []uint64, strategy accountapp.BuildConversionStrategy) { + stream := newAccountEventStream(c) + defer stream.Close() + var total atomic.Int64 + result, syncResult, err := h.runWebToBuildConversion(c.Request.Context(), all, ids, strategy, stream.PhaseProgressObserver("converting", &total), stream.SyncProgressObserver()) + if err != nil { + stream.WriteError("accountConversionFailed", "Grok Web 账号转换失败") + return + } + _ = stream.Write("complete", newBuildConversionResponse(result, syncResult)) +} + +func newBuildConversionResponse(result accountapp.BuildConversionResult, syncResult accountsyncapp.Result) buildConversionResponse { + return buildConversionResponse{ + Created: result.Created, Linked: result.Linked, Skipped: result.Skipped, Failed: result.Failed, + Synced: syncResult.Succeeded, SyncFailed: syncResult.Failed, + } +} + +func prepareAccountEventStream(c *gin.Context) { + c.Header("Content-Type", "text/event-stream; charset=utf-8") + c.Header("Cache-Control", "no-cache, no-transform") + c.Header("X-Accel-Buffering", "no") +} + +type accountEventStream struct { + context *gin.Context + mu sync.Mutex + stop chan struct{} + done chan struct{} + closeOnce sync.Once +} + +func newAccountEventStream(c *gin.Context) *accountEventStream { + prepareAccountEventStream(c) + stream := &accountEventStream{context: c, stop: make(chan struct{}), done: make(chan struct{})} + _ = stream.writeComment("connected") + go stream.heartbeat() + return stream +} + +func (s *accountEventStream) ProgressObserver() accountapp.BatchProgressObserver { + return s.PhaseProgressObserver("", nil) +} + +func (s *accountEventStream) PhaseProgressObserver(phase string, totalValue *atomic.Int64) accountapp.BatchProgressObserver { + return func(completed, total int) error { + if totalValue != nil { + totalValue.Store(int64(total)) + } + return s.Write("progress", accountTaskProgressResponse{Completed: completed, Total: total, Phase: phase}) + } +} + +func (s *accountEventStream) SyncProgressObserver() func(completed, total int) { + return func(completed, total int) { + _ = s.Write("progress", accountTaskProgressResponse{Completed: completed, Total: total, Phase: "syncing"}) + } +} + +func (s *accountEventStream) WriteError(code, message string) { + _ = s.Write("error", gin.H{"code": code, "message": message}) +} + +func (s *accountEventStream) Write(event string, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if err := setAccountWriteDeadline(s.context.Writer); err != nil { + return err + } + if _, err := fmt.Fprintf(s.context.Writer, "event: %s\ndata: %s\n\n", event, payload); err != nil { + return err + } + s.context.Writer.Flush() + return s.context.Request.Context().Err() +} + +func (s *accountEventStream) writeComment(comment string) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := setAccountWriteDeadline(s.context.Writer); err != nil { + return err + } + if _, err := fmt.Fprintf(s.context.Writer, ": %s\n\n", comment); err != nil { + return err + } + s.context.Writer.Flush() + return s.context.Request.Context().Err() +} + +func (s *accountEventStream) heartbeat() { + defer close(s.done) + ticker := time.NewTicker(accountEventHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case <-s.context.Request.Context().Done(): + return + case <-ticker.C: + if err := s.writeComment("heartbeat"); err != nil { + return + } + } + } +} + +func (s *accountEventStream) Close() { + s.closeOnce.Do(func() { close(s.stop) }) + <-s.done +} + +func setAccountWriteDeadline(writer http.ResponseWriter) error { + err := http.NewResponseController(writer).SetWriteDeadline(time.Now().Add(accountEventWriteTimeout)) + if errors.Is(err, http.ErrNotSupported) { + return nil + } + return err +} + +// writeAccountEvent keeps the event encoder independently testable without starting a heartbeat. +func writeAccountEvent(c *gin.Context, event string, value any) error { + return (&accountEventStream{context: c}).Write(event, value) +} + +func (h *Handler) importFile(c *gin.Context, providerValue accountdomain.Provider) { + fileDescription := "账号凭据 JSON 或逐行 JSON 文本" + if providerValue == accountdomain.ProviderWeb { + fileDescription = "Grok Web JSON、逐行 JSON 或 SSO 文本" + } else if providerValue == accountdomain.ProviderConsole { + fileDescription = "Grok Console JSON、逐行 JSON 或 SSO 文本" + } + documents, ok := readAccountImportDocuments(c, fileDescription) + if !ok { + return + } + stream := newAccountEventStream(c) + defer stream.Close() + var total atomic.Int64 + pipeline := h.startSyncPipeline(c.Request.Context(), stream.SyncProgressObserver()) + var result accountapp.ImportResult + var err error + if providerValue == accountdomain.ProviderWeb { + result, err = h.service.ImportWebCredentialDocumentsWithProgress(pipeline.ctx, documents, pipeline.Observe, stream.PhaseProgressObserver("importing", &total)) + } else if providerValue == accountdomain.ProviderConsole { + result, err = h.service.ImportConsoleCredentialDocumentsWithProgress(pipeline.ctx, documents, pipeline.Observe, stream.PhaseProgressObserver("importing", &total)) + } else { + result, err = h.service.ImportCredentialDocumentsWithProgress(pipeline.ctx, documents, pipeline.Observe, stream.PhaseProgressObserver("importing", &total)) + } + syncResult := pipeline.Finish(err != nil) + if err != nil { + stream.WriteError("authImportFailed", "导入账号失败") + return + } + _ = stream.Write("complete", accountImportResponse{Created: result.Created, Updated: result.Updated, Synced: syncResult.Succeeded, SyncFailed: syncResult.Failed}) +} + +func readAccountImportDocuments(c *gin.Context, fileDescription string) ([][]byte, bool) { + form, err := c.MultipartForm() + if err != nil { + var sizeError *http.MaxBytesError + if errors.As(err, &sizeError) { + response.Error(c, http.StatusRequestEntityTooLarge, "accountImportFileTooLarge", "账号凭据文件总大小不能超过 30 MiB") + return nil, false + } + response.Error(c, http.StatusBadRequest, "invalidAuthFile", "请选择有效的"+fileDescription) + return nil, false + } + defer form.RemoveAll() + files := append(form.File["files"], form.File["file"]...) + if len(files) == 0 { + response.Error(c, http.StatusBadRequest, "invalidAuthFile", "请选择有效的"+fileDescription) + return nil, false + } + if len(files) > maxAccountImportFiles { + response.Error(c, http.StatusBadRequest, "invalidAuthFile", "单次最多选择 1000 个账号文件") + return nil, false + } + documents := make([][]byte, 0, len(files)) + totalBytes := int64(0) + for _, file := range files { + if file.Size < 0 || totalBytes+file.Size > maxAccountImportBytes { + response.Error(c, http.StatusRequestEntityTooLarge, "accountImportFileTooLarge", "账号凭据文件总大小不能超过 30 MiB") + return nil, false + } + opened, openErr := file.Open() + if openErr != nil { + response.Error(c, http.StatusBadRequest, "invalidAuthFile", "无法读取"+fileDescription) + return nil, false + } + data, readErr := io.ReadAll(io.LimitReader(opened, maxAccountImportBytes-totalBytes+1)) + _ = opened.Close() + if readErr != nil { + response.Error(c, http.StatusBadRequest, "invalidAuthFile", "无法读取"+fileDescription) + return nil, false + } + totalBytes += int64(len(data)) + if totalBytes > maxAccountImportBytes { + response.Error(c, http.StatusRequestEntityTooLarge, "accountImportFileTooLarge", "账号凭据文件总大小不能超过 30 MiB") + return nil, false + } + documents = append(documents, data) + } + return documents, true +} + +func (h *Handler) refreshWebQuota(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if _, err := h.service.RefreshQuota(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "quotaRefreshFailed", err, http.StatusBadGateway, "同步 Provider 额度失败") + return + } + value, err := h.service.Get(c.Request.Context(), id) + if err != nil { + h.writeServiceError(c, "accountGetFailed", err, http.StatusInternalServerError, "读取账号失败") + return + } + response.Success(c, http.StatusOK, newAccountResponse(value)) +} + +func (h *Handler) exportCredentials(c *gin.Context) { + providerValue := accountdomain.Provider(c.DefaultQuery("provider", string(accountdomain.ProviderBuild))) + if limitText, pagedExport := c.GetQuery("limit"); pagedExport { + if _, usesOffset := c.GetQuery("offset"); usesOffset { + response.Error(c, http.StatusBadRequest, "accountExportFailed", "分批导出不支持 offset,请使用服务端返回的 afterId") + return + } + limit, err := strconv.Atoi(strings.TrimSpace(limitText)) + if err != nil { + response.Error(c, http.StatusBadRequest, "accountExportFailed", "导出数量必须为整数") + return + } + afterID, err := strconv.ParseUint(strings.TrimSpace(c.DefaultQuery("afterId", "0")), 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "accountExportFailed", "导出游标必须为非负整数") + return + } + snapshotMaxID, err := strconv.ParseUint(strings.TrimSpace(c.DefaultQuery("snapshotMaxId", "0")), 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "accountExportFailed", "导出快照上界必须为非负整数") + return + } + result, exportErr := h.service.ExportProviderCredentialsCursor(c.Request.Context(), providerValue, afterID, snapshotMaxID, limit) + if exportErr != nil { + h.writeServiceError(c, "accountExportFailed", exportErr, http.StatusInternalServerError, "导出账号失败") + return + } + c.Header("X-Export-Next-ID", strconv.FormatUint(result.NextID, 10)) + c.Header("X-Export-Snapshot-Max-ID", strconv.FormatUint(result.SnapshotMaxID, 10)) + c.Header("X-Export-Has-More", strconv.FormatBool(result.HasMore)) + h.writeCredentialExport(c, providerValue, result.ExportResult) + return + } + result, err := h.service.ExportProviderCredentials(c.Request.Context(), providerValue) + if err != nil { + h.writeServiceError(c, "accountExportFailed", err, http.StatusInternalServerError, "导出账号失败") + return + } + h.writeCredentialExport(c, providerValue, result) +} + +func (h *Handler) exportSelectedCredentials(c *gin.Context) { + var request credentialExportRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + providerValue := accountdomain.Provider(request.Provider) + result, err := h.service.ExportProviderCredentialsByIDs(c.Request.Context(), providerValue, ids) + if err != nil { + h.writeServiceError(c, "accountExportFailed", err, http.StatusInternalServerError, "导出账号失败") + return + } + h.writeCredentialExport(c, providerValue, result) +} + +func (h *Handler) writeCredentialExport(c *gin.Context, providerValue accountdomain.Provider, result accountapp.ExportResult) { + filename := "grok2api-" + string(providerValue) + "-accounts-" + time.Now().UTC().Format("20060102T150405Z") + ".json" + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + c.Header("Access-Control-Expose-Headers", "Content-Disposition, X-Exported-Accounts, X-Export-Next-ID, X-Export-Snapshot-Max-ID, X-Export-Has-More") + c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Exported-Accounts", strconv.Itoa(result.Count)) + c.Data(http.StatusOK, "application/json; charset=utf-8", result.Data) +} + +func (h *Handler) syncInitial(ctx context.Context, accountIDs ...uint64) accountsyncapp.Result { + if h.sync == nil { + return accountsyncapp.Result{} + } + return h.sync.Sync(ctx, accountIDs...) +} + +func (h *Handler) update(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var request updateRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, err := h.service.Update(c.Request.Context(), id, accountapp.UpdateInput{ + Name: request.Name, Enabled: request.Enabled, Priority: request.Priority, + MaxConcurrent: request.MaxConcurrent, MinimumRemaining: request.MinimumRemaining, + CloudflareCookies: request.CloudflareCookies, ClearCloudflareCookies: request.ClearCloudflareCookies, + BuildSuperEntitled: request.BuildSuperEntitled, BuildRouteMode: request.BuildRouteMode, + }) + if err != nil { + h.writeServiceError(c, "accountUpdateFailed", err, http.StatusInternalServerError, "更新账号失败") + return + } + result := newAccountResponse(value) + if request.BuildSuperEntitled != nil { + if synchronizer, ok := h.sync.(accountModelSynchronizer); ok { + result.ModelSyncFailed = synchronizer.SyncModels(c.Request.Context(), id) != nil + } + } + response.Success(c, http.StatusOK, result) +} + +func (h *Handler) delete(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var request struct { + Provider string `json:"provider"` + LinkedDeleteTargets []string `json:"linkedDeleteTargets"` + } + // Empty body = legacy single-account delete. Non-empty body must bind cleanly + // so a truncated/malformed linked-delete request cannot silently drop targets. + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &request); err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + } + targets, err := parseLinkedDeleteTargets(request.LinkedDeleteTargets) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidLinkedDeleteTargets", err.Error()) + return + } + if len(targets) > 0 { + if request.Provider == "" { + response.Error(c, http.StatusBadRequest, "invalidProvider", "删除关联账号时必须指定 provider") + return + } + result, err := h.service.DeleteWithLinked(c.Request.Context(), accountdomain.Provider(request.Provider), id, targets) + if err != nil { + h.writeServiceError(c, "accountDeleteFailed", err, http.StatusInternalServerError, "删除账号失败") + return + } + response.Success(c, http.StatusOK, newAccountDeleteResponse(result)) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "accountDeleteFailed", err, http.StatusInternalServerError, "删除账号失败") + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": true}) +} + +func parseLinkedDeleteTargets(values []string) ([]accountdomain.Provider, error) { + if len(values) == 0 { + return nil, nil + } + out := make([]accountdomain.Provider, 0, len(values)) + seen := map[accountdomain.Provider]struct{}{} + for _, value := range values { + provider := accountdomain.Provider(strings.TrimSpace(value)) + if !provider.IsValid() { + return nil, fmt.Errorf("关联删除目标无效") + } + if _, ok := seen[provider]; ok { + continue + } + seen[provider] = struct{}{} + out = append(out, provider) + } + return out, nil +} + +func newAccountDeleteResponse(result accountapp.AccountDeleteResult) gin.H { + byProvider := gin.H{} + for provider, count := range result.DeletedByProvider { + byProvider[string(provider)] = count + } + return gin.H{ + "deleted": result.Deleted, + "rootsDeleted": result.RootsDeleted, + "linkedDeleted": result.LinkedDeleted, + "skipped": result.Skipped, + "deletedByProvider": byProvider, + } +} + +// writeServiceError 仅暴露明确的账号业务错误,未知内部错误使用稳定文案。 +func (h *Handler) writeServiceError(c *gin.Context, code string, err error, fallbackStatus int, fallbackMessage string) { + switch { + case errors.Is(err, accountapp.ErrImportLimit): + response.Error(c, http.StatusBadRequest, "accountImportLimitExceeded", err.Error()) + case errors.Is(err, accountapp.ErrExportLimit): + response.Error(c, http.StatusBadRequest, "accountExportLimitExceeded", err.Error()) + case errors.Is(err, accountapp.ErrInvalidInput), errors.Is(err, accountapp.ErrInvalidImport): + response.Error(c, http.StatusBadRequest, code, err.Error()) + case errors.Is(err, accountapp.ErrAccountPoolMismatch): + response.Error(c, http.StatusConflict, "accountPoolMismatch", err.Error()) + case errors.Is(err, accountapp.ErrConflict): + response.Error(c, http.StatusConflict, code, err.Error()) + case errors.Is(err, accountapp.ErrNotFound): + response.Error(c, http.StatusNotFound, "accountNotFound", err.Error()) + case errors.Is(err, accountapp.ErrUnsupported): + response.Error(c, http.StatusConflict, "accountOperationUnsupported", err.Error()) + case errors.Is(err, accountapp.ErrConversionBusy): + response.Error(c, http.StatusConflict, "accountConversionBusy", err.Error()) + case errors.Is(err, accountapp.ErrWebAccountScriptBusy): + response.Error(c, http.StatusConflict, "webAccountScriptBusy", err.Error()) + default: + response.Error(c, fallbackStatus, code, fallbackMessage) + } +} + +func (h *Handler) refreshToken(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + value, err := h.service.RefreshToken(c.Request.Context(), id) + if err != nil { + h.writeServiceError(c, "tokenRefreshFailed", err, http.StatusBadGateway, "刷新账号凭据失败") + return + } + response.Success(c, http.StatusOK, newAccountResponse(value)) +} + +func (h *Handler) acceptWebTerms(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.AcceptWebTerms(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "webTermsAcceptanceFailed", err, http.StatusBadGateway, "接受 Grok Web 服务协议失败") + return + } + response.Success(c, http.StatusOK, gin.H{"completed": true}) +} + +func (h *Handler) setWebBirthDate(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.SetWebBirthDate(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "webBirthDateUpdateFailed", err, http.StatusBadGateway, "设置 Grok Web 账号生日失败") + return + } + response.Success(c, http.StatusOK, gin.H{"completed": true}) +} + +func (h *Handler) enableWebNSFW(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.EnableWebNSFW(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "webNSFWEnableFailed", err, http.StatusBadGateway, "开启 Grok Web NSFW 失败") + return + } + response.Success(c, http.StatusOK, gin.H{"completed": true}) +} + +func (h *Handler) refreshBilling(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + value, err := h.service.RefreshBilling(c.Request.Context(), id) + if err != nil { + h.writeServiceError(c, "billingRefreshFailed", err, http.StatusBadGateway, "刷新账号额度失败") + return + } + response.Success(c, http.StatusOK, newBillingResponse(value)) +} + +func (h *Handler) refreshAllBilling(c *gin.Context) { + stream := newAccountEventStream(c) + defer stream.Close() + succeeded, failed, err := h.service.SyncAllBillingWithProgress(c.Request.Context(), stream.ProgressObserver()) + if err != nil { + stream.WriteError("billingRefreshFailed", "刷新账号额度失败") + return + } + _ = stream.Write("complete", accountBatchResponse{Succeeded: succeeded, Failed: failed}) +} + +func (h *Handler) refreshAllTokens(c *gin.Context) { + stream := newAccountEventStream(c) + defer stream.Close() + succeeded, failed, skipped, err := h.service.RefreshAllTokensWithProgress(c.Request.Context(), stream.ProgressObserver()) + if err != nil { + stream.WriteError("tokenRefreshFailed", "续期账号凭据失败") + return + } + _ = stream.Write("complete", accountTokenRefreshResponse{Succeeded: succeeded, Failed: failed, Skipped: skipped}) +} + +func (h *Handler) refreshAllWebQuotas(c *gin.Context) { + stream := newAccountEventStream(c) + defer stream.Close() + succeeded, failed, err := h.service.SyncAllWebQuotasWithProgress(c.Request.Context(), stream.ProgressObserver()) + if err != nil { + stream.WriteError("quotaRefreshFailed", "同步 Grok Web 账号额度失败") + return + } + _ = stream.Write("complete", accountBatchResponse{Succeeded: succeeded, Failed: failed}) +} + +func (h *Handler) refreshAllConsoleQuotas(c *gin.Context) { + stream := newAccountEventStream(c) + defer stream.Close() + succeeded, failed, err := h.service.SyncAllConsoleQuotasWithProgress(c.Request.Context(), stream.ProgressObserver()) + if err != nil { + stream.WriteError("quotaRefreshFailed", "同步 Grok Console 账号额度失败") + return + } + _ = stream.Write("complete", accountBatchResponse{Succeeded: succeeded, Failed: failed}) +} + +func newAccountResponse(value accountapp.View) accountResponse { + c := value.Credential + buildRouteMode := c.BuildRouteMode + if c.Provider != accountdomain.ProviderBuild || !buildRouteMode.IsValid() { + buildRouteMode = accountdomain.BuildRouteAuto + } + result := accountResponse{ + ID: c.ID, Provider: string(c.Provider), AuthType: string(c.AuthType), WebTier: string(c.WebTier), + WebTierSyncedAt: c.WebTierSyncedAt, WebNSFWEnabledAt: c.WebNSFWEnabledAt, WebTermsAcceptedAt: c.WebTermsAcceptedAt, Name: c.Name, Email: c.Email, UserID: c.UserID, TeamID: c.TeamID, + Enabled: c.Enabled, AuthStatus: string(c.AuthStatus), Refreshable: c.EncryptedRefreshToken != "", + RefreshDueAt: c.RefreshDueAt, LastRefreshAt: c.LastRefreshAt, + RefreshFailures: c.RefreshFailureCount, LastRefreshErrorStatus: c.LastRefreshErrorStatus, LastRefreshError: c.LastRefreshErrorCode, LastRefreshErrorMessage: c.LastRefreshErrorMessage, LastRefreshErrorResponse: c.LastRefreshErrorResponse, + Priority: c.Priority, MaxConcurrent: c.MaxConcurrent, MinimumRemaining: c.MinimumRemaining, + FailureCount: c.FailureCount, CooldownUntil: c.CooldownUntil, LastError: c.LastError, + LastUsedAt: c.LastUsedAt, LinkedAccountID: c.LinkedAccountID, LinkedName: c.LinkedAccountName, LinkedProvider: string(c.LinkedProvider), + CreatedAt: c.CreatedAt, ObservedModel: c.ObservedModel, ObservedModelAt: c.ObservedModelAt, + CloudflareCookieConfigured: c.EncryptedCloudflareCookie != "", + BuildSuperEntitled: c.BuildSuperEntitled && c.Provider == accountdomain.ProviderBuild, + BuildRouteMode: string(buildRouteMode), + BuildBotFlagged: value.BuildBotFlagged && c.Provider == accountdomain.ProviderBuild, + EgressNodeID: c.EgressNodeID, + EgressAssignmentMode: string(c.EgressAssignmentMode), + Quota: newQuotaResponse(value.Quota), QuotaWindows: make([]quotaWindowResponse, 0, len(value.QuotaWindows)), + } + for _, linked := range c.LinkedAccounts { + result.LinkedAccounts = append(result.LinkedAccounts, linkedAccountResponse{ID: linked.ID, Provider: string(linked.Provider), Name: linked.Name, Email: linked.Email, UserID: linked.UserID}) + } + for _, window := range value.QuotaWindows { + breakdown := make([]quotaBreakdownResponse, 0, len(window.Breakdown)) + for _, item := range window.Breakdown { + breakdown = append(breakdown, quotaBreakdownResponse{ProductCode: item.ProductCode, UsagePercent: item.UsagePercent}) + } + result.QuotaWindows = append(result.QuotaWindows, quotaWindowResponse{ + Mode: window.Mode, Remaining: window.Remaining, Total: window.Total, + UsagePercent: window.UsagePercent, Breakdown: breakdown, + WindowSeconds: window.WindowSeconds, ResetAt: window.ResetAt, SyncedAt: window.SyncedAt, + Source: string(window.Source), + }) + } + if !c.ExpiresAt.IsZero() { + expiresAt := c.ExpiresAt + result.ExpiresAt = &expiresAt + } + if value.Billing != nil { + billing := newBillingResponse(*value.Billing) + result.Billing = &billing + } + return result +} + +func newQuotaResponse(value accountapp.QuotaView) quotaResponse { + return quotaResponse{Type: string(value.Type), Source: value.Source, Confidence: value.Confidence, Unit: value.Unit, Used: value.Used, Limit: value.Limit, Remaining: value.Remaining, UsagePercent: value.UsagePercent, LimitKnown: value.LimitKnown, WindowHours: value.WindowHours, Observed: value.Observed, Confirmed: value.Confirmed, Status: string(value.Status), PeriodStart: value.PeriodStart, PeriodEnd: value.PeriodEnd, ExhaustedAt: value.ExhaustedAt, NextProbeAt: value.NextProbeAt, LastConfirmedAt: value.LastConfirmedAt} +} + +func newBillingResponse(value accountdomain.Billing) billingResponse { + history := make([]billingHistoryResponse, 0, len(value.History)) + for _, entry := range value.History { + history = append(history, billingHistoryResponse{ + Year: entry.Year, Month: entry.Month, + PeriodType: entry.PeriodType, PeriodStart: entry.PeriodStart, PeriodEnd: entry.PeriodEnd, + IncludedUsed: entry.IncludedUsed, OnDemandUsed: entry.OnDemandUsed, TotalUsed: entry.TotalUsed, + }) + } + return billingResponse{PlanCode: value.PlanCode, PlanName: value.PlanName, MonthlyLimit: value.MonthlyLimit, Used: value.Used, Remaining: value.Remaining(), OnDemandCap: value.OnDemandCap, OnDemandUsed: value.OnDemandUsed, PrepaidBalance: value.PrepaidBalance, CreditUsagePercent: value.CreditUsagePercent, IsUnifiedBillingUser: value.IsUnifiedBillingUser, OnDemandEnabled: value.OnDemandEnabled, TopUpMethod: value.TopUpMethod, UsagePeriodType: value.UsagePeriodType, UsagePeriodStart: value.UsagePeriodStart, UsagePeriodEnd: value.UsagePeriodEnd, BillingPeriodStart: value.BillingPeriodStart, BillingPeriodEnd: value.BillingPeriodEnd, History: history, SyncedAt: value.SyncedAt} +} + +func pagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + return repository.NormalizePage(page, size, repository.DefaultPageSize) +} + +func pathID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", "ID 无效") + return 0, false + } + return id, true +} + +func parseIDs(values []string) ([]uint64, error) { + ids := make([]uint64, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return nil, errors.New("ID 无效") + } + ids = append(ids, id) + } + return ids, nil +} + +func (h *Handler) validateProviderIDs(c *gin.Context, ids []uint64, providerValue string) bool { + provider := accountdomain.Provider(providerValue) + if !provider.IsValid() { + response.Error(c, http.StatusBadRequest, "invalidProvider", "账号来源无效") + return false + } + valid, err := h.service.AccountsBelongToProvider(c.Request.Context(), ids, provider) + if err != nil { + h.writeServiceError(c, "accountPoolValidationFailed", err, http.StatusInternalServerError, "校验账号号池失败") + return false + } + if !valid { + response.Error(c, http.StatusConflict, "accountPoolMismatch", "批量操作包含不属于当前号池的账号") + return false + } + return true +} diff --git a/backend/internal/transport/http/account/handler_test.go b/backend/internal/transport/http/account/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dcf5ffeb8f256e2f31502d67fd0e66014ff2b861 --- /dev/null +++ b/backend/internal/transport/http/account/handler_test.go @@ -0,0 +1,315 @@ +package account + +import ( + "bytes" + "context" + "fmt" + "mime/multipart" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + accountsyncapp "github.com/chenyme/grok2api/backend/internal/application/accountsync" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/gin-gonic/gin" +) + +func TestNewAccountResponseExposesBuildBotFlagOnlyForBuild(t *testing.T) { + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + build := newAccountResponse(accountapp.View{ + Credential: accountdomain.Credential{Provider: accountdomain.ProviderBuild, BuildRouteMode: accountdomain.BuildRouteXAI, WebNSFWEnabledAt: &now, WebTermsAcceptedAt: &now}, + BuildBotFlagged: true, + }) + if !build.BuildBotFlagged || build.BuildRouteMode != string(accountdomain.BuildRouteXAI) || build.WebNSFWEnabledAt == nil || !build.WebNSFWEnabledAt.Equal(now) || build.WebTermsAcceptedAt == nil || !build.WebTermsAcceptedAt.Equal(now) { + t.Fatalf("Build metadata = %#v", build) + } + web := newAccountResponse(accountapp.View{ + Credential: accountdomain.Credential{Provider: accountdomain.ProviderWeb, WebNSFWEnabledAt: &now, WebTermsAcceptedAt: &now}, + BuildBotFlagged: true, + }) + if web.BuildBotFlagged || web.BuildRouteMode != string(accountdomain.BuildRouteAuto) || web.WebNSFWEnabledAt == nil || !web.WebNSFWEnabledAt.Equal(now) || web.WebTermsAcceptedAt == nil || !web.WebTermsAcceptedAt.Equal(now) { + t.Fatalf("non-Build metadata = %#v", web) + } +} + +func TestNewAccountResponseExposesAllLinkedAccounts(t *testing.T) { + response := newAccountResponse(accountapp.View{Credential: accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, + LinkedAccounts: []accountdomain.LinkedAccount{ + {ID: 2, Provider: accountdomain.ProviderBuild, Name: "build", Email: "build@example.com", UserID: "build-user"}, + {ID: 3, Provider: accountdomain.ProviderConsole, Name: "console", Email: "console@example.com", UserID: "console-user"}, + }, + }}) + if len(response.LinkedAccounts) != 2 || response.LinkedAccounts[0].Provider != string(accountdomain.ProviderBuild) || response.LinkedAccounts[0].Email != "build@example.com" || response.LinkedAccounts[0].UserID != "build-user" || response.LinkedAccounts[1].Provider != string(accountdomain.ProviderConsole) || response.LinkedAccounts[1].Email != "console@example.com" || response.LinkedAccounts[1].UserID != "console-user" { + t.Fatalf("linked accounts = %#v", response.LinkedAccounts) + } +} + +func TestNewAccountResponseExposesCredentialRefreshError(t *testing.T) { + response := newAccountResponse(accountapp.View{Credential: accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, LastRefreshErrorStatus: 400, LastRefreshErrorCode: "invalid_grant", LastRefreshErrorMessage: "Refresh token has expired", LastRefreshErrorResponse: `{"error":"invalid_grant"}`, + }}) + if response.LastRefreshErrorStatus != 400 || response.LastRefreshError != "invalid_grant" || response.LastRefreshErrorMessage != "Refresh token has expired" || response.LastRefreshErrorResponse == "" { + t.Fatalf("refresh error = %#v", response) + } +} + +type accountSynchronizerStub struct { + accountIDs []uint64 +} + +type accountProgressSynchronizerStub struct { + accountSynchronizerStub +} + +func TestWriteServiceErrorUsesCredentialLimitCodes(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + err error + code string + }{ + {name: "import", err: fmt.Errorf("%w: too many", accountapp.ErrImportLimit), code: "accountImportLimitExceeded"}, + {name: "export", err: fmt.Errorf("%w: too many", accountapp.ErrExportLimit), code: "accountExportLimitExceeded"}, + {name: "invalid input", err: fmt.Errorf("%w: bad", accountapp.ErrInvalidInput), code: "fallback"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + new(Handler).writeServiceError(ctx, "fallback", test.err, 500, "failed") + if test.name == "invalid input" { + if recorder.Code != 400 { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + return + } + if recorder.Code != 400 || !strings.Contains(recorder.Body.String(), `"code":"`+test.code+`"`) { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestCredentialExportRejectsOffsetPagination(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/api/admin/v1/accounts/export?provider=grok_build&limit=1000&offset=1000", nil) + new(Handler).exportCredentials(ctx) + if recorder.Code != 400 || !strings.Contains(recorder.Body.String(), "afterId") { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestCredentialExportExposesBatchMetadataHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + new(Handler).writeCredentialExport(ctx, accountdomain.ProviderBuild, accountapp.ExportResult{Data: []byte(`[]`)}) + + exposed := recorder.Header().Get("Access-Control-Expose-Headers") + for _, header := range []string{"X-Exported-Accounts", "X-Export-Next-ID", "X-Export-Snapshot-Max-ID", "X-Export-Has-More"} { + if !strings.Contains(exposed, header) { + t.Fatalf("exposed headers %q do not include %s", exposed, header) + } + } +} + +func TestParseLinkedDeleteTargets(t *testing.T) { + targets, err := parseLinkedDeleteTargets([]string{"grok_build", "grok_console", "grok_build"}) + if err != nil || len(targets) != 2 || targets[0] != accountdomain.ProviderBuild || targets[1] != accountdomain.ProviderConsole { + t.Fatalf("targets=%v err=%v", targets, err) + } + if _, err := parseLinkedDeleteTargets([]string{"nope"}); err == nil { + t.Fatal("expected invalid target") + } + targets, err = parseLinkedDeleteTargets(nil) + if err != nil || targets != nil { + t.Fatalf("empty targets=%v err=%v", targets, err) + } +} + +func TestNewAccountDeleteResponseShape(t *testing.T) { + payload := newAccountDeleteResponse(accountapp.AccountDeleteResult{ + Deleted: 3, RootsDeleted: 1, LinkedDeleted: 2, + DeletedByProvider: map[accountdomain.Provider]int64{ + accountdomain.ProviderWeb: 1, + accountdomain.ProviderBuild: 1, + accountdomain.ProviderConsole: 1, + }, + }) + if payload["deleted"] != int64(3) || payload["rootsDeleted"] != int64(1) || payload["linkedDeleted"] != int64(2) { + t.Fatalf("payload = %#v", payload) + } + byProvider, ok := payload["deletedByProvider"].(gin.H) + if !ok || byProvider["grok_web"] != int64(1) { + t.Fatalf("byProvider = %#v", payload["deletedByProvider"]) + } +} + +func TestLinkedDeleteMissingAccountReturnsNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "linked-delete-not-found.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + service := accountapp.NewService(relational.NewAccountRepository(database), nil, nil, nil, nil, nil, nil) + handler := NewHandler(service, nil) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Params = []gin.Param{{Key: "id", Value: "999999"}} + ginContext.Request = httptest.NewRequest("DELETE", "/api/admin/v1/accounts/999999", strings.NewReader(`{"provider":"grok_web","linkedDeleteTargets":["grok_build"]}`)) + ginContext.Request.Header.Set("Content-Type", "application/json") + + handler.delete(ginContext) + + if recorder.Code != 404 || !strings.Contains(recorder.Body.String(), `"code":"accountNotFound"`) { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func (s *accountSynchronizerStub) Sync(_ context.Context, accountIDs ...uint64) accountsyncapp.Result { + s.accountIDs = append(s.accountIDs, accountIDs...) + return accountsyncapp.Result{Succeeded: len(accountIDs)} +} + +func (s *accountSynchronizerStub) SyncStream(_ context.Context, accountIDs <-chan uint64) accountsyncapp.Result { + for accountID := range accountIDs { + s.accountIDs = append(s.accountIDs, accountID) + } + return accountsyncapp.Result{Succeeded: len(s.accountIDs)} +} + +func (s *accountProgressSynchronizerStub) SyncStreamObserved(_ context.Context, accountIDs <-chan uint64, observer func(completed, total int)) accountsyncapp.Result { + for accountID := range accountIDs { + s.accountIDs = append(s.accountIDs, accountID) + } + for completed := 1; completed <= len(s.accountIDs); completed++ { + observer(completed, completed) + } + return accountsyncapp.Result{Succeeded: len(s.accountIDs)} +} + +func TestSyncInitialUsesOnlyChangedAccounts(t *testing.T) { + sync := &accountSynchronizerStub{} + handler := NewHandler(nil, sync) + + result := handler.syncInitial(context.Background(), 3, 5) + + if result.Succeeded != 2 || len(sync.accountIDs) != 2 || sync.accountIDs[0] != 3 || sync.accountIDs[1] != 5 { + t.Fatalf("account ids = %#v", sync.accountIDs) + } +} + +func TestWriteBuildConversionEventUsesSSEFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/api/admin/v1/accounts/web/convert-to-build", nil) + + if err := writeAccountEvent(ctx, "progress", accountTaskProgressResponse{Completed: 3, Total: 10}); err != nil { + t.Fatal(err) + } + if body := recorder.Body.String(); body != "event: progress\ndata: {\"completed\":3,\"total\":10}\n\n" { + t.Fatalf("body = %q", body) + } +} + +func TestConvertWebToBuildRejectsInvalidStrategy(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/api/admin/v1/accounts/web/convert-to-build", strings.NewReader(`{"ids":["1"],"strategy":"invalid"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + + new(Handler).convertWebToBuild(ctx) + + if recorder.Code != 400 || !strings.Contains(recorder.Body.String(), `"code":"invalidRequest"`) { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestAccountProgressEventIncludesOptionalPhase(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/api/admin/v1/accounts/import", nil) + stream := &accountEventStream{context: ctx} + var total atomic.Int64 + + if err := stream.PhaseProgressObserver("importing", &total)(3, 10); err != nil { + t.Fatal(err) + } + if body := recorder.Body.String(); body != "event: progress\ndata: {\"completed\":3,\"total\":10,\"phase\":\"importing\"}\n\n" { + t.Fatalf("body = %q", body) + } + if total.Load() != 10 { + t.Fatalf("total = %d", total.Load()) + } +} + +func TestReadAccountImportDocumentsAcceptsMultipleFiles(t *testing.T) { + gin.SetMode(gin.TestMode) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for name, value := range map[string]string{"first.json": `{"accounts":[]}`, "second.json": `{"provider":"grok_build"}`} { + part, err := writer.CreateFormFile("files", name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(value)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/api/admin/v1/accounts/import", &body) + ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + documents, ok := readAccountImportDocuments(ctx, "账号凭据 JSON") + if !ok || len(documents) != 2 { + t.Fatalf("documents = %q, status = %d", documents, recorder.Code) + } +} + +func TestAccountSyncPipelineUsesFinalQueuedTotal(t *testing.T) { + syncer := &accountProgressSynchronizerStub{} + handler := NewHandler(nil, syncer) + progress := make([][2]int, 0, 5) + pipeline := handler.startSyncPipeline(context.Background(), func(completed, total int) { + progress = append(progress, [2]int{completed, total}) + }) + + for _, accountID := range []uint64{11, 12, 13} { + if err := pipeline.Observe(accountID); err != nil { + t.Fatal(err) + } + } + result := pipeline.Finish(false) + + if result.Succeeded != 3 { + t.Fatalf("result = %#v", result) + } + if len(progress) == 0 || progress[len(progress)-1] != [2]int{3, 3} { + t.Fatalf("progress = %#v", progress) + } + for _, value := range progress { + if value[1] != 3 { + t.Fatalf("progress contains changing total: %#v", progress) + } + } +} diff --git a/backend/internal/transport/http/account/web_account_scripts.go b/backend/internal/transport/http/account/web_account_scripts.go new file mode 100644 index 0000000000000000000000000000000000000000..62cdb4b7a8ebb0c9a7ab58eadd8c31e926050391 --- /dev/null +++ b/backend/internal/transport/http/account/web_account_scripts.go @@ -0,0 +1,105 @@ +package account + +import ( + "net/http" + + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +const maxWebAccountScriptRequestIDs = 1000 + +type webAccountScriptsRequest struct { + IDs []string `json:"ids"` + All bool `json:"all"` + Actions webAccountScriptActionsRequest `json:"actions"` +} + +type webAccountScriptActionsRequest struct { + AcceptTerms bool `json:"acceptTerms"` + SetBirthDate bool `json:"setBirthDate"` + EnableNSFW bool `json:"enableNSFW"` +} + +func (r webAccountScriptActionsRequest) options() accountapp.WebAccountScriptOptions { + return accountapp.WebAccountScriptOptions{ + AcceptTerms: r.AcceptTerms, + SetBirthDate: r.SetBirthDate, + EnableNSFW: r.EnableNSFW, + } +} + +func (r webAccountScriptActionsRequest) empty() bool { + return !r.AcceptTerms && !r.SetBirthDate && !r.EnableNSFW +} + +func (h *Handler) runWebAccountScripts(c *gin.Context) { + var request webAccountScriptsRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "账号脚本请求无效") + return + } + if request.All && len(request.IDs) > 0 { + response.Error(c, http.StatusBadRequest, "invalidRequest", "全部账号与指定账号不能同时提交") + return + } + if !request.All && len(request.IDs) == 0 { + response.Error(c, http.StatusBadRequest, "invalidRequest", "至少选择一个账号") + return + } + if len(request.IDs) > maxWebAccountScriptRequestIDs { + response.Error(c, http.StatusBadRequest, "invalidRequest", "单次最多处理 1000 个账号") + return + } + if request.Actions.empty() { + response.Error(c, http.StatusBadRequest, "invalidRequest", "至少选择一个账号脚本") + return + } + + var ids []uint64 + if !request.All { + var err error + ids, err = parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + ids = uniqueWebAccountScriptIDs(ids) + if !h.validateProviderIDs(c, ids, string(accountdomain.ProviderWeb)) { + return + } + } + + stream := newAccountEventStream(c) + defer stream.Close() + var ( + succeeded int + failed int + err error + ) + if request.All { + succeeded, failed, err = h.service.RunAllWebAccountScriptsWithProgress(c.Request.Context(), request.Actions.options(), stream.ProgressObserver()) + } else { + succeeded, failed, err = h.service.RunWebAccountScriptsWithProgress(c.Request.Context(), ids, request.Actions.options(), stream.ProgressObserver()) + } + if err != nil { + stream.WriteError("webAccountScriptFailed", "执行 Grok Web 账号脚本失败") + return + } + _ = stream.Write("complete", accountBatchResponse{Succeeded: succeeded, Failed: failed}) +} + +func uniqueWebAccountScriptIDs(ids []uint64) []uint64 { + seen := make(map[uint64]struct{}, len(ids)) + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} diff --git a/backend/internal/transport/http/account/web_account_scripts_test.go b/backend/internal/transport/http/account/web_account_scripts_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1e1380ece708eec1825dfe86bbfba849cab05028 --- /dev/null +++ b/backend/internal/transport/http/account/web_account_scripts_test.go @@ -0,0 +1,71 @@ +package account + +import ( + "encoding/json" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRunWebAccountScriptsRejectsInvalidRequestsBeforeSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + body string + }{ + {name: "all and ids", body: `{"all":true,"ids":["1"],"actions":{"acceptTerms":true}}`}, + {name: "empty selected", body: `{"ids":[],"actions":{"acceptTerms":true}}`}, + {name: "empty actions", body: `{"ids":["1"],"actions":{}}`}, + {name: "invalid id", body: `{"ids":["invalid"],"actions":{"acceptTerms":true}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/api/admin/v1/accounts/web/run-scripts", strings.NewReader(test.body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + new(Handler).runWebAccountScripts(ctx) + + if recorder.Code != 400 { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if contentType := recorder.Header().Get("Content-Type"); strings.HasPrefix(contentType, "text/event-stream") { + t.Fatalf("invalid request switched to SSE: %q", contentType) + } + }) + } +} + +func TestRunWebAccountScriptsRejectsOversizedSelectionBeforeSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + ids := make([]string, maxWebAccountScriptRequestIDs+1) + for index := range ids { + ids[index] = strconv.Itoa(index + 1) + } + body, err := json.Marshal(webAccountScriptsRequest{ + IDs: ids, + Actions: webAccountScriptActionsRequest{ + AcceptTerms: true, + }, + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/api/admin/v1/accounts/web/run-scripts", strings.NewReader(string(body))) + ctx.Request.Header.Set("Content-Type", "application/json") + + new(Handler).runWebAccountScripts(ctx) + + if recorder.Code != 400 || !strings.Contains(recorder.Body.String(), "单次最多处理 1000 个账号") { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if contentType := recorder.Header().Get("Content-Type"); strings.HasPrefix(contentType, "text/event-stream") { + t.Fatalf("oversized request switched to SSE: %q", contentType) + } +} diff --git a/backend/internal/transport/http/adminauth/handler.go b/backend/internal/transport/http/adminauth/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..e5bcfd0c16fbe6cdaa3bd57c611078e428adab4f --- /dev/null +++ b/backend/internal/transport/http/adminauth/handler.go @@ -0,0 +1,189 @@ +package adminauth + +import ( + "errors" + "io" + "net" + "net/http" + "strings" + "time" + + adminapp "github.com/chenyme/grok2api/backend/internal/application/adminauth" + admindomain "github.com/chenyme/grok2api/backend/internal/domain/admin" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/chenyme/grok2api/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +const refreshCookieName = "grok2api_admin_refresh" + +type Handler struct { + service *adminapp.Service + secureCookies bool +} + +func NewHandler(service *adminapp.Service, secureCookies bool) *Handler { + return &Handler{service: service, secureCookies: secureCookies} +} + +func (h *Handler) RegisterPublic(router *gin.RouterGroup) { + router.POST("/auth/login", h.login) + router.POST("/auth/refresh", h.refresh) + router.POST("/auth/logout", h.logout) +} + +func (h *Handler) RegisterAuthenticated(router *gin.RouterGroup) { + router.GET("/me", h.me) + router.PUT("/me/password", h.changePassword) +} + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type refreshRequest struct { + RefreshToken string `json:"refreshToken"` +} + +type changePasswordRequest struct { + CurrentPassword string `json:"currentPassword" binding:"required"` + NewPassword string `json:"newPassword" binding:"required"` +} + +type tokenResponse struct { + AccessToken string `json:"accessToken"` + AccessTokenExpiresAt string `json:"accessTokenExpiresAt"` + RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"` +} + +type adminResponse struct { + ID uint64 `json:"id,string"` + Username string `json:"username"` +} + +func (h *Handler) login(c *gin.Context) { + var request loginRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + adminValue, tokens, err := h.service.Login(c.Request.Context(), request.Username, request.Password, remoteAddress(c.Request)) + if err != nil { + if errors.Is(err, adminapp.ErrLoginRateLimited) { + response.Error(c, http.StatusTooManyRequests, "loginRateLimited", "登录尝试过于频繁,请稍后重试") + return + } + if errors.Is(err, adminapp.ErrRuntimeUnavailable) { + response.Error(c, http.StatusServiceUnavailable, "authRuntimeUnavailable", "管理员认证服务暂不可用") + return + } + response.Error(c, http.StatusUnauthorized, "invalidCredentials", "管理员账号或密码错误") + return + } + h.setRefreshCookie(c, tokens) + response.Success(c, http.StatusOK, gin.H{"admin": newAdminResponse(adminValue), "tokens": newTokenResponse(tokens)}) +} + +func remoteAddress(request *http.Request) string { + value := strings.TrimSpace(request.RemoteAddr) + host, _, err := net.SplitHostPort(value) + if err == nil && host != "" { + return host + } + return value +} + +func (h *Handler) refresh(c *gin.Context) { + var request refreshRequest + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + if request.RefreshToken == "" { + request.RefreshToken, _ = c.Cookie(refreshCookieName) + } + if request.RefreshToken == "" { + response.Error(c, http.StatusUnauthorized, "invalidRefreshToken", "刷新会话无效") + return + } + tokens, err := h.service.Refresh(c.Request.Context(), request.RefreshToken) + if err != nil { + if errors.Is(err, adminapp.ErrRuntimeUnavailable) { + response.Error(c, http.StatusServiceUnavailable, "authRuntimeUnavailable", "管理员认证服务暂不可用") + return + } + response.Error(c, http.StatusUnauthorized, "invalidRefreshToken", "刷新会话无效") + return + } + h.setRefreshCookie(c, tokens) + response.Success(c, http.StatusOK, newTokenResponse(tokens)) +} + +func (h *Handler) logout(c *gin.Context) { + var request refreshRequest + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + if request.RefreshToken == "" { + request.RefreshToken, _ = c.Cookie(refreshCookieName) + } + if err := h.service.Logout(c.Request.Context(), request.RefreshToken); err != nil { + response.Error(c, http.StatusServiceUnavailable, "authRuntimeUnavailable", "管理员认证服务暂不可用") + return + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(refreshCookieName, "", -1, "/api/admin/v1/auth", "", h.secureCookies || c.Request.TLS != nil, true) + response.Success(c, http.StatusOK, gin.H{"loggedOut": true}) +} + +func (h *Handler) me(c *gin.Context) { + value, ok := c.Get(middleware.AdminKey) + adminValue, valid := value.(admindomain.Admin) + if !ok || !valid { + response.Error(c, http.StatusUnauthorized, "adminUnauthorized", "管理员登录已失效") + return + } + response.Success(c, http.StatusOK, newAdminResponse(adminValue)) +} + +func (h *Handler) changePassword(c *gin.Context) { + var request changePasswordRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, ok := c.Get(middleware.AdminKey) + adminValue, valid := value.(admindomain.Admin) + if !ok || !valid { + response.Error(c, http.StatusUnauthorized, "adminUnauthorized", "管理员登录已失效") + return + } + if err := h.service.ChangePassword(c.Request.Context(), adminValue.ID, request.CurrentPassword, request.NewPassword); err != nil { + if errors.Is(err, adminapp.ErrInvalidCredentials) || errors.Is(err, adminapp.ErrInvalidPassword) { + response.Error(c, http.StatusBadRequest, "passwordChangeFailed", err.Error()) + return + } + response.Error(c, http.StatusInternalServerError, "passwordChangeFailed", "修改管理员密码失败") + return + } + response.Success(c, http.StatusOK, gin.H{"passwordChanged": true}) +} + +func newAdminResponse(value admindomain.Admin) adminResponse { + return adminResponse{ID: value.ID, Username: value.Username} +} + +func newTokenResponse(value adminapp.Tokens) tokenResponse { + return tokenResponse{AccessToken: value.AccessToken, AccessTokenExpiresAt: value.AccessTokenExpiresAt.Format(time.RFC3339), RefreshTokenExpiresAt: value.RefreshTokenExpiresAt.Format(time.RFC3339)} +} + +func (h *Handler) setRefreshCookie(c *gin.Context, value adminapp.Tokens) { + maxAge := int(time.Until(value.RefreshTokenExpiresAt).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(refreshCookieName, value.RefreshToken, maxAge, "/api/admin/v1/auth", "", h.secureCookies || c.Request.TLS != nil, true) +} diff --git a/backend/internal/transport/http/adminauth/handler_test.go b/backend/internal/transport/http/adminauth/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c9b180abbb298344ec99464c92c492ddea4b9c50 --- /dev/null +++ b/backend/internal/transport/http/adminauth/handler_test.go @@ -0,0 +1,70 @@ +package adminauth + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + adminapp "github.com/chenyme/grok2api/backend/internal/application/adminauth" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/gin-gonic/gin" +) + +func TestRefreshUsesHTTPOnlyCookieAndDoesNotExposeToken(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "auth.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + service := adminapp.NewService( + relational.NewAdminRepository(database), + relational.NewAdminSessionRepository(database), + security.NewTokenService("12345678901234567890123456789012"), + 15*time.Minute, + 30*24*time.Hour, + ) + if err := service.Bootstrap(ctx, "admin", "password123"); err != nil { + t.Fatal(err) + } + + router := gin.New() + NewHandler(service, true).RegisterPublic(router.Group("/api/admin/v1")) + + login := httptest.NewRecorder() + loginRequest := httptest.NewRequest(http.MethodPost, "/api/admin/v1/auth/login", strings.NewReader(`{"username":"admin","password":"password123"}`)) + loginRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(login, loginRequest) + if login.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", login.Code, login.Body.String()) + } + if strings.Contains(login.Body.String(), `"refreshToken":`) { + t.Fatalf("login response exposed refresh token: %s", login.Body.String()) + } + cookies := login.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != refreshCookieName || !cookies[0].HttpOnly || !cookies[0].Secure { + t.Fatalf("unexpected refresh cookie: %#v", cookies) + } + + refresh := httptest.NewRecorder() + refreshRequest := httptest.NewRequest(http.MethodPost, "/api/admin/v1/auth/refresh", strings.NewReader(`{}`)) + refreshRequest.Header.Set("Content-Type", "application/json") + refreshRequest.AddCookie(cookies[0]) + router.ServeHTTP(refresh, refreshRequest) + if refresh.Code != http.StatusOK { + t.Fatalf("refresh status = %d, body = %s", refresh.Code, refresh.Body.String()) + } + if strings.Contains(refresh.Body.String(), `"refreshToken":`) { + t.Fatalf("refresh response exposed refresh token: %s", refresh.Body.String()) + } +} diff --git a/backend/internal/transport/http/audit/handler.go b/backend/internal/transport/http/audit/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..7161ef98b3e5235b94f2d4c92e7e1ac1faa6bb23 --- /dev/null +++ b/backend/internal/transport/http/audit/handler.go @@ -0,0 +1,355 @@ +package audit + +import ( + "encoding/base64" + "errors" + "net/http" + "strconv" + "time" + "unicode/utf8" + + auditapp "github.com/chenyme/grok2api/backend/internal/application/audit" + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct{ service *auditapp.Service } + +func NewHandler(service *auditapp.Service) *Handler { return &Handler{service: service} } + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/request-audits", h.list) + router.GET("/request-audits/summary", h.summary) + router.GET("/request-audits/:id", h.get) +} + +type auditResponse struct { + ID uint64 `json:"id,string"` + RequestID string `json:"requestId"` + ClientKeyID uint64 `json:"clientKeyId,string"` + ClientKeyName string `json:"clientKeyName,omitempty"` + ModelRouteID uint64 `json:"modelRouteId,string"` + ModelPublicID string `json:"modelPublicId,omitempty"` + ModelUpstreamModel string `json:"modelUpstreamModel,omitempty"` + Provider string `json:"provider"` + Operation string `json:"operation"` + UsageSource string `json:"usageSource"` + AccountID *uint64 `json:"accountId,string,omitempty"` + AccountName string `json:"accountName,omitempty"` + EgressNodeID *uint64 `json:"egressNodeId,string,omitempty"` + EgressNodeName string `json:"egressNodeName,omitempty"` + EgressScope string `json:"egressScope,omitempty"` + EgressMode string `json:"egressMode,omitempty"` + StatusCode int `json:"statusCode"` + Streaming bool `json:"streaming"` + MediaInputImages int64 `json:"mediaInputImages"` + MediaOutputImages int64 `json:"mediaOutputImages"` + MediaOutputSeconds int64 `json:"mediaOutputSeconds"` + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + TotalTokens int64 `json:"totalTokens"` + CostInUSDTicks int64 `json:"costInUsdTicks"` + EstimatedCostInUSDTicks int64 `json:"estimatedCostInUsdTicks"` + PricingModel string `json:"pricingModel,omitempty"` + PricingVersion string `json:"pricingVersion,omitempty"` + Billing *billingBreakdownResponse `json:"billing,omitempty"` + NumSourcesUsed int64 `json:"numSourcesUsed"` + NumServerSideToolsUsed int64 `json:"numServerSideToolsUsed"` + ContextInputTokens int64 `json:"contextInputTokens"` + ContextOutputTokens int64 `json:"contextOutputTokens"` + FirstTokenMS *int64 `json:"firstTokenMs,omitempty"` + OutputTokensPerSecond *float64 `json:"outputTokensPerSecond,omitempty"` + DurationMS int64 `json:"durationMs"` + ErrorCode string `json:"errorCode,omitempty"` + AttemptCount int `json:"attemptCount"` + CreatedAt time.Time `json:"createdAt"` +} + +type billingBreakdownResponse struct { + Source string `json:"source"` + Method string `json:"method"` + Model string `json:"model,omitempty"` + Version string `json:"version,omitempty"` + Tier string `json:"tier,omitempty"` + Components []billingComponentResponse `json:"components"` + TotalInUSDTicks int64 `json:"totalInUsdTicks"` +} + +type billingComponentResponse struct { + Kind string `json:"kind"` + Unit string `json:"unit"` + Quantity int64 `json:"quantity"` + UnitPriceInUSDTicks int64 `json:"unitPriceInUsdTicks"` + SubtotalInUSDTicks int64 `json:"subtotalInUsdTicks"` +} + +type auditAttemptResponse struct { + ID uint64 `json:"id,string"` + Number int `json:"number"` + Source string `json:"source"` + Stage string `json:"stage"` + AccountID *uint64 `json:"accountId,string,omitempty"` + AccountName string `json:"accountName,omitempty"` + Method string `json:"method,omitempty"` + RequestPath string `json:"requestPath,omitempty"` + UpstreamURL string `json:"upstreamUrl,omitempty"` + StartedAt time.Time `json:"startedAt"` + DurationMS int64 `json:"durationMs"` + UpstreamStatusCode *int `json:"upstreamStatusCode,omitempty"` + UpstreamStatus string `json:"upstreamStatus,omitempty"` + ResponseHeaders map[string][]string `json:"responseHeaders"` + ResponseBody string `json:"responseBody"` + ResponseBodyEncoding string `json:"responseBodyEncoding"` + ResponseBodyTruncated bool `json:"responseBodyTruncated"` + TransportError string `json:"transportError,omitempty"` + ErrorChain []auditErrorFrameResponse `json:"errorChain"` +} + +type auditErrorFrameResponse struct { + Type string `json:"type"` + Message string `json:"message"` +} + +type auditDetailResponse struct { + Audit auditResponse `json:"audit"` + Attempts []auditAttemptResponse `json:"attempts"` +} + +func (h *Handler) list(c *gin.Context) { + if c.Query("pagination") == "cursor" { + h.listCursor(c) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + page, pageSize = repository.NormalizePage(page, pageSize, repository.DefaultPageSize) + values, total, err := h.service.List(c.Request.Context(), page, pageSize) + if err != nil { + response.Error(c, http.StatusInternalServerError, "auditListFailed", "读取审计记录失败") + return + } + items := make([]auditResponse, 0, len(values)) + for _, value := range values { + items = append(items, newAuditResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total}) +} + +func (h *Handler) listCursor(c *gin.Context) { + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50")) + _, pageSize = repository.NormalizePage(1, pageSize, repository.DefaultCursorPageSize) + result, err := h.service.ListCursor(c.Request.Context(), c.Query("cursor"), pageSize, c.Query("search"), c.Query("period"), newListFilter(c)) + if errors.Is(err, auditapp.ErrInvalidCursor) { + response.Error(c, http.StatusBadRequest, "invalidCursor", err.Error()) + return + } + if errors.Is(err, auditapp.ErrInvalidFilter) { + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + if errors.Is(err, auditapp.ErrInvalidPeriod) { + response.Error(c, http.StatusBadRequest, "invalidAuditPeriod", "period 仅支持 24h、7d、30d、90d") + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "auditListFailed", "读取审计记录失败") + return + } + items := make([]auditResponse, 0, len(result.Items)) + for _, value := range result.Items { + items = append(items, newAuditResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "pageSize": pageSize, "nextCursor": result.NextCursor, "hasMore": result.HasMore}) +} + +func (h *Handler) get(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", "审计 ID 无效") + return + } + value, err := h.service.Get(c.Request.Context(), id) + if errors.Is(err, repository.ErrNotFound) { + response.Error(c, http.StatusNotFound, "auditNotFound", "审计记录不存在") + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "auditDetailFailed", "读取审计详情失败") + return + } + attempts := make([]auditAttemptResponse, 0, len(value.Attempts)) + for _, attempt := range value.Attempts { + body := string(attempt.ResponseBody) + encoding := "utf8" + if !utf8.Valid(attempt.ResponseBody) { + body = base64.StdEncoding.EncodeToString(attempt.ResponseBody) + encoding = "base64" + } + errorChain := make([]auditErrorFrameResponse, 0, len(attempt.ErrorChain)) + for _, frame := range attempt.ErrorChain { + errorChain = append(errorChain, auditErrorFrameResponse{Type: frame.Type, Message: frame.Message}) + } + attempts = append(attempts, auditAttemptResponse{ + ID: attempt.ID, Number: attempt.Number, Source: string(attempt.Source), Stage: attempt.Stage, + AccountID: attempt.AccountID, AccountName: attempt.AccountName, Method: attempt.Method, RequestPath: attempt.RequestPath, + UpstreamURL: attempt.UpstreamURL, StartedAt: attempt.StartedAt, DurationMS: attempt.DurationMS, + UpstreamStatusCode: attempt.UpstreamStatusCode, UpstreamStatus: attempt.UpstreamStatus, + ResponseHeaders: attempt.ResponseHeaders, ResponseBody: body, ResponseBodyEncoding: encoding, + ResponseBodyTruncated: attempt.ResponseBodyTruncated, + TransportError: attempt.TransportError, ErrorChain: errorChain, + }) + } + response.Success(c, http.StatusOK, auditDetailResponse{Audit: newAuditResponse(value), Attempts: attempts}) +} + +type summaryResponse struct { + Period string `json:"period"` + GeneratedAt time.Time `json:"generatedAt"` + Range summaryRangeResponse `json:"range"` + Usage summaryUsageResponse `json:"usage"` + Pricing pricingResponse `json:"pricing"` +} + +type summaryRangeResponse struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +type summaryUsageResponse struct { + Requests int64 `json:"requests"` + SuccessfulRequests int64 `json:"successfulRequests"` + FailedRequests int64 `json:"failedRequests"` + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + TotalTokens int64 `json:"totalTokens"` + AverageDurationMS float64 `json:"averageDurationMs"` + SuccessRate float64 `json:"successRate"` + EstimatedCostInUSDTicks int64 `json:"estimatedCostInUsdTicks"` +} + +type pricingResponse struct { + Source string `json:"source"` + AsOf string `json:"asOf"` + PricedRequests int64 `json:"pricedRequests"` + UnpricedRequests int64 `json:"unpricedRequests"` + PricedTokens int64 `json:"pricedTokens"` + UnpricedTokens int64 `json:"unpricedTokens"` +} + +func (h *Handler) summary(c *gin.Context) { + load := h.service.Summary + if c.Query("refresh") == "1" { + load = h.service.SummaryFresh + } + result, err := load(c.Request.Context(), c.Query("search"), c.Query("period"), newListFilter(c)) + if errors.Is(err, auditapp.ErrInvalidFilter) { + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + if errors.Is(err, auditapp.ErrInvalidPeriod) { + response.Error(c, http.StatusBadRequest, "invalidAuditPeriod", "period 仅支持 24h、7d、30d、90d") + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "auditSummaryFailed", "读取审计统计失败") + return + } + response.Success(c, http.StatusOK, summaryResponse{ + Period: string(result.Period), GeneratedAt: result.GeneratedAt, Range: summaryRangeResponse{Start: result.Start, End: result.End}, + Usage: summaryUsageResponse{ + Requests: result.Usage.Requests, SuccessfulRequests: result.Usage.SuccessfulRequests, FailedRequests: result.Usage.FailedRequests, + InputTokens: result.Usage.InputTokens, CachedInputTokens: result.Usage.CachedInputTokens, OutputTokens: result.Usage.OutputTokens, + ReasoningTokens: result.Usage.ReasoningTokens, TotalTokens: result.Usage.TotalTokens, AverageDurationMS: result.Usage.AverageDurationMS, + SuccessRate: result.Usage.SuccessRate, EstimatedCostInUSDTicks: result.Usage.EstimatedCostInUSDTicks, + }, + Pricing: pricingResponse{ + Source: auditdomain.OfficialPricingSource, AsOf: auditdomain.OfficialPricingAsOf, + PricedRequests: result.Usage.PricedRequests, UnpricedRequests: result.Usage.UnpricedRequests, + PricedTokens: result.Usage.PricedTokens, UnpricedTokens: result.Usage.UnpricedTokens, + }, + }) +} + +func newListFilter(c *gin.Context) auditapp.ListFilter { + return auditapp.ListFilter{ + Model: c.Query("model"), Status: c.Query("status"), Mode: c.Query("mode"), + Key: c.Query("key"), Account: c.Query("account"), + Sort: repository.SortQuery{Field: c.Query("sortBy"), Direction: repository.SortDirection(c.Query("sortOrder"))}, + } +} + +func newAuditResponse(value auditdomain.Record) auditResponse { + return auditResponse{ + ID: value.ID, RequestID: value.RequestID, ClientKeyID: value.ClientKeyID, ClientKeyName: value.ClientKeyName, + ModelRouteID: value.ModelRouteID, ModelPublicID: value.ModelPublicID, ModelUpstreamModel: value.ModelUpstreamModel, + Provider: value.Provider, Operation: string(value.Operation), UsageSource: string(value.UsageSource), + AccountID: value.AccountID, AccountName: value.AccountName, + EgressNodeID: value.EgressNodeID, EgressNodeName: value.EgressNodeName, EgressScope: value.EgressScope, EgressMode: string(value.EgressMode), + StatusCode: value.StatusCode, Streaming: value.Streaming, + MediaInputImages: value.MediaInputImages, MediaOutputImages: value.MediaOutputImages, MediaOutputSeconds: value.MediaOutputSeconds, + InputTokens: value.InputTokens, CachedInputTokens: value.CachedInputTokens, OutputTokens: value.OutputTokens, + ReasoningTokens: value.ReasoningTokens, TotalTokens: value.TotalTokens, CostInUSDTicks: value.CostInUSDTicks, + EstimatedCostInUSDTicks: value.EstimatedCostInUSDTicks, PricingModel: value.PricingModel, PricingVersion: value.PricingVersion, + Billing: newBillingBreakdown(value), + NumSourcesUsed: value.NumSourcesUsed, NumServerSideToolsUsed: value.NumServerSideToolsUsed, + ContextInputTokens: value.ContextInputTokens, ContextOutputTokens: value.ContextOutputTokens, + FirstTokenMS: value.FirstTokenMS, OutputTokensPerSecond: auditOutputTokensPerSecond(value), DurationMS: value.DurationMS, + ErrorCode: value.ErrorCode, AttemptCount: value.AttemptCount, CreatedAt: value.CreatedAt, + } +} + +func newBillingBreakdown(value auditdomain.Record) *billingBreakdownResponse { + if value.CostInUSDTicks > 0 { + return &billingBreakdownResponse{ + Source: "upstream", Method: "upstream_reported", Components: []billingComponentResponse{}, TotalInUSDTicks: value.CostInUSDTicks, + } + } + if value.PricingModel == "" { + return nil + } + breakdown := &billingBreakdownResponse{ + Source: "official", Method: "stored_estimate", Model: value.PricingModel, Version: value.PricingVersion, + Components: []billingComponentResponse{}, TotalInUSDTicks: value.EstimatedCostInUSDTicks, + } + if value.PricingVersion != auditdomain.OfficialPricingAsOf { + return breakdown + } + pricing, ok := auditdomain.ReconstructOfficialCost( + value.PricingModel, + value.InputTokens, + value.CachedInputTokens, + value.OutputTokens, + value.ContextInputTokens, + value.MediaInputImages, + value.MediaOutputImages, + value.MediaOutputSeconds, + ) + if !ok || pricing.CostInUSDTicks != value.EstimatedCostInUSDTicks { + return breakdown + } + breakdown.Method = "official_rates" + breakdown.Model = pricing.Model + breakdown.Tier = string(pricing.Tier) + breakdown.Components = make([]billingComponentResponse, 0, len(pricing.Components)) + for _, component := range pricing.Components { + breakdown.Components = append(breakdown.Components, billingComponentResponse{ + Kind: string(component.Kind), Unit: string(component.Unit), Quantity: component.Quantity, + UnitPriceInUSDTicks: component.UnitPriceInUSDTicks, SubtotalInUSDTicks: component.CostInUSDTicks, + }) + } + return breakdown +} + +func auditOutputTokensPerSecond(value auditdomain.Record) *float64 { + if !value.Streaming || value.StatusCode < 200 || value.StatusCode >= 300 || value.ErrorCode != "" || value.FirstTokenMS == nil || value.OutputTokens <= 0 || value.DurationMS <= *value.FirstTokenMS { + return nil + } + throughput := float64(value.OutputTokens) * 1000 / float64(value.DurationMS-*value.FirstTokenMS) + return &throughput +} diff --git a/backend/internal/transport/http/audit/handler_test.go b/backend/internal/transport/http/audit/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..44d6cf00870f94f4a147ee59a8cb8d29f336f74c --- /dev/null +++ b/backend/internal/transport/http/audit/handler_test.go @@ -0,0 +1,128 @@ +package audit + +import ( + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + auditapp "github.com/chenyme/grok2api/backend/internal/application/audit" + auditdomain "github.com/chenyme/grok2api/backend/internal/domain/audit" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/gin-gonic/gin" +) + +func TestAuditDetailReturnsCompleteTextAndBinaryBodies(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "audit-handler.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repository := relational.NewAuditRepository(database) + now := time.Now().UTC() + status := http.StatusBadGateway + if err := repository.Create(ctx, auditdomain.Record{ + EventID: "evt_audit_handler_0001", RequestID: "request-detail", ClientKeyID: 1, ModelRouteID: 1, StatusCode: status, CreatedAt: now, + Attempts: []auditdomain.Attempt{ + {Number: 1, Source: auditdomain.AttemptSourceUpstreamHTTP, Stage: "upstream_response", StartedAt: now, UpstreamStatusCode: &status, ResponseHeaders: http.Header{"Content-Type": {"application/json"}}, ResponseBody: []byte(`{"error":"complete"}`), ResponseBodyTruncated: true}, + {Number: 2, Source: auditdomain.AttemptSourceUpstreamHTTP, Stage: "upstream_response", StartedAt: now, UpstreamStatusCode: &status, ResponseHeaders: http.Header{}, ResponseBody: []byte{0x00, 0xff, 0x01}}, + }, + }); err != nil { + t.Fatal(err) + } + service := auditapp.NewService(repository, slog.Default(), 8, 4, time.Second) + router := gin.New() + NewHandler(service).Register(router.Group("/api/admin/v1")) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/admin/v1/request-audits/1", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var payload struct { + Data struct { + Audit struct { + AttemptCount int `json:"attemptCount"` + } `json:"audit"` + Attempts []struct { + ResponseBody string `json:"responseBody"` + ResponseBodyEncoding string `json:"responseBodyEncoding"` + ResponseBodyTruncated bool `json:"responseBodyTruncated"` + } `json:"attempts"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.Audit.AttemptCount != 2 || len(payload.Data.Attempts) != 2 { + t.Fatalf("payload = %#v", payload) + } + if payload.Data.Attempts[0].ResponseBodyEncoding != "utf8" || payload.Data.Attempts[0].ResponseBody != `{"error":"complete"}` || !payload.Data.Attempts[0].ResponseBodyTruncated { + t.Fatalf("text body = %#v", payload.Data.Attempts[0]) + } + if payload.Data.Attempts[1].ResponseBodyEncoding != "base64" || payload.Data.Attempts[1].ResponseBody != base64.StdEncoding.EncodeToString([]byte{0x00, 0xff, 0x01}) { + t.Fatalf("binary body = %#v", payload.Data.Attempts[1]) + } + + missing := httptest.NewRecorder() + router.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/admin/v1/request-audits/999", nil)) + if missing.Code != http.StatusNotFound { + t.Fatalf("missing status = %d, body = %s", missing.Code, missing.Body.String()) + } +} + +func TestAuditResponseDerivesOutputThroughput(t *testing.T) { + firstTokenMS := int64(250) + response := newAuditResponse(auditdomain.Record{StatusCode: http.StatusOK, Streaming: true, FirstTokenMS: &firstTokenMS, DurationMS: 1250, OutputTokens: 80}) + if response.FirstTokenMS == nil || *response.FirstTokenMS != 250 || response.OutputTokensPerSecond == nil || *response.OutputTokensPerSecond != 80 { + t.Fatalf("response = %#v", response) + } + + unmeasured := newAuditResponse(auditdomain.Record{DurationMS: 1250, OutputTokens: 80}) + if unmeasured.OutputTokensPerSecond != nil { + t.Fatalf("unmeasured throughput = %v", *unmeasured.OutputTokensPerSecond) + } +} + +func TestAuditResponseExplainsBillingWithoutChangingStoredTotal(t *testing.T) { + estimated := newAuditResponse(auditdomain.Record{ + InputTokens: 100, CachedInputTokens: 20, OutputTokens: 50, ContextInputTokens: 100, + EstimatedCostInUSDTicks: 1_840_000, PricingModel: "grok-build-0.1", PricingVersion: auditdomain.OfficialPricingAsOf, + }) + if estimated.Billing == nil || estimated.Billing.Source != "official" || estimated.Billing.Method != "official_rates" || estimated.Billing.TotalInUSDTicks != 1_840_000 || len(estimated.Billing.Components) != 3 { + t.Fatalf("estimated billing = %#v", estimated.Billing) + } + if estimated.Billing.Components[0].Kind != "uncached_input" || estimated.Billing.Components[1].Kind != "output" || estimated.Billing.Components[2].Kind != "cached_input" { + t.Fatalf("billing component order = %#v", estimated.Billing.Components) + } + var componentTotal int64 + for _, component := range estimated.Billing.Components { + componentTotal += component.SubtotalInUSDTicks + } + if componentTotal != estimated.Billing.TotalInUSDTicks { + t.Fatalf("component total = %d, billing = %#v", componentTotal, estimated.Billing) + } + + upstream := newAuditResponse(auditdomain.Record{CostInUSDTicks: 2_500_000}) + if upstream.Billing == nil || upstream.Billing.Source != "upstream" || upstream.Billing.Method != "upstream_reported" || upstream.Billing.TotalInUSDTicks != 2_500_000 || len(upstream.Billing.Components) != 0 { + t.Fatalf("upstream billing = %#v", upstream.Billing) + } + + historical := newAuditResponse(auditdomain.Record{ + InputTokens: 100, CachedInputTokens: 20, OutputTokens: 50, + EstimatedCostInUSDTicks: 1_840_000, PricingModel: "grok-build-0.1", PricingVersion: "2026-07-11", + }) + if historical.Billing == nil || historical.Billing.Method != "stored_estimate" || len(historical.Billing.Components) != 0 || historical.Billing.TotalInUSDTicks != 1_840_000 { + t.Fatalf("historical billing = %#v", historical.Billing) + } +} diff --git a/backend/internal/transport/http/clientkey/handler.go b/backend/internal/transport/http/clientkey/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..1a6d160e9db2bf6952cf77bf183801ab03028e51 --- /dev/null +++ b/backend/internal/transport/http/clientkey/handler.go @@ -0,0 +1,368 @@ +package clientkey + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct{ service *clientkeyapp.Service } + +func NewHandler(service *clientkeyapp.Service) *Handler { return &Handler{service: service} } + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/client-keys", h.list) + router.POST("/client-keys", h.create) + router.PATCH("/client-keys/batch", h.batchUpdate) + router.DELETE("/client-keys", h.batchDelete) + router.GET("/client-keys/:id/secret", h.revealSecret) + router.PATCH("/client-keys/:id", h.update) + router.DELETE("/client-keys/:id", h.delete) +} + +type createRequest struct { + Name string `json:"name" binding:"required"` + Enabled *bool `json:"enabled"` + ExpiresAt string `json:"expiresAt"` + RPMLimit *int `json:"rpmLimit"` + MaxConcurrent *int `json:"maxConcurrent"` + BillingLimitUSDTicks int64 `json:"billingLimitUsdTicks"` + AllowModelAliases *bool `json:"allowModelAliases"` + AllowedModelIDs []string `json:"allowedModelIds"` + ProviderScope *[]string `json:"providerScope"` + TierScope *[]string `json:"tierScope"` + AccountPool *string `json:"accountPool"` +} + +type updateRequest struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + ExpiresAt *string `json:"expiresAt"` + RPMLimit *int `json:"rpmLimit"` + MaxConcurrent *int `json:"maxConcurrent"` + BillingLimitUSDTicks *int64 `json:"billingLimitUsdTicks"` + AllowModelAliases *bool `json:"allowModelAliases"` + AllowedModelIDs *[]string `json:"allowedModelIds"` + ProviderScope *[]string `json:"providerScope"` + TierScope *[]string `json:"tierScope"` + AccountPool *string `json:"accountPool"` +} + +type batchUpdateRequest struct { + IDs []string `json:"ids" binding:"required"` + Enabled bool `json:"enabled"` +} + +type batchDeleteRequest struct { + IDs []string `json:"ids" binding:"required"` +} + +type keyResponse struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Enabled bool `json:"enabled"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + RPMLimit int `json:"rpmLimit"` + MaxConcurrent int `json:"maxConcurrent"` + BillingLimitUSDTicks int64 `json:"billingLimitUsdTicks"` + BilledUsageUSDTicks int64 `json:"billedUsageUsdTicks"` + AllowModelAliases bool `json:"allowModelAliases"` + AllowedModelIDs []string `json:"allowedModelIds"` + ProviderScope []string `json:"providerScope"` + TierScope []string `json:"tierScope"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` +} + +func (h *Handler) list(c *gin.Context) { + page, pageSize := pagination(c) + values, total, err := h.service.List(c.Request.Context(), page, pageSize, c.Query("search"), clientkeyapp.ListFilter{Status: c.Query("status"), ModelScope: c.Query("modelScope"), Sort: repository.SortQuery{Field: c.Query("sortBy"), Direction: repository.SortDirection(c.Query("sortOrder"))}}) + if errors.Is(err, clientkeyapp.ErrInvalidFilter) { + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "clientKeyListFailed", "读取客户端 Key 失败") + return + } + items := make([]keyResponse, 0, len(values)) + for _, value := range values { + items = append(items, newKeyResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total}) +} + +func (h *Handler) batchUpdate(c *gin.Context) { + var request batchUpdateRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + updated, err := h.service.BatchSetEnabled(c.Request.Context(), ids, request.Enabled) + if err != nil { + h.writeServiceError(c, "clientKeyBatchUpdateFailed", err) + return + } + response.Success(c, http.StatusOK, gin.H{"updated": updated}) +} + +func (h *Handler) batchDelete(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + deleted, err := h.service.BatchDelete(c.Request.Context(), ids) + if err != nil { + h.writeServiceError(c, "clientKeyBatchDeleteFailed", err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": deleted}) +} + +func (h *Handler) create(c *gin.Context) { + var request createRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + expiresAt, err := parseTime(request.ExpiresAt) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidExpiresAt", "expiresAt 必须是 RFC3339 时间") + return + } + modelIDs, err := parseIDs(request.AllowedModelIDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidModelId", "allowedModelIds 包含无效 ID") + return + } + enabled := true + if request.Enabled != nil { + enabled = *request.Enabled + } + providerScope, tierScope, err := parseRequestedScopes(request.ProviderScope, request.TierScope, request.AccountPool) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidAccountScope", err.Error()) + return + } + input := clientkeyapp.CreateInput{Name: request.Name, Enabled: enabled, ExpiresAt: expiresAt, BillingLimitUSDTicks: request.BillingLimitUSDTicks, AllowedModels: modelIDs} + if providerScope != nil { + input.ProviderScope = *providerScope + } + if tierScope != nil { + input.TierScope = *tierScope + } + if request.AllowModelAliases != nil { + input.AllowModelAliases = *request.AllowModelAliases + } + if request.RPMLimit != nil { + input.RPMLimit = *request.RPMLimit + input.RPMUnlimited = *request.RPMLimit == 0 + } + if request.MaxConcurrent != nil { + input.MaxConcurrent = *request.MaxConcurrent + input.ConcurrencyUnlimited = *request.MaxConcurrent == 0 + } + created, err := h.service.Create(c.Request.Context(), input) + if err != nil { + h.writeServiceError(c, "clientKeyCreateFailed", err) + return + } + response.Success(c, http.StatusCreated, gin.H{"key": newKeyResponse(created.Key), "secret": created.Secret}) +} + +func (h *Handler) update(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var request updateRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + providerScope, tierScope, err := parseRequestedScopes(request.ProviderScope, request.TierScope, request.AccountPool) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidAccountScope", err.Error()) + return + } + input := clientkeyapp.UpdateInput{Name: request.Name, Enabled: request.Enabled, RPMLimit: request.RPMLimit, MaxConcurrent: request.MaxConcurrent, BillingLimitUSDTicks: request.BillingLimitUSDTicks, AllowModelAliases: request.AllowModelAliases, ProviderScope: providerScope, TierScope: tierScope} + if request.ExpiresAt != nil { + if *request.ExpiresAt == "" { + input.ClearExpiresAt = true + } else { + expiresAt, err := parseTime(*request.ExpiresAt) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidExpiresAt", "expiresAt 必须是 RFC3339 时间") + return + } + input.ExpiresAt = expiresAt + } + } + if request.AllowedModelIDs != nil { + ids, err := parseIDs(*request.AllowedModelIDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidModelId", "allowedModelIds 包含无效 ID") + return + } + input.AllowedModels = &ids + } + value, err := h.service.Update(c.Request.Context(), id, input) + if err != nil { + h.writeServiceError(c, "clientKeyUpdateFailed", err) + return + } + response.Success(c, http.StatusOK, newKeyResponse(value)) +} + +func (h *Handler) revealSecret(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + secret, err := h.service.RevealSecret(c.Request.Context(), id) + if err != nil { + h.writeServiceError(c, "clientKeySecretReadFailed", err) + return + } + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + response.Success(c, http.StatusOK, gin.H{"secret": secret}) +} + +func (h *Handler) delete(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "clientKeyDeleteFailed", err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": true}) +} + +// writeServiceError 仅暴露明确的客户端 Key 业务错误,避免泄露持久化细节。 +func (h *Handler) writeServiceError(c *gin.Context, code string, err error) { + switch { + case errors.Is(err, clientkeyapp.ErrInvalidInput): + response.Error(c, http.StatusBadRequest, code, err.Error()) + case errors.Is(err, clientkeyapp.ErrNotFound): + response.Error(c, http.StatusNotFound, "clientKeyNotFound", err.Error()) + case errors.Is(err, clientkeyapp.ErrConflict): + response.Error(c, http.StatusConflict, "clientKeyConflict", err.Error()) + case errors.Is(err, clientkeyapp.ErrSecretUnavailable): + response.Error(c, http.StatusConflict, "clientKeySecretUnavailable", err.Error()) + default: + response.Error(c, http.StatusInternalServerError, code, "客户端 Key 操作失败") + } +} + +func newKeyResponse(value clientkeydomain.Key) keyResponse { + ids := make([]string, 0, len(value.AllowedModels)) + for _, id := range value.AllowedModels { + ids = append(ids, strconv.FormatUint(id, 10)) + } + return keyResponse{ + ID: value.ID, Name: value.Name, Prefix: value.Prefix, Enabled: value.Enabled, ExpiresAt: value.ExpiresAt, + RPMLimit: value.RPMLimit, MaxConcurrent: value.MaxConcurrent, BillingLimitUSDTicks: value.BillingLimitUSDTicks, + BilledUsageUSDTicks: value.BilledUsageUSDTicks, AllowModelAliases: value.AllowModelAliases, AllowedModelIDs: ids, + ProviderScope: value.ProviderScope.Values(), TierScope: value.TierScope.Values(), LastUsedAt: value.LastUsedAt, + } +} + +func parseRequestedScopes(providerValues, tierValues *[]string, legacyPool *string) (*clientkeydomain.ProviderScope, *clientkeydomain.TierScope, error) { + if legacyPool != nil && (providerValues != nil || tierValues != nil) { + return nil, nil, errors.New("accountPool 不能与 providerScope 或 tierScope 同时设置") + } + if legacyPool != nil { + providers := clientkeydomain.ProviderScopeAll + var tiers clientkeydomain.TierScope + switch strings.TrimSpace(*legacyPool) { + case "all": + tiers = clientkeydomain.TierScopeAll + case "free": + tiers = clientkeydomain.TierScopeFree + case "super": + tiers = clientkeydomain.TierScopeSuper + default: + return nil, nil, errors.New("accountPool 必须是 all、free 或 super") + } + return &providers, &tiers, nil + } + var providers *clientkeydomain.ProviderScope + if providerValues != nil { + value, valid := clientkeydomain.ParseProviderScopeValues(*providerValues) + if !valid { + return nil, nil, errors.New("providerScope 必须是 all,或 grok_build、grok_web、grok_console 的组合") + } + providers = &value + } + var tiers *clientkeydomain.TierScope + if tierValues != nil { + value, valid := clientkeydomain.ParseTierScopeValues(*tierValues) + if !valid { + return nil, nil, errors.New("tierScope 必须是 all,或 free、super 的组合") + } + tiers = &value + } + return providers, tiers, nil +} + +func parseTime(value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, err + } + parsed = parsed.UTC() + return &parsed, nil +} + +func parseIDs(values []string) ([]uint64, error) { + result := make([]uint64, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return nil, fmt.Errorf("无效模型 ID: %s", value) + } + result = append(result, id) + } + return result, nil +} + +func pathID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", "ID 无效") + return 0, false + } + return id, true +} + +func pagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + return repository.NormalizePage(page, size, repository.DefaultPageSize) +} diff --git a/backend/internal/transport/http/clientkey/handler_test.go b/backend/internal/transport/http/clientkey/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7403d37da385449a87905445d4b0902d7f9bbe56 --- /dev/null +++ b/backend/internal/transport/http/clientkey/handler_test.go @@ -0,0 +1,101 @@ +package clientkey + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/gin-gonic/gin" +) + +func TestCreateDistinguishesOmittedLimitsFromExplicitZero(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "client-key-handler.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + service := clientkeyapp.NewService(relational.NewClientKeyRepository(database), nil, nil, 60, 5, cipher) + router := gin.New() + NewHandler(service).Register(router.Group("/api")) + + assertCreate := func(body string) { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/api/client-keys", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("create response = %d %s", response.Code, response.Body.String()) + } + } + assertCreate(`{"name":"defaults"}`) + assertCreate(`{"name":"unlimited","rpmLimit":0,"maxConcurrent":0}`) + assertCreate(`{"name":"free-pool","accountPool":"free"}`) + assertCreate(`{"name":"mixed-scope","providerScope":["grok_build","grok_web"],"tierScope":["free","super"]}`) + + defaults, total, err := service.List(ctx, 1, 20, "defaults", clientkeyapp.ListFilter{}) + if err != nil || total != 1 || len(defaults) != 1 { + t.Fatalf("default key list = %#v, total = %d, err = %v", defaults, total, err) + } + if defaults[0].RPMLimit != 60 || defaults[0].MaxConcurrent != 5 { + t.Fatalf("omitted limits = rpm %d, concurrency %d", defaults[0].RPMLimit, defaults[0].MaxConcurrent) + } + unlimited, total, err := service.List(ctx, 1, 20, "unlimited", clientkeyapp.ListFilter{}) + if err != nil || total != 1 || len(unlimited) != 1 { + t.Fatalf("unlimited key list = %#v, total = %d, err = %v", unlimited, total, err) + } + if unlimited[0].RPMLimit != 0 || unlimited[0].MaxConcurrent != 0 { + t.Fatalf("explicit zero limits = rpm %d, concurrency %d", unlimited[0].RPMLimit, unlimited[0].MaxConcurrent) + } + freePool, total, err := service.List(ctx, 1, 20, "free-pool", clientkeyapp.ListFilter{}) + if err != nil || total != 1 || len(freePool) != 1 || freePool[0].ProviderScope != 7 || freePool[0].TierScope != 1 { + t.Fatalf("free-pool key list = %#v, total = %d, err = %v", freePool, total, err) + } + mixedScope, total, err := service.List(ctx, 1, 20, "mixed-scope", clientkeyapp.ListFilter{}) + if err != nil || total != 1 || len(mixedScope) != 1 || mixedScope[0].ProviderScope != 3 || mixedScope[0].TierScope != 3 { + t.Fatalf("mixed-scope key list = %#v, total = %d, err = %v", mixedScope, total, err) + } + request := httptest.NewRequest(http.MethodPost, "/api/client-keys", bytes.NewBufferString(`{"name":"invalid-pool","accountPool":"unknown"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("invalid pool response = %d %s", response.Code, response.Body.String()) + } + request = httptest.NewRequest(http.MethodPost, "/api/client-keys", bytes.NewBufferString(`{"name":"ambiguous","accountPool":"free","tierScope":["super"]}`)) + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("ambiguous scope response = %d %s", response.Code, response.Body.String()) + } + for _, body := range []string{ + `{"name":"empty-providers","providerScope":[]}`, + `{"name":"empty-tiers","tierScope":[]}`, + `{"name":"empty-legacy-pool","accountPool":""}`, + } { + request = httptest.NewRequest(http.MethodPost, "/api/client-keys", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("empty scope response = %d %s", response.Code, response.Body.String()) + } + } +} diff --git a/backend/internal/transport/http/dashboard/handler.go b/backend/internal/transport/http/dashboard/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..5e12ee5edf9600484d67b7ad03fbe54395b891c8 --- /dev/null +++ b/backend/internal/transport/http/dashboard/handler.go @@ -0,0 +1,193 @@ +package dashboard + +import ( + "errors" + "net/http" + "time" + + dashboardapp "github.com/chenyme/grok2api/backend/internal/application/dashboard" + dashboarddomain "github.com/chenyme/grok2api/backend/internal/domain/dashboard" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct{ service *dashboardapp.Service } + +func NewHandler(service *dashboardapp.Service) *Handler { return &Handler{service: service} } + +func (h *Handler) Register(router *gin.RouterGroup) { router.GET("/dashboard", h.get) } + +type responseDTO struct { + Period string `json:"period"` + GeneratedAt time.Time `json:"generatedAt"` + Range rangeDTO `json:"range"` + Resources resourcesDTO `json:"resources"` + Usage usageDTO `json:"usage"` + Series []seriesDTO `json:"series"` + Activity []activityDTO `json:"activity"` + TopModels []modelUsageDTO `json:"topModels"` + Providers []providerUsageDTO `json:"providers"` +} + +type rangeDTO struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +type resourcesDTO struct { + ActiveAccounts int64 `json:"activeAccounts"` + TotalAccounts int64 `json:"totalAccounts"` + BuildAccounts int64 `json:"buildAccounts"` + WebAccounts int64 `json:"webAccounts"` + ConsoleAccounts int64 `json:"consoleAccounts"` + EnabledModels int64 `json:"enabledModels"` + TotalModels int64 `json:"totalModels"` + ActiveClientKeys int64 `json:"activeClientKeys"` + TotalClientKeys int64 `json:"totalClientKeys"` +} + +type usageDTO struct { + Requests int64 `json:"requests"` + SuccessfulRequests int64 `json:"successfulRequests"` + FailedRequests int64 `json:"failedRequests"` + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + Tokens int64 `json:"tokens"` + BilledCostUSDTicks int64 `json:"billedCostUsdTicks"` + SuccessRate float64 `json:"successRate"` + AverageFirstTokenMS float64 `json:"averageFirstTokenMs"` + OutputTokensPerSecond float64 `json:"outputTokensPerSecond"` + FirstTokenSamples int64 `json:"firstTokenSamples"` + ThroughputSamples int64 `json:"throughputSamples"` +} + +type seriesDTO struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + Tokens int64 `json:"tokens"` + BilledCostUSDTicks int64 `json:"billedCostUsdTicks"` +} + +type activityDTO struct { + Start time.Time `json:"start"` + Requests int64 `json:"requests"` +} + +type providerUsageDTO struct { + Provider string `json:"provider"` + Requests int64 `json:"requests"` + SuccessfulRequests int64 `json:"successfulRequests"` + Tokens int64 `json:"tokens"` +} + +type modelUsageDTO struct { + Model string `json:"model"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + Tokens int64 `json:"tokens"` + BilledCostUSDTicks int64 `json:"billedCostUsdTicks"` +} + +func (h *Handler) get(c *gin.Context) { + load := h.service.Get + if c.Query("refresh") == "1" { + load = h.service.Refresh + } + result, err := load(c.Request.Context(), c.Query("period"), c.Query("timezone")) + if errors.Is(err, dashboardapp.ErrInvalidPeriod) { + response.Error(c, http.StatusBadRequest, "invalidDashboardPeriod", "period 仅支持 24h、7d、30d、90d") + return + } + if errors.Is(err, dashboardapp.ErrInvalidTimezone) { + response.Error(c, http.StatusBadRequest, "invalidDashboardTimezone", "timezone 必须是有效的 IANA 时区") + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "dashboardLoadFailed", "读取 Dashboard 失败") + return + } + series := make([]seriesDTO, 0, len(result.Series)) + for _, point := range result.Series { + series = append(series, seriesDTO{Start: point.Start, End: point.End, Requests: point.Requests, InputTokens: point.InputTokens, CachedInputTokens: point.CachedInputTokens, OutputTokens: point.OutputTokens, ReasoningTokens: point.ReasoningTokens, Tokens: point.Tokens, BilledCostUSDTicks: point.BilledCostUSDTicks}) + } + activity := make([]activityDTO, 0, len(result.Activity)) + for _, point := range result.Activity { + activity = append(activity, activityDTO{Start: point.Start, Requests: point.Requests}) + } + topModels := make([]modelUsageDTO, 0, len(result.TopModels)) + for _, item := range result.TopModels { + topModels = append(topModels, modelUsageDTO{Model: item.Model, Requests: item.Requests, InputTokens: item.InputTokens, CachedInputTokens: item.CachedInputTokens, OutputTokens: item.OutputTokens, ReasoningTokens: item.ReasoningTokens, Tokens: item.Tokens, BilledCostUSDTicks: item.BilledCostUSDTicks}) + } + providers := make([]providerUsageDTO, 0, len(result.Providers)) + for _, item := range result.Providers { + providers = append(providers, providerUsageDTO{Provider: item.Provider, Requests: item.Requests, SuccessfulRequests: item.SuccessfulRequests, Tokens: item.Tokens}) + } + response.Success(c, http.StatusOK, responseDTO{ + Period: string(result.Period), + GeneratedAt: result.GeneratedAt, + Range: rangeDTO{Start: result.Range.Start, End: result.Range.End}, + Resources: resourcesDTO{ + ActiveAccounts: result.Resources.ActiveAccounts, + TotalAccounts: result.Resources.TotalAccounts, + BuildAccounts: result.Resources.BuildAccounts, + WebAccounts: result.Resources.WebAccounts, + ConsoleAccounts: result.Resources.ConsoleAccounts, + EnabledModels: result.Resources.EnabledModels, + TotalModels: result.Resources.TotalModels, + ActiveClientKeys: result.Resources.ActiveClientKeys, + TotalClientKeys: result.Resources.TotalClientKeys, + }, + Usage: toUsageDTO(result.Usage), + Series: series, + Activity: activity, + TopModels: topModels, + Providers: providers, + }) +} + +func toUsageDTO(usage dashboarddomain.Usage) usageDTO { + successRate := 0.0 + if usage.Requests > 0 { + successRate = float64(usage.SuccessfulRequests) / float64(usage.Requests) * 100 + } + return usageDTO{ + Requests: usage.Requests, + SuccessfulRequests: usage.SuccessfulRequests, + FailedRequests: usage.FailedRequests, + InputTokens: usage.InputTokens, + CachedInputTokens: usage.CachedInputTokens, + OutputTokens: usage.OutputTokens, + ReasoningTokens: usage.ReasoningTokens, + Tokens: usage.Tokens, + BilledCostUSDTicks: usage.BilledCostUSDTicks, + SuccessRate: successRate, + AverageFirstTokenMS: averageFirstTokenMS(usage), + OutputTokensPerSecond: outputTokensPerSecond(usage), + FirstTokenSamples: usage.FirstTokenSamples, + ThroughputSamples: usage.ThroughputSamples, + } +} + +func averageFirstTokenMS(usage dashboarddomain.Usage) float64 { + if usage.FirstTokenSamples <= 0 { + return 0 + } + return float64(usage.FirstTokenTotalMS) / float64(usage.FirstTokenSamples) +} + +func outputTokensPerSecond(usage dashboarddomain.Usage) float64 { + if usage.ThroughputTokens <= 0 || usage.GenerationTotalMS <= 0 { + return 0 + } + return float64(usage.ThroughputTokens) * 1000 / float64(usage.GenerationTotalMS) +} diff --git a/backend/internal/transport/http/dashboard/handler_test.go b/backend/internal/transport/http/dashboard/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..910ef282af2940116f0caa15cd79080f104c948d --- /dev/null +++ b/backend/internal/transport/http/dashboard/handler_test.go @@ -0,0 +1,85 @@ +package dashboard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + dashboardapp "github.com/chenyme/grok2api/backend/internal/application/dashboard" + dashboarddomain "github.com/chenyme/grok2api/backend/internal/domain/dashboard" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/gin-gonic/gin" +) + +func TestHandlerReturnsDashboardContract(t *testing.T) { + gin.SetMode(gin.TestMode) + service := dashboardapp.NewService(&dashboardRepositoryStub{aggregate: dashboarddomain.Aggregate{ + Resources: dashboarddomain.Resources{ActiveAccounts: 2, TotalAccounts: 3, BuildAccounts: 1, WebAccounts: 1, ConsoleAccounts: 1, EnabledModels: 4, TotalModels: 5, ActiveClientKeys: 6, TotalClientKeys: 7}, + Usage: dashboarddomain.Usage{Requests: 10, SuccessfulRequests: 9, FailedRequests: 1, Tokens: 1200, FirstTokenSamples: 4, FirstTokenTotalMS: 1000, ThroughputSamples: 3, ThroughputTokens: 400, GenerationTotalMS: 8000}, + Buckets: []dashboarddomain.Bucket{{Index: 0, Requests: 10, Tokens: 1200}}, + ActivityBuckets: []dashboarddomain.ActivityBucket{{Index: 179, Requests: 10}}, + Providers: []dashboarddomain.ProviderUsage{{Provider: "grok_build", Requests: 10, SuccessfulRequests: 9, Tokens: 1200}}, + }}) + router := gin.New() + NewHandler(service).Register(router.Group("/api/admin/v1")) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/admin/v1/dashboard?period=7d", nil) + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data struct { + Period string `json:"period"` + Usage struct { + Requests int64 `json:"requests"` + SuccessRate float64 `json:"successRate"` + AverageFirstTokenMS float64 `json:"averageFirstTokenMs"` + OutputTokensPerSecond float64 `json:"outputTokensPerSecond"` + } `json:"usage"` + Series []seriesDTO `json:"series"` + Activity []activityDTO `json:"activity"` + Providers []providerUsageDTO `json:"providers"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.Period != "7d" || envelope.Data.Usage.Requests != 10 || envelope.Data.Usage.SuccessRate != 90 || envelope.Data.Usage.AverageFirstTokenMS != 250 || envelope.Data.Usage.OutputTokensPerSecond != 50 || len(envelope.Data.Series) != 7 || len(envelope.Data.Activity) != 180 || len(envelope.Data.Providers) != 1 { + t.Fatalf("response = %#v", envelope.Data) + } +} + +func TestHandlerRejectsInvalidPeriod(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(dashboardapp.NewService(&dashboardRepositoryStub{})).Register(router.Group("/api/admin/v1")) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/admin/v1/dashboard?period=365d", nil)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestHandlerRejectsInvalidTimezone(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(dashboardapp.NewService(&dashboardRepositoryStub{})).Register(router.Group("/api/admin/v1")) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/admin/v1/dashboard?period=24h&timezone=invalid", nil)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +type dashboardRepositoryStub struct { + aggregate dashboarddomain.Aggregate +} + +func (s *dashboardRepositoryStub) Snapshot(context.Context, repository.DashboardSnapshotWindow, time.Time) (dashboarddomain.Aggregate, error) { + return s.aggregate, nil +} diff --git a/backend/internal/transport/http/egress/handler.go b/backend/internal/transport/http/egress/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..1e5b2b8382f3b54566de0e983f077ff43a7894e2 --- /dev/null +++ b/backend/internal/transport/http/egress/handler.go @@ -0,0 +1,742 @@ +package egress + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct{ service *egressapp.Service } + +func NewHandler(service *egressapp.Service) *Handler { return &Handler{service: service} } + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/egress-nodes", h.list) + router.POST("/egress-nodes", h.create) + router.PATCH("/egress-nodes/batch", h.updateMany) + router.DELETE("/egress-nodes", h.deleteMany) + router.GET("/egress-nodes/cleanup-preview", h.cleanupPreview) + router.POST("/egress-nodes/cleanup", h.cleanup) + router.POST("/egress-nodes/test", h.testNodes) + router.POST("/egress-nodes/:id/test", h.testNode) + router.POST("/egress-nodes/:id/accounts", h.assignAccounts) + router.DELETE("/egress-nodes/accounts", h.unassignAccounts) + router.PUT("/egress-nodes/:id", h.update) + router.POST("/egress-nodes/:id/refresh-clearance", h.refreshClearance) + router.DELETE("/egress-nodes/:id", h.delete) + router.POST("/egress-imports", h.importText) + router.GET("/egress-sources", h.listSources) + router.POST("/egress-sources", h.createSource) + router.POST("/egress-sources/:id/sync", h.syncSource) + router.PUT("/egress-sources/:id", h.updateSource) + router.DELETE("/egress-sources/:id", h.deleteSource) + router.GET("/egress-operations", h.operationsConfig) + router.PUT("/egress-operations", h.updateOperationsConfig) + router.POST("/egress-operations/rebalance", h.rebalance) +} + +func (h *Handler) cleanupPreview(c *gin.Context) { + value, err := h.service.PreviewUnhealthyCleanup(c.Request.Context()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{ + "nodes": value.Nodes, "boundAccounts": value.BoundAccounts, "subscriptionManaged": value.SubscriptionManaged, + }) +} + +func (h *Handler) cleanup(c *gin.Context) { + deleted, err := h.service.DeleteUnhealthy(c.Request.Context()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": deleted}) +} + +func (h *Handler) refreshClearance(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.RefreshClearance(c.Request.Context(), id); err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"refreshed": true}) +} + +type nodeRequest struct { + Name string `json:"name"` + Scope string `json:"scope"` + Enabled bool `json:"enabled"` + ProxyPool *bool `json:"proxyPool"` + AccountCapacity *int `json:"accountCapacity"` + ProxyURL *string `json:"proxyURL"` + ClearProxyURL bool `json:"clearProxyURL"` + UserAgent string `json:"userAgent"` + CloudflareCookies *string `json:"cloudflareCookies"` + ClearCookies bool `json:"clearCookies"` +} + +type nodeResponse struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Scope string `json:"scope"` + Enabled bool `json:"enabled"` + ProxyConfigured bool `json:"proxyConfigured"` + ProxyPool bool `json:"proxyPool"` + SourceID uint64 `json:"sourceId,omitempty,string"` + AccountCapacity int `json:"accountCapacity"` + UserAgent string `json:"userAgent"` + CookieConfigured bool `json:"cookieConfigured"` + AccountBoundProxy bool `json:"accountBoundProxy"` + Health float64 `json:"health"` + FailureCount int `json:"failureCount"` + CooldownUntil *time.Time `json:"cooldownUntil,omitempty"` + LastError string `json:"lastError,omitempty"` + ProbeStatus string `json:"probeStatus"` + LastProbedAt *time.Time `json:"lastProbedAt,omitempty"` + ProbeLatencyMS int `json:"probeLatencyMs"` + ExitIP string `json:"exitIp,omitempty"` + ProbeError string `json:"probeError,omitempty"` + ProbeProvider string `json:"probeProvider,omitempty"` + IPv4Probe probeFamilyResponse `json:"ipv4Probe"` + IPv6Probe probeFamilyResponse `json:"ipv6Probe"` + AssignedAccountCount int `json:"assignedAccountCount"` +} + +type probeFamilyResponse struct { + Status string `json:"status"` + TestedAt *time.Time `json:"testedAt,omitempty"` + LatencyMS int `json:"latencyMs"` + ExitIP string `json:"exitIp,omitempty"` + Error string `json:"error,omitempty"` +} + +type accountAssignmentRequest struct { + Provider string `json:"provider" binding:"required"` + IDs []string `json:"ids" binding:"required"` + Mode string `json:"mode"` +} + +type batchNodeDeleteRequest struct { + IDs []string `json:"ids" binding:"required"` +} + +type batchNodeUpdateRequest struct { + IDs []string `json:"ids" binding:"required"` + Enabled *bool `json:"enabled" binding:"required"` +} + +func (h *Handler) updateMany(c *gin.Context) { + var request batchNodeUpdateRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseBoundedEgressNodeIDs(request.IDs, 5000) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", "代理节点 ID 无效") + return + } + updated, err := h.service.UpdateManyEnabled(c.Request.Context(), ids, *request.Enabled) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"updated": updated}) +} + +func (h *Handler) deleteMany(c *gin.Context) { + var request batchNodeDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseBoundedEgressNodeIDs(request.IDs, 5000) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", "代理节点 ID 无效") + return + } + deleted, err := h.service.DeleteMany(c.Request.Context(), ids) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": deleted}) +} + +func (h *Handler) assignAccounts(c *gin.Context) { + nodeID, ok := pathID(c) + if !ok { + return + } + var request accountAssignmentRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseAccountIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", "账号 ID 无效") + return + } + mode := accountdomain.EgressAssignmentMode(request.Mode) + if mode == "" { + mode = accountdomain.EgressAssignmentManual + } + result, err := h.service.AssignAccounts(c.Request.Context(), nodeID, accountdomain.Provider(request.Provider), ids, mode) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"assigned": result.Assigned}) +} + +func (h *Handler) unassignAccounts(c *gin.Context) { + var request accountAssignmentRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseAccountIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", "账号 ID 无效") + return + } + result, err := h.service.UnassignAccounts(c.Request.Context(), accountdomain.Provider(request.Provider), ids) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"assigned": result.Assigned}) +} + +func (value nodeRequest) input() egressapp.Input { + return egressapp.Input{ + Name: value.Name, Scope: egressdomain.Scope(value.Scope), Enabled: value.Enabled, ProxyPool: value.ProxyPool, + AccountCapacity: value.AccountCapacity, + ProxyURL: value.ProxyURL, ClearProxyURL: value.ClearProxyURL, UserAgent: value.UserAgent, + CloudflareCookies: value.CloudflareCookies, ClearCookies: value.ClearCookies, + } +} + +func (h *Handler) list(c *gin.Context) { + scope := egressdomain.Scope(c.Query("scope")) + sort := repository.SortQuery{Field: c.Query("sortBy"), Direction: repository.SortDirection(c.Query("sortOrder"))} + if legacyEgressListRequest(c) { + values, err := h.service.ListAll(c.Request.Context(), scope, sort) + if h.writeListError(c, err) { + return + } + items := make([]nodeResponse, 0, len(values)) + for _, value := range values { + items = append(items, newNodeResponse(value)) + } + pageSize := len(items) + if pageSize == 0 { + pageSize = repository.DefaultPageSize + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": 1, "pageSize": pageSize, "total": len(items), "defaultUserAgents": h.service.DefaultUserAgents()}) + return + } + page, pageSize := nodePagination(c) + values, total, err := h.service.List(c.Request.Context(), page, pageSize, c.Query("search"), egressapp.ListFilter{ + Scope: scope, Enabled: c.Query("enabled"), ProbeStatus: c.Query("probe"), Assignment: c.Query("assignment"), + Sort: sort, + }) + if h.writeListError(c, err) { + return + } + items := make([]nodeResponse, 0, len(values)) + for _, value := range values { + items = append(items, newNodeResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total, "defaultUserAgents": h.service.DefaultUserAgents()}) +} + +func legacyEgressListRequest(c *gin.Context) bool { + if _, exists := c.GetQuery("page"); exists { + return false + } + if _, exists := c.GetQuery("pageSize"); exists { + return false + } + return c.Query("search") == "" && c.Query("enabled") == "" && c.Query("probe") == "" && c.Query("assignment") == "" +} + +func (h *Handler) writeListError(c *gin.Context, err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, egressapp.ErrInvalidFilter): + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + case errors.Is(err, egressapp.ErrInvalidSort): + response.Error(c, http.StatusBadRequest, "invalidSort", err.Error()) + default: + response.Error(c, http.StatusInternalServerError, "egressNodeListFailed", "读取代理节点失败") + } + return true +} + +func nodePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + return repository.NormalizePage(page, pageSize, repository.DefaultPageSize) +} + +func (h *Handler) create(c *gin.Context) { + var request nodeRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, err := h.service.Create(c.Request.Context(), request.input()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusCreated, newNodeResponse(value)) +} + +func (h *Handler) update(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var request nodeRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, err := h.service.Update(c.Request.Context(), id, request.input()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, newNodeResponse(value)) +} + +func newNodeResponse(value egressdomain.PublicNode) nodeResponse { + return nodeResponse{ + ID: value.ID, Name: value.Name, Scope: string(value.Scope), Enabled: value.Enabled, + ProxyConfigured: value.ProxyConfigured, ProxyPool: value.ProxyPool, UserAgent: value.UserAgent, CookieConfigured: value.CookieConfigured, + AccountBoundProxy: value.AccountBoundProxy, + SourceID: value.SourceID, AccountCapacity: value.AccountCapacity, + Health: value.Health, FailureCount: value.FailureCount, CooldownUntil: value.CooldownUntil, LastError: value.LastError, + ProbeStatus: string(value.ProbeStatus), LastProbedAt: value.LastProbedAt, ProbeLatencyMS: value.ProbeLatencyMS, ExitIP: value.ExitIP, ProbeError: value.ProbeError, + ProbeProvider: string(value.ProbeProvider), + IPv4Probe: newProbeFamilyResponse(value.IPv4Probe), IPv6Probe: newProbeFamilyResponse(value.IPv6Probe), + AssignedAccountCount: value.AssignedAccountCount, + } +} + +func newProbeFamilyResponse(value egressdomain.ProbeFamilyResult) probeFamilyResponse { + status := value.Status + if !status.IsValid() { + status = egressdomain.ProbeStatusUnknown + } + var testedAt *time.Time + if !value.TestedAt.IsZero() { + canonical := value.TestedAt.UTC() + testedAt = &canonical + } + return probeFamilyResponse{ + Status: string(status), TestedAt: testedAt, LatencyMS: value.LatencyMS, ExitIP: value.ExitIP, Error: value.Error, + } +} + +func parseAccountIDs(values []string) ([]uint64, error) { + result := make([]uint64, 0, len(values)) + seen := make(map[uint64]struct{}, len(values)) + for _, value := range values { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return nil, errors.New("invalid id") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + if len(result) == 0 { + return nil, errors.New("no ids") + } + return result, nil +} + +func parseEgressNodeIDs(values []string) ([]uint64, error) { + result := make([]uint64, 0, len(values)) + seen := make(map[uint64]struct{}, len(values)) + for _, value := range values { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return nil, errors.New("invalid id") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + if len(result) == 0 { + return nil, errors.New("no ids") + } + return result, nil +} + +func parseBoundedEgressNodeIDs(values []string, limit int) ([]uint64, error) { + if len(values) == 0 || limit < 1 || len(values) > limit { + return nil, errors.New("invalid id count") + } + return parseEgressNodeIDs(values) +} + +func (h *Handler) delete(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": true}) +} + +type sourceRequest struct { + Name string `json:"name"` + Scope string `json:"scope"` + Enabled bool `json:"enabled"` + URL *string `json:"url"` + ClearURL bool `json:"clearUrl"` + RefreshIntervalSeconds *int `json:"refreshIntervalSeconds"` + DefaultAccountCapacity *int `json:"defaultAccountCapacity"` +} + +type sourceResponse struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Scope string `json:"scope"` + Enabled bool `json:"enabled"` + URLConfigured bool `json:"urlConfigured"` + RefreshIntervalSeconds int `json:"refreshIntervalSeconds"` + DefaultAccountCapacity int `json:"defaultAccountCapacity"` + LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"` + NextSyncAt *time.Time `json:"nextSyncAt,omitempty"` + LastSyncImported int `json:"lastSyncImported"` + LastSyncError string `json:"lastSyncError,omitempty"` +} + +type importRequest struct { + Name string `json:"name"` + Scope string `json:"scope"` + AccountCapacity int `json:"accountCapacity"` + Content string `json:"content"` +} + +type probeBatchRequest struct { + IDs []string `json:"ids"` +} + +type operationsConfigRequest struct { + ProbeProvider string `json:"probeProvider"` + ProbeIntervalSeconds int `json:"probeIntervalSeconds"` + AutoAssignEnabled bool `json:"autoAssignEnabled"` + AutoBalanceEnabled bool `json:"autoBalanceEnabled"` + AssignmentIntervalSeconds int `json:"assignmentIntervalSeconds"` + Fallbacks map[string]operationsFallbackRequest `json:"fallbacks"` +} + +type operationsFallbackRequest struct { + Mode string `json:"mode"` + NodeID string `json:"nodeId"` +} + +type operationsConfigResponse struct { + ProbeProvider string `json:"probeProvider"` + ProbeIntervalSeconds int `json:"probeIntervalSeconds"` + AutoAssignEnabled bool `json:"autoAssignEnabled"` + AutoBalanceEnabled bool `json:"autoBalanceEnabled"` + AssignmentIntervalSeconds int `json:"assignmentIntervalSeconds"` + Fallbacks map[string]operationsFallbackResponse `json:"fallbacks"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type operationsFallbackResponse struct { + Mode string `json:"mode"` + NodeID string `json:"nodeId,omitempty"` +} + +func (value operationsConfigRequest) input() (egressapp.OperationsConfigInput, error) { + result := egressapp.OperationsConfigInput{ + ProbeProvider: egressdomain.ProbeProvider(strings.TrimSpace(value.ProbeProvider)), ProbeIntervalSeconds: value.ProbeIntervalSeconds, AutoAssignEnabled: value.AutoAssignEnabled, + AutoBalanceEnabled: value.AutoBalanceEnabled, AssignmentIntervalSeconds: value.AssignmentIntervalSeconds, + } + if value.Fallbacks == nil { + return result, nil + } + result.Fallbacks = make(map[egressdomain.Scope]egressapp.FallbackConfigInput, len(value.Fallbacks)) + for rawScope, fallback := range value.Fallbacks { + nodeID := uint64(0) + if strings.TrimSpace(fallback.NodeID) != "" { + parsed, err := strconv.ParseUint(fallback.NodeID, 10, 64) + if err != nil || parsed == 0 { + return egressapp.OperationsConfigInput{}, fmt.Errorf("%w: 固定回退节点 ID 无效", egressapp.ErrInvalidInput) + } + nodeID = parsed + } + result.Fallbacks[egressdomain.Scope(rawScope)] = egressapp.FallbackConfigInput{ + Mode: egressdomain.FallbackMode(strings.TrimSpace(fallback.Mode)), NodeID: nodeID, + } + } + return result, nil +} + +func (value sourceRequest) input() egressapp.SubscriptionSourceInput { + return egressapp.SubscriptionSourceInput{ + Name: value.Name, Scope: egressdomain.Scope(value.Scope), Enabled: value.Enabled, URL: value.URL, ClearURL: value.ClearURL, + RefreshIntervalSeconds: value.RefreshIntervalSeconds, DefaultAccountCapacity: value.DefaultAccountCapacity, + } +} + +func newSourceResponse(value egressdomain.PublicSubscriptionSource) sourceResponse { + return sourceResponse{ + ID: value.ID, Name: value.Name, Scope: string(value.Scope), Enabled: value.Enabled, URLConfigured: value.URLConfigured, + RefreshIntervalSeconds: value.RefreshIntervalSeconds, DefaultAccountCapacity: value.DefaultAccountCapacity, + LastSyncedAt: value.LastSyncedAt, NextSyncAt: value.NextSyncAt, LastSyncImported: value.LastSyncImported, LastSyncError: value.LastSyncError, + } +} + +func newOperationsConfigResponse(value egressdomain.OperationsConfig) operationsConfigResponse { + fallbacks := make(map[string]operationsFallbackResponse, 4) + for _, scope := range []egressdomain.Scope{egressdomain.ScopeBuild, egressdomain.ScopeWeb, egressdomain.ScopeConsole, egressdomain.ScopeWebAsset} { + fallback := value.FallbackFor(scope) + item := operationsFallbackResponse{Mode: string(fallback.Mode)} + if fallback.NodeID != 0 { + item.NodeID = strconv.FormatUint(fallback.NodeID, 10) + } + fallbacks[string(scope)] = item + } + return operationsConfigResponse{ + ProbeProvider: string(value.ProbeProvider.Normalized()), ProbeIntervalSeconds: value.ProbeIntervalSeconds, AutoAssignEnabled: value.AutoAssignEnabled, + AutoBalanceEnabled: value.AutoBalanceEnabled, AssignmentIntervalSeconds: value.AssignmentIntervalSeconds, + Fallbacks: fallbacks, UpdatedAt: value.UpdatedAt, + } +} + +func (h *Handler) listSources(c *gin.Context) { + values, err := h.service.ListSources(c.Request.Context()) + if err != nil { + h.writeError(c, err) + return + } + items := make([]sourceResponse, 0, len(values)) + for _, value := range values { + items = append(items, newSourceResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items}) +} + +func (h *Handler) createSource(c *gin.Context) { + var request sourceRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, err := h.service.CreateSource(c.Request.Context(), request.input()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusCreated, newSourceResponse(value)) +} + +func (h *Handler) updateSource(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var request sourceRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, err := h.service.UpdateSource(c.Request.Context(), id, request.input()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, newSourceResponse(value)) +} + +func (h *Handler) deleteSource(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.service.DeleteSource(c.Request.Context(), id); err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": true}) +} + +func (h *Handler) syncSource(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + value, err := h.service.SyncSource(c.Request.Context(), id) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"imported": value.Imported, "skipped": value.Skipped}) +} + +func (h *Handler) importText(c *gin.Context) { + var request importRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + value, err := h.service.ImportText(c.Request.Context(), egressapp.ImportInput{ + Name: request.Name, Scope: egressdomain.Scope(request.Scope), AccountCapacity: request.AccountCapacity, Content: request.Content, + }) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusCreated, gin.H{"imported": value.Imported, "skipped": value.Skipped}) +} + +func (h *Handler) testNode(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + value, err := h.service.TestNode(c.Request.Context(), id) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{ + "status": value.Status, "testedAt": value.TestedAt, "latencyMs": value.LatencyMS, "exitIp": value.ExitIP, "error": value.Error, + "probeProvider": value.Provider, + "ipv4": newProbeFamilyResponse(value.IPv4), "ipv6": newProbeFamilyResponse(value.IPv6), + }) +} + +func (h *Handler) testNodes(c *gin.Context) { + var request probeBatchRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseOptionalAccountIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", "账号 ID 无效") + return + } + value, err := h.service.TestNodes(c.Request.Context(), ids) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"requested": value.Requested, "healthy": value.Healthy, "unhealthy": value.Unhealthy}) +} + +func (h *Handler) operationsConfig(c *gin.Context) { + value, err := h.service.OperationsConfig(c.Request.Context()) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, newOperationsConfigResponse(value)) +} + +func (h *Handler) updateOperationsConfig(c *gin.Context) { + var request operationsConfigRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + input, err := request.input() + if err != nil { + h.writeError(c, err) + return + } + value, err := h.service.UpdateOperationsConfig(c.Request.Context(), input) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, newOperationsConfigResponse(value)) +} + +func (h *Handler) rebalance(c *gin.Context) { + config, err := h.service.OperationsConfig(c.Request.Context()) + if err != nil { + h.writeError(c, err) + return + } + value, err := h.service.RebalanceAccounts(c.Request.Context(), true, true, time.Duration(config.ProbeIntervalSeconds)*time.Second) + if err != nil { + h.writeError(c, err) + return + } + response.Success(c, http.StatusOK, gin.H{"assigned": value.Assigned, "rebalanced": value.Rebalanced, "unplaced": value.Unplaced}) +} + +func parseOptionalAccountIDs(values []string) ([]uint64, error) { + if len(values) == 0 { + return nil, nil + } + return parseAccountIDs(values) +} + +func (h *Handler) writeError(c *gin.Context, err error) { + switch { + case errors.Is(err, egressapp.ErrInvalidInput): + response.Error(c, http.StatusBadRequest, "invalidEgressNode", err.Error()) + case errors.Is(err, egressapp.ErrNotFound): + response.Error(c, http.StatusNotFound, "egressNodeNotFound", err.Error()) + case errors.Is(err, egressapp.ErrProbeStale): + response.Error(c, http.StatusConflict, "egressProbeStale", err.Error()) + case errors.Is(err, repository.ErrConflict): + response.Error(c, http.StatusConflict, "egressConflict", "名称已存在") + case errors.Is(err, egressapp.ErrOperationsUnavailable): + response.Error(c, http.StatusServiceUnavailable, "egressOperationsUnavailable", "代理运营功能暂不可用") + case errors.Is(err, egressapp.ErrSubscriptionSync): + response.Error(c, http.StatusBadGateway, "egressSubscriptionSyncFailed", "代理订阅同步失败") + case errors.Is(err, egressapp.ErrClearanceUnavailable): + response.Error(c, http.StatusConflict, "clearanceRefreshUnavailable", err.Error()) + case strings.Contains(err.Error(), "FlareSolverr") || strings.Contains(err.Error(), "Clearance"): + response.Error(c, http.StatusBadGateway, "clearanceRefreshFailed", err.Error()) + default: + response.Error(c, http.StatusInternalServerError, "egressNodeOperationFailed", "代理节点操作失败") + } +} + +func pathID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", "ID 无效") + return 0, false + } + return id, true +} diff --git a/backend/internal/transport/http/egress/handler_test.go b/backend/internal/transport/http/egress/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..83ff3fa2b34d62a74213920aa1d110244ab90084 --- /dev/null +++ b/backend/internal/transport/http/egress/handler_test.go @@ -0,0 +1,127 @@ +package egress + +import ( + "bytes" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + egressdomain "github.com/chenyme/grok2api/backend/internal/domain/egress" + "github.com/gin-gonic/gin" +) + +func TestBatchNodeUpdateRequestRequiresEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, test := range []struct { + name string + body string + wantErr bool + want bool + }{ + {name: "missing", body: `{"ids":["1"]}`, wantErr: true}, + {name: "explicit false", body: `{"ids":["1"],"enabled":false}`, want: false}, + {name: "explicit true", body: `{"ids":["1"],"enabled":true}`, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest("PATCH", "/egress-nodes/batch", bytes.NewBufferString(test.body)) + context.Request.Header.Set("Content-Type", "application/json") + var request batchNodeUpdateRequest + err := context.ShouldBindJSON(&request) + if test.wantErr { + if err == nil { + t.Fatal("expected binding error") + } + return + } + if err != nil { + t.Fatal(err) + } + if request.Enabled == nil || *request.Enabled != test.want { + t.Fatalf("enabled = %v, want %v", request.Enabled, test.want) + } + }) + } +} + +func TestUpdateManyRejectsMissingEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest("PATCH", "/egress-nodes/batch", bytes.NewBufferString(`{"ids":["1"]}`)) + context.Request.Header.Set("Content-Type", "application/json") + + (&Handler{}).updateMany(context) + + if recorder.Code != 400 || !strings.Contains(recorder.Body.String(), "invalidRequest") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestParseBoundedEgressNodeIDsChecksRawInputLength(t *testing.T) { + values := make([]string, 5001) + for index := range values { + values[index] = "1" + } + if _, err := parseBoundedEgressNodeIDs(values, 5000); err == nil || !strings.Contains(err.Error(), "count") { + t.Fatalf("oversized duplicate input error = %v", err) + } + ids, err := parseBoundedEgressNodeIDs([]string{"2", "2", "1"}, 5000) + if err != nil { + t.Fatal(err) + } + if len(ids) != 2 || ids[0] != 2 || ids[1] != 1 { + t.Fatalf("ids = %v", ids) + } +} + +func TestNewNodeResponseIncludesIPv4AndIPv6ProbeDetails(t *testing.T) { + testedAt := time.Now().UTC().Truncate(time.Second) + response := newNodeResponse(egressdomain.PublicNode{ + ProbeStatus: egressdomain.ProbeStatusHealthy, + ProbeProvider: egressdomain.ProbeProviderCloudflare, + IPv4Probe: egressdomain.ProbeFamilyResult{ + Status: egressdomain.ProbeStatusHealthy, TestedAt: testedAt, LatencyMS: 21, ExitIP: "198.51.100.2", + }, + IPv6Probe: egressdomain.ProbeFamilyResult{ + Status: egressdomain.ProbeStatusUnhealthy, TestedAt: testedAt, LatencyMS: 48, Error: "代理连接失败", + }, + }) + if response.ProbeProvider != "cloudflare" || response.IPv4Probe.ExitIP != "198.51.100.2" || response.IPv4Probe.TestedAt == nil || response.IPv6Probe.Status != "unhealthy" || response.IPv6Probe.Error == "" { + t.Fatalf("node response = %#v", response) + } +} + +func TestOperationsConfigRequestParsesFallbacks(t *testing.T) { + input, err := (operationsConfigRequest{ + ProbeProvider: "cloudflare", ProbeIntervalSeconds: 900, AssignmentIntervalSeconds: 300, + Fallbacks: map[string]operationsFallbackRequest{ + "grok_build": {Mode: "fixed", NodeID: "42"}, + "grok_web": {Mode: "direct"}, + }, + }).input() + if err != nil { + t.Fatal(err) + } + if fallback := input.Fallbacks[egressdomain.ScopeBuild]; fallback.Mode != egressdomain.FallbackModeFixed || fallback.NodeID != 42 { + t.Fatalf("Build fallback = %#v", fallback) + } + if fallback := input.Fallbacks[egressdomain.ScopeWeb]; fallback.Mode != egressdomain.FallbackModeDirect || fallback.NodeID != 0 { + t.Fatalf("Web fallback = %#v", fallback) + } + if input.ProbeProvider != egressdomain.ProbeProviderCloudflare { + t.Fatalf("probe provider = %q", input.ProbeProvider) + } +} + +func TestOperationsConfigRequestRejectsInvalidFallbackNodeID(t *testing.T) { + _, err := (operationsConfigRequest{ + Fallbacks: map[string]operationsFallbackRequest{"grok_build": {Mode: "fixed", NodeID: "zero"}}, + }).input() + if !errors.Is(err, egressapp.ErrInvalidInput) { + t.Fatalf("invalid node ID error = %v", err) + } +} diff --git a/backend/internal/transport/http/frontend.go b/backend/internal/transport/http/frontend.go new file mode 100644 index 0000000000000000000000000000000000000000..9ac4789271aa404d4cff60da63c78c807096a353 --- /dev/null +++ b/backend/internal/transport/http/frontend.go @@ -0,0 +1,91 @@ +package httpserver + +import ( + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// registerFrontend 在构建产物存在时托管静态文件,并为前端路由提供 SPA 回退。 +func registerFrontend(router *gin.Engine, staticPath string) { + root, indexPath, ok := frontendRoot(staticPath) + if !ok { + return + } + files := http.FileServer(http.Dir(root)) + router.NoRoute(func(c *gin.Context) { + requestPath := c.Request.URL.Path + if (c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead) || isBackendPath(requestPath) { + c.Status(http.StatusNotFound) + return + } + if filePath, exists := frontendFile(root, requestPath); exists { + if strings.HasPrefix(path.Clean(requestPath), "/assets/") { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + } else { + c.Header("Cache-Control", "no-cache") + } + c.Request.URL.Path = "/" + filepath.ToSlash(filePath) + files.ServeHTTP(c.Writer, c.Request) + return + } + if path.Ext(path.Clean(requestPath)) != "" { + c.Status(http.StatusNotFound) + return + } + c.Header("Cache-Control", "no-cache") + http.ServeFile(c.Writer, c.Request, indexPath) + }) +} + +func frontendRoot(staticPath string) (string, string, bool) { + staticPath = strings.TrimSpace(staticPath) + if staticPath == "" { + return "", "", false + } + root, err := filepath.Abs(staticPath) + if err != nil { + return "", "", false + } + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + return "", "", false + } + indexPath := filepath.Join(root, "index.html") + indexInfo, err := os.Stat(indexPath) + if err != nil || !indexInfo.Mode().IsRegular() { + return "", "", false + } + return filepath.Clean(root), indexPath, true +} + +func frontendFile(root, requestPath string) (string, bool) { + cleanPath := strings.TrimPrefix(path.Clean("/"+requestPath), "/") + if cleanPath == "" || cleanPath == "." { + return "", false + } + fullPath := filepath.Join(root, filepath.FromSlash(cleanPath)) + relative, err := filepath.Rel(root, fullPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", false + } + info, err := os.Stat(fullPath) + if err != nil || !info.Mode().IsRegular() { + return "", false + } + return relative, true +} + +func isBackendPath(value string) bool { + cleanPath := path.Clean("/" + value) + for _, prefix := range []string{"/api", "/v1", "/swagger"} { + if cleanPath == prefix || strings.HasPrefix(cleanPath, prefix+"/") { + return true + } + } + return cleanPath == "/healthz" || cleanPath == "/readyz" +} diff --git a/backend/internal/transport/http/inference/codex_models.go b/backend/internal/transport/http/inference/codex_models.go new file mode 100644 index 0000000000000000000000000000000000000000..6e3d51fe2dd64f695cc9563de018b42c8857b4ac --- /dev/null +++ b/backend/internal/transport/http/inference/codex_models.go @@ -0,0 +1,230 @@ +package inference + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "unicode" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/gin-gonic/gin" +) + +const codexBaseInstructions = "You are Codex, a coding agent. Follow the user's instructions and use the available tools to complete software engineering tasks. Inspect relevant files before editing, preserve unrelated changes, and verify the result." + +type codexReasoningLevel struct { + Effort string `json:"effort"` + Description string `json:"description"` +} + +type codexTruncationPolicy struct { + Mode string `json:"mode"` + Limit int `json:"limit"` +} + +type codexModelEntry struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + DefaultReasoningLevel string `json:"default_reasoning_level"` + SupportedReasoningLevels []codexReasoningLevel `json:"supported_reasoning_levels"` + ShellType string `json:"shell_type"` + Visibility string `json:"visibility"` + MinimalClientVersion string `json:"minimal_client_version"` + SupportedInAPI bool `json:"supported_in_api"` + Priority int `json:"priority"` + AdditionalSpeedTiers []string `json:"additional_speed_tiers"` + ServiceTiers []any `json:"service_tiers"` + DefaultServiceTier *string `json:"default_service_tier"` + AvailabilityNUX any `json:"availability_nux"` + Upgrade any `json:"upgrade"` + BaseInstructions string `json:"base_instructions"` + ModelMessages any `json:"model_messages"` + IncludeSkillsUsageInstructions bool `json:"include_skills_usage_instructions"` + SupportsReasoningSummaryParameter bool `json:"supports_reasoning_summary_parameter"` + SupportsReasoningSummaries bool `json:"supports_reasoning_summaries"` + DefaultReasoningSummary string `json:"default_reasoning_summary"` + SupportVerbosity bool `json:"support_verbosity"` + DefaultVerbosity *string `json:"default_verbosity"` + ApplyPatchToolType *string `json:"apply_patch_tool_type"` + WebSearchToolType string `json:"web_search_tool_type"` + TruncationPolicy codexTruncationPolicy `json:"truncation_policy"` + SupportsParallelToolCalls bool `json:"supports_parallel_tool_calls"` + SupportsImageDetailOriginal bool `json:"supports_image_detail_original"` + ContextWindow int `json:"context_window"` + MaxContextWindow int `json:"max_context_window"` + AutoCompactTokenLimit *int `json:"auto_compact_token_limit"` + EffectiveContextWindowPercent int `json:"effective_context_window_percent"` + ExperimentalSupportedTools []string `json:"experimental_supported_tools"` + InputModalities []string `json:"input_modalities"` + SupportsSearchTool bool `json:"supports_search_tool"` + UseResponsesLite bool `json:"use_responses_lite"` +} + +type codexModelCatalog struct { + Models []codexModelEntry `json:"models"` +} + +var codexReasoningDescriptions = map[string]string{ + "none": "No reasoning", + "low": "Fast responses with lighter reasoning", + "medium": "Balances speed and reasoning depth for everyday tasks", + "high": "Greater reasoning depth for complex problems", + "xhigh": "Extra high reasoning depth for complex problems", + "max": "Maximum reasoning depth for the hardest problems", +} + +type grokModelCapability struct { + contextWindow int + description string + imageInput bool +} + +// Metadata besides reasoning levels. Reasoning levels come from domain/model so aliases and +// Codex catalogs never diverge from the levels each model actually supports. +var grokCapabilities = map[string]grokModelCapability{ + "grok-4.5": {500000, "xAI Grok 4.5 frontier model with reasoning and vision.", true}, + "grok-4.3": {1000000, "xAI Grok 4.3 high-capacity reasoning model.", true}, + "grok-build-0.1": {256000, "xAI Grok Build 0.1 coding model.", false}, + "grok-4.20-0309-reasoning": {2000000, "xAI Grok 4.20 reasoning model.", true}, + "grok-4.20-0309-non-reasoning": {2000000, "xAI Grok 4.20 non-reasoning model.", true}, + "grok-4.20-multi-agent-0309": {2000000, "xAI Grok 4.20 multi-agent model.", true}, + "grok-3-mini": {131072, "xAI Grok 3 Mini model.", false}, + "grok-3-mini-fast": {131072, "xAI Grok 3 Mini Fast model.", false}, + "grok-composer-2.5-fast": {200000, "xAI Grok Composer 2.5 model.", false}, +} + +var grokDefaultCapability = grokModelCapability{ + contextWindow: 128000, + description: "Grok model served via grok2api.", +} + +func lookupGrokCapability(slug string) (grokModelCapability, []string) { + // Effort-suffixed aliases inherit the base model's metadata. + if base, _, ok := modeldomain.ParseReasoningModelAlias(slug); ok { + slug = base + } + levels := modeldomain.SupportedReasoningEfforts(slug) + if capability, ok := grokCapabilities[slug]; ok { + return capability, levels + } + return grokDefaultCapability, levels +} + +func codexVisibilityForCapability(capability modeldomain.Capability) string { + switch capability { + case modeldomain.CapabilityImage, modeldomain.CapabilityImageEdit, modeldomain.CapabilityVideo: + return "hide" + default: + return "list" + } +} + +func codexReasoningLevelsFor(levels []string) []codexReasoningLevel { + result := make([]codexReasoningLevel, 0, len(levels)) + for _, level := range levels { + result = append(result, codexReasoningLevel{Effort: level, Description: codexReasoningDescriptions[level]}) + } + return result +} + +func codexAgentToolsSupported(item modelListItem) bool { + return item.Provider == account.ProviderBuild && item.Capability == modeldomain.CapabilityResponses +} + +func codexReasoningSupported(levels []string) bool { + for _, level := range levels { + if level != "none" { + return true + } + } + return false +} + +func newCodexModelCatalog(items []modelListItem) codexModelCatalog { + models := make([]codexModelEntry, 0, len(items)) + for index, item := range items { + capability, reasoningLevels := lookupGrokCapability(item.ID) + defaultLevel := modeldomain.DefaultReasoningEffort(item.ID) + // Alias entries pin a single effort; advertise only that level for client UX. + if _, effort, ok := modeldomain.ParseReasoningModelAlias(item.ID); ok { + reasoningLevels = []string{effort} + defaultLevel = effort + } + modalities := []string{"text"} + if capability.imageInput { + modalities = append(modalities, "image") + } + var applyPatchToolType *string + toolsSupported := codexAgentToolsSupported(item) + if toolsSupported { + value := "freeform" + applyPatchToolType = &value + } + reasoningSupported := codexReasoningSupported(reasoningLevels) + models = append(models, codexModelEntry{ + Slug: item.ID, + DisplayName: codexDisplayName(item.ID), + Description: capability.description, + DefaultReasoningLevel: defaultLevel, + SupportedReasoningLevels: codexReasoningLevelsFor(reasoningLevels), + ShellType: "shell_command", + Visibility: codexVisibilityForCapability(item.Capability), + MinimalClientVersion: "0.0.0", + SupportedInAPI: true, + Priority: index + 1, + AdditionalSpeedTiers: []string{}, + ServiceTiers: []any{}, + BaseInstructions: codexBaseInstructions, + IncludeSkillsUsageInstructions: false, + SupportsReasoningSummaryParameter: reasoningSupported, + SupportsReasoningSummaries: reasoningSupported, + DefaultReasoningSummary: "auto", + SupportVerbosity: false, + ApplyPatchToolType: applyPatchToolType, + WebSearchToolType: "text", + TruncationPolicy: codexTruncationPolicy{Mode: "tokens", Limit: 10000}, + SupportsParallelToolCalls: toolsSupported, + SupportsImageDetailOriginal: false, + ContextWindow: capability.contextWindow, + MaxContextWindow: capability.contextWindow, + EffectiveContextWindowPercent: 95, + ExperimentalSupportedTools: []string{}, + InputModalities: modalities, + SupportsSearchTool: false, + UseResponsesLite: false, + }) + } + return codexModelCatalog{Models: models} +} + +func writeCodexModelCatalog(c *gin.Context, catalog codexModelCatalog) { + body, err := json.Marshal(catalog) + if err != nil { + writeOpenAIError(c, http.StatusInternalServerError, "model_list_failed", "编码模型列表失败") + return + } + sum := sha256.Sum256(body) + etag := `"` + hex.EncodeToString(sum[:]) + `"` + c.Header("ETag", etag) + if strings.TrimSpace(c.GetHeader("If-None-Match")) == etag { + c.Status(http.StatusNotModified) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", body) +} + +func codexDisplayName(slug string) string { + words := strings.Fields(strings.NewReplacer("_", " ", "-", " ").Replace(slug)) + for index, word := range words { + runes := []rune(word) + if len(runes) > 0 { + runes[0] = unicode.ToUpper(runes[0]) + words[index] = string(runes) + } + } + return strings.Join(words, " ") +} diff --git a/backend/internal/transport/http/inference/handler.go b/backend/internal/transport/http/inference/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..db334fe349fb926672fac4e08617631535d1c0d0 --- /dev/null +++ b/backend/internal/transport/http/inference/handler.go @@ -0,0 +1,1827 @@ +package inference + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "mime" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + "github.com/chenyme/grok2api/backend/internal/application/gateway" + modelapp "github.com/chenyme/grok2api/backend/internal/application/model" + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +type Handler struct { + gateway *gateway.Service + models *modelapp.Service + maxBodyBytes int64 + publicAPIBaseURL string + publicBaseURL func() string +} + +const ( + responseCopyBufferBytes = 32 << 10 + maxJSONMetadataInspectionBytes = 8 << 20 + maxStreamEventInspectionBytes = 8 << 20 + maxStreamFailureDiagnosticBytes = 64 << 10 + maxCredentialErrorInspectBytes = 64 << 10 + maxJSONResponseTransferBytes = 128 << 20 + maxStreamResponseTransferBytes = 256 << 20 + maxMediaResponseTransferBytes = int64(2) << 30 + responseWriteTimeout = 30 * time.Second +) + +var ( + errResponseTransferLimit = errors.New("响应超过代理安全上限") + errUpstreamStreamIncomplete = errors.New("上游流在终止事件前结束") + errUpstreamStreamFailed = errors.New("上游流返回失败终止事件") + errUpstreamStreamRead = errors.New("读取上游流失败") +) + +type streamProtocol uint8 + +const ( + streamProtocolResponses streamProtocol = iota + streamProtocolChat + streamProtocolAnthropic + streamProtocolImage +) + +const mediaTransferErrorTrailer = "X-Grok2API-Transfer-Error" + +func NewHandler(gatewayService *gateway.Service, models *modelapp.Service, maxBodyBytes int64, publicAPIBaseURL ...string) *Handler { + baseURL := "" + if len(publicAPIBaseURL) > 0 { + baseURL = strings.TrimRight(strings.TrimSpace(publicAPIBaseURL[0]), "/") + } + return &Handler{gateway: gatewayService, models: models, maxBodyBytes: maxBodyBytes, publicAPIBaseURL: baseURL} +} + +// SetPublicAPIBaseURLResolver makes video content URLs follow hot-updated runtime settings. +// Set it before Register; request handling only reads the resolver. +func (h *Handler) SetPublicAPIBaseURLResolver(resolve func() string) *Handler { + h.publicBaseURL = resolve + return h +} + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/models", h.listModels) + router.POST("/responses", h.createResponse) + router.POST("/chat/completions", h.createChatCompletion) + router.POST("/messages", h.createMessage) + router.POST("/images/generations", h.generateImage) + router.POST("/images/edits", h.editImage) + router.POST("/videos/generations", h.generateVideo) + router.GET("/videos/:requestId", h.getVideo) + router.GET("/videos/:requestId/content", h.getVideoContent) + router.POST("/responses/compact", h.compactResponse) + router.GET("/responses/:responseId", h.getResponse) + router.DELETE("/responses/:responseId", h.deleteResponse) +} + +type responsesRequest struct { + Model string `json:"model"` + Stream bool `json:"stream"` + PromptCacheKey string `json:"prompt_cache_key"` + PreviousResponseID string `json:"previous_response_id"` +} + +type chatCompletionRequest struct { + Model string `json:"model"` + Stream bool `json:"stream"` + PromptCacheKey string `json:"prompt_cache_key"` +} + +type messagesRequest struct { + Model string `json:"model"` + MaxTokens *int `json:"max_tokens"` + Messages json.RawMessage `json:"messages"` + Stream bool `json:"stream"` + PromptCacheKey string `json:"prompt_cache_key"` +} + +type imageGenerationRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Count *int `json:"n"` + PartialImages *int `json:"partial_images"` + Size string `json:"size"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + ResponseFormat string `json:"response_format"` + StorageOptions json.RawMessage `json:"storage_options"` + Stream bool `json:"stream"` +} + +type imageEditJSONImage struct { + URL string `json:"url"` + FileID string `json:"file_id"` +} + +type imageEditJSONRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Image *imageEditJSONImage `json:"image"` + Images []imageEditJSONImage `json:"images"` + Count *int `json:"n"` + Size string `json:"size"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + ResponseFormat string `json:"response_format"` + StorageOptions json.RawMessage `json:"storage_options"` + Stream bool `json:"stream"` + PartialImages *int `json:"partial_images"` +} + +type videoGenerationImage struct { + URL string `json:"url"` + FileID string `json:"file_id"` +} + +type videoGenerationRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + User *string `json:"user"` + Duration json.RawMessage `json:"duration"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + Image *videoGenerationImage `json:"image"` + ReferenceImages []videoGenerationImage `json:"reference_images"` + Output json.RawMessage `json:"output"` + StorageOptions json.RawMessage `json:"storage_options"` +} + +type modelListItem struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Provider account.Provider `json:"-"` + Capability modeldomain.Capability `json:"-"` +} + +func (h *Handler) listModels(c *gin.Context) { + allowAliases := false + var clientKey clientkeydomain.Key + hasClientKey := false + if clientValue, exists := c.Get(middleware.ClientKey); exists { + if value, ok := clientValue.(clientkeydomain.Key); ok { + clientKey = value + hasClientKey = true + allowAliases = clientKey.AllowModelAliases + } + } + var values []modeldomain.Route + var err error + if hasClientKey { + values, err = h.models.ListEnabledForClientKey(c.Request.Context(), clientKey) + } else { + values, err = h.models.ListEnabled(c.Request.Context()) + } + if err != nil { + writeOpenAIError(c, http.StatusInternalServerError, "model_list_failed", "读取模型列表失败") + return + } + if hasClientKey { + values = filterModelRoutesForClientKey(values, clientKey) + } + items := newModelListItems(values) + if allowAliases { + items = appendReasoningModelAliases(items) + } + if clientVersion := strings.TrimSpace(c.Query("client_version")); clientVersion != "" { + writeCodexModelCatalog(c, newCodexModelCatalog(items)) + return + } + c.JSON(http.StatusOK, gin.H{"object": "list", "data": items}) +} + +func filterModelRoutesForClientKey(values []modeldomain.Route, key clientkeydomain.Key) []modeldomain.Route { + filtered := make([]modeldomain.Route, 0, len(values)) + scope := key.AccountScope() + for _, value := range values { + if scope.AllowsProvider(value.Provider) && key.AllowsModel(value.ID) { + filtered = append(filtered, value) + } + } + return filtered +} + +// newModelListItems deduplicates by downstream public name and hides Provider prefixes used only for internal routing. +func newModelListItems(values []modeldomain.Route) []modelListItem { + data := make([]modelListItem, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + publicID := modeldomain.ExternalPublicID(value.Provider, value.PublicID) + if seen[publicID] { + continue + } + seen[publicID] = true + data = append(data, modelListItem{ID: publicID, Object: "model", Created: value.CreatedAt.Unix(), OwnedBy: "grok2api", Provider: value.Provider, Capability: value.Capability}) + } + return data +} + +// appendReasoningModelAliases expands base models into effort-suffixed aliases using only +// levels each model actually supports (never a blanket none/low/medium/high/xhigh/max template). +func appendReasoningModelAliases(items []modelListItem) []modelListItem { + if len(items) == 0 { + return items + } + seen := make(map[string]bool, len(items)*2) + result := make([]modelListItem, 0, len(items)*2) + for _, item := range items { + seen[item.ID] = true + result = append(result, item) + } + for _, item := range items { + for _, aliasID := range modeldomain.ReasoningAliasPublicIDs(item.ID) { + if seen[aliasID] { + continue + } + seen[aliasID] = true + result = append(result, modelListItem{ + ID: aliasID, Object: "model", Created: item.Created, OwnedBy: item.OwnedBy, + Provider: item.Provider, Capability: item.Capability, + }) + } + } + return result +} + +func (h *Handler) createResponse(c *gin.Context) { + h.handleCreate(c, false) +} + +func (h *Handler) compactResponse(c *gin.Context) { + h.handleCreate(c, true) +} + +func (h *Handler) createChatCompletion(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + if !isJSONRequest(c) { + writeOpenAIError(c, http.StatusUnsupportedMediaType, "invalid_request", "Chat Completions only supports application/json") + return + } + body, err := io.ReadAll(c.Request.Body) + if err != nil { + writeOpenAIError(c, http.StatusRequestEntityTooLarge, "request_too_large", "请求体超过限制") + return + } + var request chatCompletionRequest + if json.Unmarshal(body, &request) != nil || strings.TrimSpace(request.Model) == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "Chat Completions 请求缺少有效 model") + return + } + clientValue, exists := c.Get(middleware.ClientKey) + clientKey, ok := clientValue.(clientkeydomain.Key) + if !exists || !ok { + writeOpenAIError(c, http.StatusUnauthorized, "invalid_api_key", "客户端 API Key 无效") + return + } + requestID, _ := c.Get(middleware.RequestIDKey) + requestIDValue, _ := requestID.(string) + result, err := h.gateway.CreateChatCompletion(c.Request.Context(), gateway.Input{ + RequestID: requestIDValue, ClientKey: clientKey, PublicModel: request.Model, + Body: body, Streaming: request.Stream, PromptCacheKey: request.PromptCacheKey, + PromptCacheSeed: extractPromptCacheSeed(c.Request.Header, body), + AllowClientToolCacheRoute: allowBuildClientToolCacheRoute(c.Request.Header), + GrokTurnIndex: c.GetHeader("x-grok-turn-idx"), + }) + if err != nil { + writeGatewayError(c, err) + return + } + h.writeResult(c, result, request.Stream, streamProtocolChat) +} + +func (h *Handler) createMessage(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + if !isJSONRequest(c) { + writeAnthropicError(c, http.StatusUnsupportedMediaType, "invalid_request_error", "Messages only supports application/json") + return + } + if strings.TrimSpace(c.GetHeader("anthropic-version")) == "" { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "anthropic-version header is required") + return + } + body, err := io.ReadAll(c.Request.Body) + if err != nil { + writeAnthropicError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body exceeds the configured limit") + return + } + var request messagesRequest + if json.Unmarshal(body, &request) != nil || strings.TrimSpace(request.Model) == "" || request.MaxTokens == nil || *request.MaxTokens <= 0 || len(bytes.TrimSpace(request.Messages)) == 0 { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "model, max_tokens, and messages are required") + return + } + clientValue, exists := c.Get(middleware.ClientKey) + clientKey, ok := clientValue.(clientkeydomain.Key) + if !exists || !ok { + writeAnthropicError(c, http.StatusUnauthorized, "authentication_error", "invalid API key") + return + } + requestID, _ := c.Get(middleware.RequestIDKey) + requestIDValue, _ := requestID.(string) + result, err := h.gateway.CreateMessage(c.Request.Context(), gateway.Input{ + RequestID: requestIDValue, ClientKey: clientKey, PublicModel: request.Model, + Body: body, Streaming: request.Stream, PromptCacheKey: request.PromptCacheKey, + PromptCacheSeed: extractPromptCacheSeed(c.Request.Header, body), + AllowClientToolCacheRoute: allowBuildClientToolCacheRoute(c.Request.Header), + GrokTurnIndex: c.GetHeader("x-grok-turn-idx"), + }) + if err != nil { + writeGatewayAnthropicError(c, err) + return + } + h.writeAnthropicResult(c, result, request.Stream) +} + +func (h *Handler) generateImage(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + if !isJSONRequest(c) { + writeOpenAIError(c, http.StatusUnsupportedMediaType, "invalid_request", "图片生成仅支持 application/json") + return + } + var request imageGenerationRequest + if decodeSingleJSON(c.Request.Body, &request, false) != nil || strings.TrimSpace(request.Model) == "" || strings.TrimSpace(request.Prompt) == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "图片请求缺少有效 model 或 prompt") + return + } + if value := bytes.TrimSpace(request.StorageOptions); len(value) > 0 && !bytes.Equal(value, []byte("null")) { + writeOpenAIError(c, http.StatusBadRequest, "unsupported_parameter", "当前 Grok Web Provider 不支持 storage_options") + return + } + count := 1 + if request.Count != nil { + if *request.Count < 1 || *request.Count > 10 { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "n 必须在 1 到 10 之间") + return + } + count = *request.Count + } + if request.Stream && count != 1 { + writeImageGenerationUserError(c, "unsupported_parameter", "input", "Streaming is only supported with n=1.") + return + } + partialImages := 0 + if request.PartialImages != nil { + if *request.PartialImages < 0 || *request.PartialImages > 3 { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "partial_images 必须在 0 到 3 之间") + return + } + partialImages = *request.PartialImages + if partialImages > 0 && !request.Stream { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "partial_images 仅可在 stream=true 时使用") + return + } + } + clientKey, requestID, ok := requestIdentity(c) + if !ok { + return + } + result, err := h.gateway.GenerateImage(c.Request.Context(), gateway.ImageGenerationInput{ + RequestID: requestID, ClientKey: clientKey, PublicModel: request.Model, Prompt: request.Prompt, + Count: count, Size: request.Size, AspectRatio: request.AspectRatio, + Resolution: request.Resolution, ResponseFormat: request.ResponseFormat, + Streaming: request.Stream, PartialImages: partialImages, + }) + if err != nil { + writeGatewayError(c, err) + return + } + h.writeResult(c, result, request.Stream, streamProtocolImage) +} + +func (h *Handler) writeMediaResult(c *gin.Context, result *gateway.Result) { + errorCode := "" + defer result.Body.Close() + defer func() { result.Finalize(gateway.Usage{}, "", errorCode) }() + if isUpstreamCredentialStatus(result.StatusCode) { + errorCode = "upstream_unavailable" + clientCode := readCredentialErrorCode(result.StatusCode, result.Body) + writeOpenAIError(c, http.StatusServiceUnavailable, clientCode, credentialErrorMessage(clientCode)) + return + } + contentLength, contentLengthErr := strconv.ParseInt(result.Header.Get("Content-Length"), 10, 64) + if contentLengthErr == nil && contentLength > maxMediaResponseTransferBytes { + errorCode = "response_too_large" + writeOpenAIError(c, http.StatusBadGateway, "media_too_large", "上游媒体超过 2 GiB 安全上限") + return + } + copyHeaders(c.Writer.Header(), result.Header) + if contentLengthErr == nil && contentLength >= 0 { + c.Header("Content-Length", strconv.FormatInt(contentLength, 10)) + } else { + c.Header("Trailer", mediaTransferErrorTrailer) + } + c.Status(result.StatusCode) + if err := copyMedia(responseDeadlineWriter{ResponseWriter: c.Writer}, result.Body, maxMediaResponseTransferBytes); err != nil { + if errors.Is(err, errResponseTransferLimit) { + errorCode = "response_too_large" + } else { + errorCode = "stream_interrupted" + } + if contentLengthErr != nil { + c.Header(mediaTransferErrorTrailer, errorCode) + } + } +} + +type responseDeadlineWriter struct{ http.ResponseWriter } + +func (w responseDeadlineWriter) Write(payload []byte) (int, error) { + if err := setResponseWriteDeadline(w.ResponseWriter); err != nil { + return 0, err + } + return w.ResponseWriter.Write(payload) +} + +func setResponseWriteDeadline(writer http.ResponseWriter) error { + err := http.NewResponseController(writer).SetWriteDeadline(time.Now().Add(responseWriteTimeout)) + if errors.Is(err, http.ErrNotSupported) { + return nil + } + return err +} + +func copyMedia(writer io.Writer, source io.Reader, limit int64) error { + buffer := make([]byte, 64<<10) + var transferred int64 + for { + n, readErr := source.Read(buffer) + if n > 0 { + remaining := limit - transferred + if remaining <= 0 { + return errResponseTransferLimit + } + writeSize := n + if int64(writeSize) > remaining { + writeSize = int(remaining) + } + written, writeErr := writer.Write(buffer[:writeSize]) + transferred += int64(written) + if writeErr != nil { + return writeErr + } + if written != writeSize { + return io.ErrShortWrite + } + if writeSize != n { + return errResponseTransferLimit + } + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + } +} + +func (h *Handler) editImage(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + if !isJSONRequest(c) { + writeOpenAIError(c, http.StatusUnsupportedMediaType, "invalid_request", "图片编辑仅支持 application/json") + return + } + var request imageEditJSONRequest + if err := decodeSingleJSON(c.Request.Body, &request, false); err != nil { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "图片编辑 JSON 请求无效") + return + } + if value := bytes.TrimSpace(request.StorageOptions); len(value) > 0 && !bytes.Equal(value, []byte("null")) { + writeOpenAIError(c, http.StatusBadRequest, "unsupported_parameter", "当前 Grok Web Provider 不支持 storage_options") + return + } + model := strings.TrimSpace(request.Model) + prompt := strings.TrimSpace(request.Prompt) + count := 1 + if request.Count != nil { + count = *request.Count + } + inputs := append([]imageEditJSONImage(nil), request.Images...) + if request.Image != nil { + inputs = append([]imageEditJSONImage{*request.Image}, inputs...) + } + if len(inputs) == 0 || len(inputs) > 8 { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "image 或 images 数量必须在 1 到 8 之间") + return + } + imageURLs := make([]string, 0, len(inputs)) + for _, input := range inputs { + if strings.TrimSpace(input.FileID) != "" { + writeOpenAIError(c, http.StatusBadRequest, "unsupported_parameter", "当前暂不支持 image.file_id,请使用 image.url") + return + } + if value := strings.TrimSpace(input.URL); value != "" { + imageURLs = append(imageURLs, value) + } + } + if len(imageURLs) != len(inputs) { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "每个 image 都必须提供有效 url") + return + } + if model == "" || prompt == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "图片编辑缺少有效 model 或 prompt") + return + } + if count != 1 { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "Grok Web 图片编辑当前仅支持 n=1") + return + } + partialImages := 0 + if request.PartialImages != nil { + if *request.PartialImages < 0 || *request.PartialImages > 3 { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "partial_images 必须在 0 到 3 之间") + return + } + partialImages = *request.PartialImages + if partialImages > 0 && !request.Stream { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "partial_images 仅可在 stream=true 时使用") + return + } + } + aspectRatio := strings.ToLower(strings.TrimSpace(request.AspectRatio)) + size := strings.ToLower(strings.TrimSpace(request.Size)) + if aspectRatio != "" && !validImageAspectRatio(aspectRatio) { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "aspect_ratio 不受支持") + return + } + if size != "" && !validImageEditSize(size) { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "size 必须是 auto、1024x1024、1024x1536 或 1536x1024") + return + } + resolution := strings.ToLower(strings.TrimSpace(request.Resolution)) + if resolution == "" { + resolution = "1k" + } + if resolution != "1k" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_parameter", "Grok Web 图片编辑当前仅支持 resolution=1k") + return + } + clientKey, requestID, ok := requestIdentity(c) + if !ok { + return + } + result, err := h.gateway.EditImage(c.Request.Context(), gateway.ImageEditInput{ + RequestID: requestID, ClientKey: clientKey, PublicModel: model, Prompt: prompt, + ImageURLs: imageURLs, Count: count, Size: size, AspectRatio: aspectRatio, + Resolution: resolution, ResponseFormat: request.ResponseFormat, + Streaming: request.Stream, PartialImages: partialImages, + }) + if err != nil { + writeGatewayError(c, err) + return + } + h.writeResult(c, result, request.Stream, streamProtocolImage) +} + +func requestIdentity(c *gin.Context) (clientkeydomain.Key, string, bool) { + clientValue, exists := c.Get(middleware.ClientKey) + clientKey, ok := clientValue.(clientkeydomain.Key) + if !exists || !ok { + writeOpenAIError(c, http.StatusUnauthorized, "invalid_api_key", "客户端 API Key 无效") + return clientkeydomain.Key{}, "", false + } + requestID, _ := c.Get(middleware.RequestIDKey) + requestIDValue, _ := requestID.(string) + return clientKey, requestIDValue, true +} + +func (h *Handler) generateVideo(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + if !isJSONRequest(c) { + writeOpenAIError(c, http.StatusUnsupportedMediaType, "invalid_request", "视频生成仅支持 application/json") + return + } + var request videoGenerationRequest + if err := decodeSingleJSON(c.Request.Body, &request, true); err != nil { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "视频生成 JSON 请求无效: "+err.Error()) + return + } + if hasJSONValue(request.Output) { + writeOpenAIError(c, http.StatusBadRequest, "unsupported_parameter", "当前 Grok Web Provider 不支持 output.upload_url") + return + } + if hasJSONValue(request.StorageOptions) { + writeOpenAIError(c, http.StatusBadRequest, "unsupported_parameter", "当前 Grok Web Provider 不支持 storage_options") + return + } + duration, err := parseVideoDuration(request.Duration) + if err != nil { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + model := strings.TrimSpace(request.Model) + prompt := strings.TrimSpace(request.Prompt) + if model == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "视频生成缺少有效 model") + return + } + aspectRatio := strings.TrimSpace(request.AspectRatio) + if aspectRatio == "" { + aspectRatio = "16:9" + } + if !validVideoAspectRatio(aspectRatio) { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "aspect_ratio 必须是 1:1、16:9、9:16、4:3、3:4、3:2 或 2:3") + return + } + resolution := strings.ToLower(strings.TrimSpace(request.Resolution)) + if resolution == "" { + resolution = "720p" + } + if resolution != "480p" && resolution != "720p" && resolution != "1080p" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "resolution 必须是 480p、720p 或 1080p") + return + } + inputs := append([]videoGenerationImage(nil), request.ReferenceImages...) + if request.Image != nil { + inputs = append([]videoGenerationImage{*request.Image}, inputs...) + } + if len(inputs) > mediadomain.MaxInputImages { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", fmt.Sprintf("image 与 reference_images 合计不能超过 %d 张", mediadomain.MaxInputImages)) + return + } + referenceURLs := make([]string, 0, len(inputs)) + for _, input := range inputs { + if strings.TrimSpace(input.FileID) != "" { + writeOpenAIError(c, http.StatusBadRequest, "unsupported_parameter", "当前暂不支持 image.file_id,请使用 image.url") + return + } + urlValue := strings.TrimSpace(input.URL) + if urlValue == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "每个 image 都必须提供有效 url") + return + } + referenceURLs = append(referenceURLs, urlValue) + } + if prompt == "" && len(referenceURLs) == 0 { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "文本生视频必须提供 prompt;图片生视频可以省略 prompt") + return + } + clientKey, requestID, ok := requestIdentity(c) + if !ok { + return + } + job, err := h.gateway.CreateVideo(c.Request.Context(), gateway.VideoInput{ + RequestID: requestID, ClientKey: clientKey, PublicModel: model, + Prompt: prompt, Duration: duration, AspectRatio: aspectRatio, Resolution: resolution, + ReferenceURLs: referenceURLs, + }) + if err != nil { + writeGatewayError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"request_id": job.ID}) +} + +func (h *Handler) getVideo(c *gin.Context) { + clientKey, _, ok := requestIdentity(c) + if !ok { + return + } + job, err := h.gateway.GetVideo(c.Request.Context(), strings.TrimSpace(c.Param("requestId")), clientKey) + if err != nil { + writeGatewayError(c, err) + return + } + c.JSON(http.StatusOK, videoGenerationResponse(job, h.videoContentURL(job.ID))) +} + +func (h *Handler) videoContentURL(jobID string) string { + path := "/v1/videos/" + url.PathEscape(jobID) + "/content" + baseURL := h.publicAPIBaseURL + if h.publicBaseURL != nil { + baseURL = strings.TrimRight(strings.TrimSpace(h.publicBaseURL()), "/") + } + if baseURL == "" { + return path + } + return baseURL + path +} + +func (h *Handler) getVideoContent(c *gin.Context) { + clientKey, _, ok := requestIdentity(c) + if !ok { + return + } + body, contentType, size, err := h.gateway.OpenVideoContent(c.Request.Context(), strings.TrimSpace(c.Param("requestId")), clientKey) + if err != nil { + writeGatewayError(c, err) + return + } + defer func() { _ = body.Close() }() + writeVideoContent(c, body, contentType, size) +} + +func writeVideoContent(c *gin.Context, body io.Reader, contentType string, size int64) { + if size > maxMediaResponseTransferBytes { + writeOpenAIError(c, http.StatusBadGateway, "media_too_large", "上游媒体超过 2 GiB 安全上限") + return + } + c.Header("Content-Type", contentType) + c.Header("Content-Disposition", "inline") + c.Header("Cache-Control", "private, no-store") + c.Header("X-Content-Type-Options", "nosniff") + if size >= 0 { + c.Header("Content-Length", strconv.FormatInt(size, 10)) + } else { + c.Header("Trailer", mediaTransferErrorTrailer) + } + c.Status(http.StatusOK) + if err := copyMedia(responseDeadlineWriter{ResponseWriter: c.Writer}, body, maxMediaResponseTransferBytes); err != nil && size < 0 { + errorCode := "stream_interrupted" + if errors.Is(err, errResponseTransferLimit) { + errorCode = "response_too_large" + } + c.Header(mediaTransferErrorTrailer, errorCode) + } +} + +func parseVideoDuration(durationRaw json.RawMessage) (int, error) { + duration, hasDuration, err := parseOptionalVideoInteger(durationRaw) + if err != nil { + return 0, fmt.Errorf("duration 必须是整数或整数字符串") + } + value := 8 + if hasDuration { + value = duration + } + if value < 1 || value > 15 { + return 0, fmt.Errorf("duration 必须在 1 到 15 秒之间") + } + return value, nil +} + +func parseOptionalVideoInteger(raw json.RawMessage) (int, bool, error) { + if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return 0, false, nil + } + var number int + if json.Unmarshal(raw, &number) != nil { + var text string + if json.Unmarshal(raw, &text) != nil { + return 0, true, errors.New("必须是整数或整数字符串") + } + parsed, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil { + return 0, true, errors.New("必须是整数或整数字符串") + } + number = parsed + } + return number, true, nil +} + +func hasJSONValue(value json.RawMessage) bool { + trimmed := bytes.TrimSpace(value) + return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) +} + +func validVideoAspectRatio(value string) bool { + switch value { + case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3": + return true + default: + return false + } +} + +func validImageAspectRatio(value string) bool { + switch value { + case "auto", "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2", "19.5:9", "9:19.5", "20:9", "9:20": + return true + default: + return false + } +} + +func validImageEditSize(value string) bool { + switch value { + case "auto", "1024x1024", "1024x1536", "1536x1024": + return true + default: + return false + } +} + +func videoGenerationResponse(job mediadomain.Job, contentURLs ...string) gin.H { + switch job.Status { + case mediadomain.StatusCompleted: + videoURL := job.UpstreamURL + if len(contentURLs) > 0 && contentURLs[0] != "" { + videoURL = contentURLs[0] + } + return gin.H{ + "status": "done", "model": job.Model, "progress": 100, + "video": gin.H{"url": videoURL, "duration": job.Seconds, "respect_moderation": true}, + } + case mediadomain.StatusFailed: + return gin.H{ + "status": "failed", + "error": gin.H{"code": officialVideoErrorCode(job.ErrorCode), "message": job.ErrorMessage}, + } + default: + return gin.H{"status": "pending", "model": job.Model, "progress": min(99, max(0, job.Progress))} + } +} + +func officialVideoErrorCode(value string) string { + switch value { + case "account_unavailable", "provider_unavailable": + return "service_unavailable" + case "model_not_found": + return "invalid_argument" + default: + return "internal_error" + } +} + +func (h *Handler) handleCreate(c *gin.Context, compact bool) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + if !isJSONRequest(c) { + writeOpenAIError(c, http.StatusUnsupportedMediaType, "invalid_request", "Responses only supports application/json") + return + } + body, err := io.ReadAll(c.Request.Body) + if err != nil { + writeOpenAIError(c, http.StatusRequestEntityTooLarge, "request_too_large", "请求体超过限制") + return + } + var request responsesRequest + if err := json.Unmarshal(body, &request); err != nil || strings.TrimSpace(request.Model) == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "Responses 请求缺少有效 model") + return + } + if compact { + body, err = forceJSONBoolean(body, "stream", false) + if err != nil { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "Compact 请求格式无效") + return + } + request.Stream = false + } + clientValue, exists := c.Get(middleware.ClientKey) + clientKey, ok := clientValue.(clientkeydomain.Key) + if !exists || !ok { + writeOpenAIError(c, http.StatusUnauthorized, "invalid_api_key", "客户端 API Key 无效") + return + } + requestID, _ := c.Get(middleware.RequestIDKey) + requestIDValue, _ := requestID.(string) + input := gateway.Input{ + RequestID: requestIDValue, ClientKey: clientKey, PublicModel: request.Model, + Body: body, Streaming: request.Stream, PromptCacheKey: request.PromptCacheKey, + PromptCacheSeed: extractPromptCacheSeed(c.Request.Header, body), PreviousResponseID: request.PreviousResponseID, + AllowClientToolCacheRoute: allowBuildClientToolCacheRoute(c.Request.Header), + GrokTurnIndex: c.GetHeader("x-grok-turn-idx"), + } + var result *gateway.Result + if compact { + result, err = h.gateway.CompactResponse(c.Request.Context(), input) + } else { + result, err = h.gateway.CreateResponse(c.Request.Context(), input) + } + if err != nil { + writeGatewayError(c, err) + return + } + h.writeResult(c, result, request.Stream && !compact, streamProtocolResponses) +} + +func isJSONRequest(c *gin.Context) bool { + mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + return err == nil && strings.EqualFold(mediaType, "application/json") +} + +func decodeSingleJSON(reader io.Reader, target any, disallowUnknown bool) error { + decoder := json.NewDecoder(reader) + if disallowUnknown { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("请求体只能包含一个 JSON 对象") + } + return err + } + return nil +} + +func (h *Handler) getResponse(c *gin.Context) { + h.handleOwnedResource(c, false) +} + +func (h *Handler) deleteResponse(c *gin.Context) { + h.handleOwnedResource(c, true) +} + +func (h *Handler) handleOwnedResource(c *gin.Context, deleteResource bool) { + clientValue, exists := c.Get(middleware.ClientKey) + clientKey, ok := clientValue.(clientkeydomain.Key) + if !exists || !ok { + writeOpenAIError(c, http.StatusUnauthorized, "invalid_api_key", "客户端 API Key 无效") + return + } + input := gateway.ResourceInput{ClientKey: clientKey, ResponseID: strings.TrimSpace(c.Param("responseId")), RawQuery: c.Request.URL.RawQuery} + if input.ResponseID == "" { + writeOpenAIError(c, http.StatusBadRequest, "invalid_request", "response_id 不能为空") + return + } + var result *gateway.Result + var err error + if deleteResource { + result, err = h.gateway.DeleteResponse(c.Request.Context(), input) + } else { + result, err = h.gateway.GetResponse(c.Request.Context(), input) + } + if err != nil { + writeGatewayError(c, err) + return + } + h.writeResult(c, result, false, streamProtocolResponses) +} + +func (h *Handler) writeResult(c *gin.Context, result *gateway.Result, stream bool, protocol streamProtocol) { + h.writeProtocolResult(c, result, stream, false, protocol) +} + +func (h *Handler) writeAnthropicResult(c *gin.Context, result *gateway.Result, stream bool) { + h.writeProtocolResult(c, result, stream, true, streamProtocolAnthropic) +} + +func (h *Handler) writeProtocolResult(c *gin.Context, result *gateway.Result, stream, anthropic bool, protocol streamProtocol) { + usage := gateway.Usage{} + responseID := "" + errorCode := "" + defer result.Body.Close() + defer func() { result.Finalize(usage, responseID, errorCode) }() + if isUpstreamCredentialStatus(result.StatusCode) { + errorCode = "upstream_unavailable" + clientCode := readCredentialErrorCode(result.StatusCode, result.Body) + if anthropic { + writeAnthropicError(c, http.StatusServiceUnavailable, "overloaded_error", credentialErrorMessage(clientCode), clientCode) + } else { + writeOpenAIError(c, http.StatusServiceUnavailable, clientCode, credentialErrorMessage(clientCode)) + } + return + } + transferLimit := int64(maxJSONResponseTransferBytes) + if stream { + transferLimit = maxStreamResponseTransferBytes + } + if contentLength, parseErr := strconv.ParseInt(result.Header.Get("Content-Length"), 10, 64); parseErr == nil && contentLength > transferLimit { + errorCode = "response_too_large" + writeOpenAIError(c, http.StatusBadGateway, "response_too_large", "上游响应超过代理安全上限") + return + } + copyHeaders(c.Writer.Header(), result.Header) + c.Status(result.StatusCode) + if result.StatusCode >= 400 { + errorCode = "upstream_error" + } + var err error + if stream { + metadata, copyErr := copyStream(c.Writer, result.Body, protocol, result.MarkFirstToken) + usage, responseID, err = metadata.Usage, metadata.ResponseID, copyErr + if metadata.StreamFailure != nil && result.RecordStreamFailure != nil { + result.RecordStreamFailure(*metadata.StreamFailure) + } + } else { + metadata, copyErr := copyJSON(c.Writer, result.Body, protocol) + usage, responseID, err = metadata.Usage, metadata.ResponseID, copyErr + } + if err != nil { + switch { + case errors.Is(err, errResponseTransferLimit): + errorCode = "response_too_large" + case errors.Is(err, errUpstreamStreamFailed): + errorCode = "upstream_stream_error" + case errors.Is(err, errUpstreamStreamIncomplete): + errorCode = "upstream_stream_incomplete" + case errors.Is(err, errUpstreamStreamRead): + errorCode = "upstream_stream_interrupted" + default: + errorCode = "stream_interrupted" + } + } +} + +type responseMetadata struct { + Usage gateway.Usage + cacheCreationInputTokens int64 + ResponseID string + Model string + StreamFailure *gateway.StreamFailureDiagnostic +} + +func copyStream(writer gin.ResponseWriter, source io.Reader, protocol streamProtocol, onFirstToken func()) (responseMetadata, error) { + inspector := &responseInspector{protocol: protocol, onFirstToken: onFirstToken} + buffer := make([]byte, responseCopyBufferBytes) + transferred := 0 + for { + n, readErr := source.Read(buffer) + if n > 0 { + if transferred+n > maxStreamResponseTransferBytes { + return inspector.Metadata(), fmt.Errorf("%w: 流式响应超过 %d MiB", errResponseTransferLimit, maxStreamResponseTransferBytes>>20) + } + chunk := buffer[:n] + inspector.Inspect(chunk) + if err := setResponseWriteDeadline(writer); err != nil { + return inspector.Metadata(), err + } + if _, err := writer.Write(chunk); err != nil { + return inspector.Metadata(), err + } + writer.Flush() + inspector.markFirstTokenForwarded() + transferred += n + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + inspector.Finish() + return inspector.Metadata(), inspector.TerminalError() + } + if inspector.terminalSuccess { + return inspector.Metadata(), nil + } + return inspector.Metadata(), fmt.Errorf("%w: %v", errUpstreamStreamRead, readErr) + } + } +} + +func copyJSON(writer gin.ResponseWriter, source io.Reader, protocol streamProtocol) (responseMetadata, error) { + buffer := make([]byte, responseCopyBufferBytes) + metadataBody := make([]byte, 0, responseCopyBufferBytes) + metadataComplete := true + transferred := 0 + for { + n, readErr := source.Read(buffer) + if n > 0 { + if transferred+n > maxJSONResponseTransferBytes { + return responseMetadata{}, fmt.Errorf("%w: 非流式响应超过 %d MiB", errResponseTransferLimit, maxJSONResponseTransferBytes>>20) + } + chunk := buffer[:n] + if err := setResponseWriteDeadline(writer); err != nil { + return responseMetadata{}, err + } + if _, err := writer.Write(chunk); err != nil { + return responseMetadata{}, err + } + transferred += n + if metadataComplete { + if len(metadataBody)+len(chunk) <= maxJSONMetadataInspectionBytes { + metadataBody = append(metadataBody, chunk...) + } else { + metadataBody = nil + metadataComplete = false + } + } + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + if metadataComplete { + return normalizeMetadataUsage(extractMetadata(metadataBody), protocol), nil + } + return responseMetadata{}, nil + } + return responseMetadata{}, readErr + } + } +} + +type responseInspector struct { + protocol streamProtocol + pending []byte + metadata responseMetadata + onFirstToken func() + firstTokenSeen bool + firstTokenReady bool + terminalSuccess bool + terminalFailure bool +} + +func (i *responseInspector) Inspect(chunk []byte) { + i.pending = append(i.pending, chunk...) + for { + index := bytes.IndexByte(i.pending, '\n') + if index < 0 { + if len(i.pending) > maxStreamEventInspectionBytes { + i.pending = nil + } + return + } + line := bytes.TrimSpace(i.pending[:index]) + i.pending = i.pending[index+1:] + if bytes.HasPrefix(line, []byte("data:")) { + value := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + i.observeFirstToken(value) + i.observeTerminal(value) + if !bytes.Equal(value, []byte("[DONE]")) { + metadata := extractMetadata(value) + if hasUsageSignal(metadata.Usage) { + if metadata.Usage.ResponseModel == "" { + metadata.Usage.ResponseModel = i.metadata.Model + } + i.metadata.Usage = mergeGatewayUsage(i.metadata.Usage, metadata.Usage) + } + if metadata.ResponseID != "" { + i.metadata.ResponseID = metadata.ResponseID + } + if metadata.Model != "" { + i.metadata.Model = metadata.Model + i.metadata.Usage.ResponseModel = metadata.Model + } + if metadata.cacheCreationInputTokens > 0 { + i.metadata.cacheCreationInputTokens = metadata.cacheCreationInputTokens + } + } + } + } +} + +func (i *responseInspector) observeFirstToken(data []byte) { + if i.firstTokenSeen || i.firstTokenReady || i.onFirstToken == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + return + } + if !containsGeneratedDelta(data, i.protocol) { + return + } + i.firstTokenReady = true +} + +func (i *responseInspector) markFirstTokenForwarded() { + if i.firstTokenSeen || !i.firstTokenReady || i.onFirstToken == nil { + return + } + i.firstTokenReady = false + i.firstTokenSeen = true + i.onFirstToken() + i.onFirstToken = nil +} + +func containsGeneratedDelta(data []byte, protocol streamProtocol) bool { + switch protocol { + case streamProtocolResponses: + var event struct { + Type string `json:"type"` + Delta string `json:"delta"` + } + if json.Unmarshal(data, &event) != nil || event.Delta == "" { + return false + } + switch event.Type { + case "response.output_text.delta", "response.reasoning_summary_text.delta", "response.reasoning_text.delta", "response.refusal.delta", "response.function_call_arguments.delta", "response.custom_tool_call_input.delta": + return true + } + case streamProtocolChat: + var event struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + Reasoning string `json:"reasoning"` + ReasoningContent string `json:"reasoning_content"` + Refusal string `json:"refusal"` + ToolCalls []struct { + Function struct { + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + } + if json.Unmarshal(data, &event) != nil { + return false + } + for _, choice := range event.Choices { + delta := choice.Delta + if delta.Content != "" || delta.Reasoning != "" || delta.ReasoningContent != "" || delta.Refusal != "" { + return true + } + for _, call := range delta.ToolCalls { + if call.Function.Arguments != "" { + return true + } + } + } + case streamProtocolAnthropic: + var event struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + PartialJSON string `json:"partial_json"` + } `json:"delta"` + } + if json.Unmarshal(data, &event) != nil || event.Type != "content_block_delta" { + return false + } + switch event.Delta.Type { + case "text_delta": + return event.Delta.Text != "" + case "thinking_delta": + return event.Delta.Thinking != "" + case "input_json_delta": + return event.Delta.PartialJSON != "" + } + } + return false +} + +func (i *responseInspector) Metadata() responseMetadata { + return normalizeMetadataUsage(i.metadata, i.protocol) +} + +func normalizeMetadataUsage(metadata responseMetadata, protocol streamProtocol) responseMetadata { + if protocol != streamProtocolAnthropic { + return metadata + } + inputTokens := saturatingUsageSum(metadata.Usage.InputTokens, metadata.Usage.CachedInputTokens, metadata.cacheCreationInputTokens) + metadata.Usage.InputTokens = inputTokens + metadata.Usage.TotalTokens = saturatingUsageSum(inputTokens, metadata.Usage.OutputTokens) + return metadata +} + +func saturatingUsageSum(values ...int64) int64 { + var total int64 + for _, value := range values { + if value <= 0 { + continue + } + if value > math.MaxInt64-total { + return math.MaxInt64 + } + total += value + } + return total +} + +func (i *responseInspector) TerminalError() error { + if i.terminalFailure { + return errUpstreamStreamFailed + } + if !i.terminalSuccess { + return errUpstreamStreamIncomplete + } + return nil +} + +func (i *responseInspector) observeTerminal(data []byte) { + if bytes.Equal(data, []byte("[DONE]")) { + if i.protocol == streamProtocolChat { + i.terminalSuccess = true + } + return + } + var payload struct { + Type string `json:"type"` + } + if json.Unmarshal(data, &payload) != nil { + return + } + switch i.protocol { + case streamProtocolResponses: + switch payload.Type { + case "response.completed": + i.terminalSuccess = true + case "response.failed", "response.incomplete", "response.error", "error": + i.markTerminalFailure(data) + } + case streamProtocolChat: + if payload.Type == "error" { + i.markTerminalFailure(data) + } + case streamProtocolAnthropic: + switch payload.Type { + case "message_stop": + i.terminalSuccess = true + case "error": + i.markTerminalFailure(data) + } + case streamProtocolImage: + switch payload.Type { + case "image_generation.completed": + i.terminalSuccess = true + case "image_generation.failed", "error": + i.markTerminalFailure(data) + } + } +} + +func (i *responseInspector) markTerminalFailure(data []byte) { + i.terminalFailure = true + if i.metadata.StreamFailure != nil { + return + } + diagnostic := projectStreamFailureDiagnostic(data) + if len(diagnostic.Body) > 0 { + i.metadata.StreamFailure = &diagnostic + } +} + +func projectStreamFailureDiagnostic(data []byte) gateway.StreamFailureDiagnostic { + var root map[string]json.RawMessage + if json.Unmarshal(data, &root) != nil { + return gateway.StreamFailureDiagnostic{} + } + projected := make(map[string]json.RawMessage) + copySafeDiagnosticFields(projected, root, "type", "status", "code", "message", "param") + if raw := projectSafeErrorValue(root["error"]); len(raw) > 0 { + projected["error"] = raw + } + if responseRaw := root["response"]; len(responseRaw) > 0 { + var response map[string]json.RawMessage + if json.Unmarshal(responseRaw, &response) == nil { + safeResponse := make(map[string]json.RawMessage) + copySafeDiagnosticFields(safeResponse, response, "id", "status", "code", "message") + if raw := projectSafeErrorValue(response["error"]); len(raw) > 0 { + safeResponse["error"] = raw + } + if raw := projectSafeErrorValue(response["incomplete_details"]); len(raw) > 0 { + safeResponse["incomplete_details"] = raw + } + if len(safeResponse) > 0 { + if encoded, err := json.Marshal(safeResponse); err == nil { + projected["response"] = encoded + } + } + } + } + if len(projected) == 0 { + return gateway.StreamFailureDiagnostic{} + } + encoded, err := json.Marshal(projected) + if err != nil { + return gateway.StreamFailureDiagnostic{} + } + diagnostic := gateway.StreamFailureDiagnostic{Body: encoded} + if len(diagnostic.Body) > maxStreamFailureDiagnosticBytes { + bounded := diagnostic.Body[:maxStreamFailureDiagnosticBytes] + for len(bounded) > 0 && !utf8.Valid(bounded) { + bounded = bounded[:len(bounded)-1] + } + diagnostic.Body = append([]byte(nil), bounded...) + diagnostic.BodyTruncated = true + } else { + diagnostic.Body = append([]byte(nil), diagnostic.Body...) + } + return diagnostic +} + +func copySafeDiagnosticFields(destination, source map[string]json.RawMessage, fields ...string) { + for _, field := range fields { + if raw := projectSafeScalar(source[field]); len(raw) > 0 { + destination[field] = raw + } + } +} + +func projectSafeErrorValue(raw json.RawMessage) json.RawMessage { + if scalar := projectSafeScalar(raw); len(scalar) > 0 { + return scalar + } + var value map[string]json.RawMessage + if json.Unmarshal(raw, &value) != nil { + return nil + } + projected := make(map[string]json.RawMessage) + copySafeDiagnosticFields(projected, value, "type", "status", "code", "message", "param", "reason") + if len(projected) == 0 { + return nil + } + encoded, err := json.Marshal(projected) + if err != nil { + return nil + } + return encoded +} + +func projectSafeScalar(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + var value any + if json.Unmarshal(raw, &value) != nil { + return nil + } + switch value.(type) { + case nil, string, bool, float64: + return append(json.RawMessage(nil), raw...) + default: + return nil + } +} + +func (i *responseInspector) Finish() { + if len(i.pending) == 0 { + return + } + i.pending = append(i.pending, '\n') + i.Inspect(nil) +} + +func extractMetadata(data []byte) responseMetadata { + var root responsePayloadDTO + if json.Unmarshal(data, &root) != nil { + return responseMetadata{} + } + metadata := responseMetadata{ResponseID: root.ID, Model: root.Model} + usage := root.Usage + if root.Response != nil { + if metadata.ResponseID == "" { + metadata.ResponseID = root.Response.ID + } + if metadata.Model == "" { + metadata.Model = root.Response.Model + } + if usage == nil { + usage = root.Response.Usage + } + } + if usage == nil { + return metadata + } + metadata.Usage = usage.toGatewayUsage(metadata.Model) + metadata.cacheCreationInputTokens = usage.CacheCreationInputTokens + return metadata +} + +type responsePayloadDTO struct { + ID string `json:"id"` + Model string `json:"model"` + Usage *responseUsageDTO `json:"usage"` + Response *responsePayloadDTO `json:"response"` +} + +type responseUsageDTO struct { + InputTokens int64 `json:"input_tokens"` + InputTokensCamel int64 `json:"inputTokens"` + OutputTokens int64 `json:"output_tokens"` + OutputTokensCamel int64 `json:"outputTokens"` + TotalTokens int64 `json:"total_tokens"` + TotalTokensCamel int64 `json:"totalTokens"` + CostInUSDTicks int64 `json:"cost_in_usd_ticks"` + NumSourcesUsed int64 `json:"num_sources_used"` + NumServerSideToolsUsed int64 `json:"num_server_side_tools_used"` + // Responses protocol: input_tokens_details.cached_tokens + InputTokensDetails responseInputDetailsDTO `json:"input_tokens_details"` + // OpenAI Chat Completions protocol: prompt_tokens_details.cached_tokens + PromptTokensDetails responseInputDetailsDTO `json:"prompt_tokens_details"` + // Anthropic Messages protocol: top-level cache_read_input_tokens + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + OutputTokensDetails responseOutputDetailsDTO `json:"output_tokens_details"` + // OpenAI Chat Completions protocol: completion_tokens_details.reasoning_tokens + CompletionTokensDetails responseOutputDetailsDTO `json:"completion_tokens_details"` + ContextDetails responseContextDetailsDTO `json:"context_details"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` +} + +type responseInputDetailsDTO struct { + CachedTokens int64 `json:"cached_tokens"` +} + +type responseOutputDetailsDTO struct { + ReasoningTokens int64 `json:"reasoning_tokens"` +} + +type responseContextDetailsDTO struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` +} + +func (value responseUsageDTO) toGatewayUsage(responseModel string) gateway.Usage { + input := value.InputTokens + if input == 0 { + input = value.InputTokensCamel + } + if input == 0 { + input = value.PromptTokens + } + output := value.OutputTokens + if output == 0 { + output = value.OutputTokensCamel + } + if output == 0 { + output = value.CompletionTokens + } + total := value.TotalTokens + if total == 0 { + total = value.TotalTokensCamel + } + if total == 0 { + total = input + output + } + // Unified cache hits: Responses / Chat Completions / Anthropic Messages + cached := value.InputTokensDetails.CachedTokens + if cached == 0 { + cached = value.PromptTokensDetails.CachedTokens + } + if cached == 0 { + cached = value.CacheReadInputTokens + } + reasoning := value.OutputTokensDetails.ReasoningTokens + if reasoning == 0 { + reasoning = value.CompletionTokensDetails.ReasoningTokens + } + return gateway.Usage{ + InputTokens: input, CachedInputTokens: cached, + OutputTokens: output, ReasoningTokens: reasoning, + TotalTokens: total, CostInUSDTicks: value.CostInUSDTicks, + NumSourcesUsed: value.NumSourcesUsed, NumServerSideToolsUsed: value.NumServerSideToolsUsed, + ContextInputTokens: value.ContextDetails.InputTokens, ContextOutputTokens: value.ContextDetails.OutputTokens, + ResponseModel: responseModel, + } +} + +func hasUsageSignal(usage gateway.Usage) bool { + return usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.TotalTokens > 0 || + usage.CachedInputTokens > 0 || usage.ReasoningTokens > 0 || usage.CostInUSDTicks > 0 || + usage.NumSourcesUsed > 0 || usage.NumServerSideToolsUsed > 0 || + usage.ContextInputTokens > 0 || usage.ContextOutputTokens > 0 +} + +// mergeGatewayUsage merges usage from multiple streaming frames; non-zero fields overwrite, +// preventing a later partial frame from erasing an already parsed cache hit. +func mergeGatewayUsage(base, next gateway.Usage) gateway.Usage { + if next.InputTokens > 0 { + base.InputTokens = next.InputTokens + } + if next.OutputTokens > 0 { + base.OutputTokens = next.OutputTokens + } + if next.TotalTokens > 0 { + base.TotalTokens = next.TotalTokens + } + if next.CachedInputTokens > 0 { + base.CachedInputTokens = next.CachedInputTokens + } + if next.ReasoningTokens > 0 { + base.ReasoningTokens = next.ReasoningTokens + } + if next.CostInUSDTicks > 0 { + base.CostInUSDTicks = next.CostInUSDTicks + } + if next.NumSourcesUsed > 0 { + base.NumSourcesUsed = next.NumSourcesUsed + } + if next.NumServerSideToolsUsed > 0 { + base.NumServerSideToolsUsed = next.NumServerSideToolsUsed + } + if next.ContextInputTokens > 0 { + base.ContextInputTokens = next.ContextInputTokens + } + if next.ContextOutputTokens > 0 { + base.ContextOutputTokens = next.ContextOutputTokens + } + if next.ResponseModel != "" { + base.ResponseModel = next.ResponseModel + } + if base.TotalTokens == 0 && (base.InputTokens > 0 || base.OutputTokens > 0) { + base.TotalTokens = base.InputTokens + base.OutputTokens + } + return base +} + +func copyHeaders(destination, source http.Header) { + excluded := map[string]struct{}{ + "connection": {}, "content-length": {}, "keep-alive": {}, "proxy-authenticate": {}, + "proxy-authorization": {}, "set-cookie": {}, "te": {}, "trailer": {}, + "transfer-encoding": {}, "upgrade": {}, "x-models-etag": {}, + } + for _, value := range source.Values("Connection") { + for name := range strings.SplitSeq(value, ",") { + name = strings.ToLower(strings.TrimSpace(name)) + if name != "" { + excluded[name] = struct{}{} + } + } + } + for name, values := range source { + lower := strings.ToLower(name) + if _, skip := excluded[lower]; skip { + continue + } + for _, value := range values { + destination.Add(name, value) + } + } +} + +func writeOpenAIError(c *gin.Context, status int, code, message string) { + errorType := "invalid_request_error" + switch { + case status == http.StatusUnauthorized: + errorType = "authentication_error" + case status == http.StatusTooManyRequests: + errorType = "rate_limit_error" + case status >= 500: + errorType = "server_error" + } + c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"message": message, "type": errorType, "code": code, "param": nil}}) +} + +func writeImageGenerationUserError(c *gin.Context, code, param, message string) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "message": message, "type": "image_generation_user_error", "param": param, "code": code, + }}) +} + +func writeGatewayError(c *gin.Context, err error) { + status, code := http.StatusBadGateway, "upstream_unavailable" + message := "上游服务暂不可用" + var upstreamFailure *gateway.UpstreamFailure + var selectionFailure *gateway.SelectionUnavailableError + switch { + case errors.Is(err, gateway.ErrLedgerUnavailable): + status, code = http.StatusServiceUnavailable, "ledger_unavailable" + message = gateway.ErrLedgerUnavailable.Error() + case errors.Is(err, clientkeyapp.ErrBillingLimit): + status, code = http.StatusTooManyRequests, "billing_limit_exceeded" + message = clientkeyapp.ErrBillingLimit.Error() + case errors.Is(err, clientkeyapp.ErrModelNotAllowed): + status, code = http.StatusForbidden, "model_not_allowed" + message = clientkeyapp.ErrModelNotAllowed.Error() + case errors.Is(err, gateway.ErrModelNotFound): + status, code = http.StatusNotFound, "model_not_found" + message = "模型不存在" + case errors.Is(err, gateway.ErrResponseNotFound): + status, code = http.StatusNotFound, "response_not_found" + message = "Response 不存在或已过期" + case errors.Is(err, gateway.ErrResponseStateUnsupported), errors.Is(err, gateway.ErrConversationUnsupported): + status, code = http.StatusBadRequest, "unsupported_parameter" + message = err.Error() + case errors.Is(err, gateway.ErrVideoInputTooLarge): + status, code = http.StatusBadRequest, "invalid_request" + message = err.Error() + case errors.As(err, &upstreamFailure): + if isSanitizedUpstreamAvailabilityFailure(upstreamFailure) { + // Gateway mid-tier behavior: never expose upstream upgrade/billing prompts to clients. + code = upstreamFailure.ClientCredentialErrorCode() + if upstreamFailure.QuotaExhausted || upstreamFailure.FreeQuotaExhausted || upstreamFailure.HTTPStatus == http.StatusPaymentRequired { + code = "upstream_unavailable" + } + status, message = http.StatusServiceUnavailable, credentialErrorMessage(code) + } else { + status, code, message = upstreamFailure.HTTPStatus, upstreamFailure.Code, upstreamFailure.PublicMessage + } + if !isUpstreamCredentialStatus(upstreamFailure.HTTPStatus) && upstreamFailure.RetryAfter > 0 { + c.Header("Retry-After", strconv.FormatInt(max(1, int64(upstreamFailure.RetryAfter.Round(time.Second)/time.Second)), 10)) + } + case errors.As(err, &selectionFailure): + status, code, message = selectionErrorResponse(c, selectionFailure) + case errors.Is(err, gateway.ErrResponseAccountUnavailable), errors.Is(err, gateway.ErrNoAvailableAccount): + status, code = http.StatusServiceUnavailable, "upstream_unavailable" + message = "当前没有可用的上游账号" + } + writeOpenAIError(c, status, code, message) +} + +func writeGatewayAnthropicError(c *gin.Context, err error) { + status, errorType := http.StatusBadGateway, "api_error" + message := "上游服务暂不可用" + clientCode := "" + var upstreamFailure *gateway.UpstreamFailure + var selectionFailure *gateway.SelectionUnavailableError + switch { + case errors.Is(err, gateway.ErrLedgerUnavailable): + status, errorType = http.StatusServiceUnavailable, "overloaded_error" + message = gateway.ErrLedgerUnavailable.Error() + case errors.Is(err, clientkeyapp.ErrBillingLimit): + status, errorType = http.StatusTooManyRequests, "rate_limit_error" + message = clientkeyapp.ErrBillingLimit.Error() + case errors.Is(err, clientkeyapp.ErrModelNotAllowed): + status, errorType, clientCode = http.StatusForbidden, "permission_error", "model_not_allowed" + message = clientkeyapp.ErrModelNotAllowed.Error() + case errors.Is(err, gateway.ErrModelNotFound): + status, errorType = http.StatusNotFound, "not_found_error" + message = "模型不存在" + case errors.Is(err, gateway.ErrResponseStateUnsupported), errors.Is(err, gateway.ErrConversationUnsupported): + status, errorType = http.StatusBadRequest, "invalid_request_error" + message = err.Error() + case errors.As(err, &upstreamFailure): + if isSanitizedUpstreamAvailabilityFailure(upstreamFailure) { + clientCode = upstreamFailure.ClientCredentialErrorCode() + if upstreamFailure.QuotaExhausted || upstreamFailure.FreeQuotaExhausted || upstreamFailure.HTTPStatus == http.StatusPaymentRequired { + clientCode = "upstream_unavailable" + } + status, errorType, message = http.StatusServiceUnavailable, "overloaded_error", credentialErrorMessage(clientCode) + } else { + status, message = upstreamFailure.HTTPStatus, upstreamFailure.PublicMessage + if upstreamFailure.Code == "upstream_header_timeout" { + errorType = "timeout_error" + } + } + if !isUpstreamCredentialStatus(upstreamFailure.HTTPStatus) && upstreamFailure.RetryAfter > 0 { + c.Header("Retry-After", strconv.FormatInt(max(1, int64(upstreamFailure.RetryAfter.Round(time.Second)/time.Second)), 10)) + } + if status == http.StatusTooManyRequests { + errorType = "rate_limit_error" + } + case errors.As(err, &selectionFailure): + status, clientCode, message = selectionErrorResponse(c, selectionFailure) + if status == http.StatusTooManyRequests { + errorType = "rate_limit_error" + } else { + errorType = "overloaded_error" + } + case errors.Is(err, gateway.ErrResponseAccountUnavailable), errors.Is(err, gateway.ErrNoAvailableAccount): + status, errorType = http.StatusServiceUnavailable, "overloaded_error" + message = "当前没有可用的上游账号" + } + writeAnthropicError(c, status, errorType, message, clientCode) +} + +func isUpstreamCredentialStatus(status int) bool { + // Include 402 so official "add credits / upgrade SuperGrok" bodies never reach clients (Grok CLI, etc.). + return status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusPaymentRequired +} + +func isSanitizedUpstreamAvailabilityFailure(failure *gateway.UpstreamFailure) bool { + return failure != nil && (isUpstreamCredentialStatus(failure.HTTPStatus) || failure.QuotaExhausted || failure.FreeQuotaExhausted) +} + +func selectionErrorResponse(c *gin.Context, failure *gateway.SelectionUnavailableError) (int, string, string) { + status, code, message := http.StatusServiceUnavailable, "upstream_unavailable", "当前没有可用的上游账号" + if failure == nil { + return status, code, message + } + status, code = failure.HTTPStatus(), failure.Code() + if failure.Scope.IsRestricted() { + message = failure.Error() + } else { + switch failure.Reason { + case gateway.SelectionCooling: + message = "上游账号正在冷却" + case gateway.SelectionModelCooling: + message = "上游账号的目标模型正在冷却" + case gateway.SelectionQuotaExhausted: + message = "上游账号额度等待恢复" + case gateway.SelectionSaturated: + message = "上游账号当前均达到并发上限" + case gateway.SelectionUnsupportedModel: + message = "当前账号池不支持该模型" + } + } + if failure.RetryAfter > 0 { + seconds := max(int64(1), int64((failure.RetryAfter+time.Second-1)/time.Second)) + c.Header("Retry-After", strconv.FormatInt(seconds, 10)) + } + return status, code, message +} + +func writeAnthropicError(c *gin.Context, status int, errorType, message string, errorCode ...string) { + errorPayload := gin.H{"type": errorType, "message": message} + if len(errorCode) > 0 && errorCode[0] != "" && errorCode[0] != "upstream_unavailable" { + errorPayload["code"] = errorCode[0] + } + c.AbortWithStatusJSON(status, gin.H{"type": "error", "error": errorPayload}) +} + +func readCredentialErrorCode(status int, source io.Reader) string { + body, err := io.ReadAll(io.LimitReader(source, maxCredentialErrorInspectBytes+1)) + if err != nil || len(body) > maxCredentialErrorInspectBytes { + return "upstream_unavailable" + } + return gateway.ClientCredentialErrorCodeFromBody(status, body) +} + +func credentialErrorMessage(code string) string { + if code == "permission-denied" { + return "上游服务暂不可用,聊天端点访问被拒绝" + } + return "上游服务暂不可用" +} + +func forceJSONBoolean(body []byte, key string, value bool) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + payload[key] = json.RawMessage("false") + if value { + payload[key] = json.RawMessage("true") + } + return json.Marshal(payload) +} diff --git a/backend/internal/transport/http/inference/handler_test.go b/backend/internal/transport/http/inference/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bc5d7e39db323a04928720b0146416a6ecd19423 --- /dev/null +++ b/backend/internal/transport/http/inference/handler_test.go @@ -0,0 +1,998 @@ +package inference + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "unicode/utf8" + + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + "github.com/chenyme/grok2api/backend/internal/application/gateway" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + mediadomain "github.com/chenyme/grok2api/backend/internal/domain/media" + "github.com/gin-gonic/gin" +) + +func TestVideoGenerationUsesOfficialXAIEndpointsAndFields(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, 1<<20).Register(router.Group("/v1")) + + for _, test := range []struct { + name string + body string + }{ + {name: "unsupported seconds", body: `{"model":"grok-imagine-video","prompt":"test","seconds":8}`}, + {name: "unsupported nested image url", body: `{"model":"grok-imagine-video","image":{"image_url":"https://example.com/input.png"}}`}, + {name: "unsupported size", body: `{"model":"grok-imagine-video","prompt":"test","size":"16:9"}`}, + {name: "unsupported quality", body: `{"model":"grok-imagine-video","prompt":"test","quality":"720p"}`}, + {name: "unsupported input reference", body: `{"model":"grok-imagine-video","input_reference":"https://example.com/input.png"}`}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/videos/generations", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "unknown field") { + t.Fatalf("unsupported field status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } + + invalidDuration := httptest.NewRequest(http.MethodPost, "/v1/videos/generations", strings.NewReader(`{"model":"grok-imagine-video","prompt":"test","duration":16}`)) + invalidDuration.Header.Set("Content-Type", "application/json") + invalidRecorder := httptest.NewRecorder() + router.ServeHTTP(invalidRecorder, invalidDuration) + if invalidRecorder.Code != http.StatusBadRequest || !strings.Contains(invalidRecorder.Body.String(), "1 到 15") { + t.Fatalf("invalid duration status=%d body=%s", invalidRecorder.Code, invalidRecorder.Body.String()) + } + + valid := httptest.NewRequest(http.MethodPost, "/v1/videos/generations", strings.NewReader(`{ + "model":"grok-imagine-video","prompt":"test","duration":"8", + "aspect_ratio":"16:9","resolution":"720p","user":"end_user_1" + }`)) + valid.Header.Set("Content-Type", "application/json") + validRecorder := httptest.NewRecorder() + router.ServeHTTP(validRecorder, valid) + if validRecorder.Code != http.StatusUnauthorized { + t.Fatalf("official generation shape status=%d body=%s", validRecorder.Code, validRecorder.Body.String()) + } + + imageOnly := httptest.NewRequest(http.MethodPost, "/v1/videos/generations", strings.NewReader(`{ + "model":"grok-imagine-video","image":{"url":"https://example.com/input.png"} + }`)) + imageOnly.Header.Set("Content-Type", "application/json") + imageRecorder := httptest.NewRecorder() + router.ServeHTTP(imageRecorder, imageOnly) + if imageRecorder.Code != http.StatusUnauthorized { + t.Fatalf("image-only generation status=%d body=%s", imageRecorder.Code, imageRecorder.Body.String()) + } + + wrongContentType := httptest.NewRequest(http.MethodPost, "/v1/videos/generations", strings.NewReader(`{"model":"grok-imagine-video","prompt":"test"}`)) + wrongContentType.Header.Set("Content-Type", "text/plain") + wrongContentTypeRecorder := httptest.NewRecorder() + router.ServeHTTP(wrongContentTypeRecorder, wrongContentType) + if wrongContentTypeRecorder.Code != http.StatusUnsupportedMediaType { + t.Fatalf("wrong content type status=%d body=%s", wrongContentTypeRecorder.Code, wrongContentTypeRecorder.Body.String()) + } + + unsupportedRecorder := httptest.NewRecorder() + router.ServeHTTP(unsupportedRecorder, httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{}`))) + if unsupportedRecorder.Code != http.StatusNotFound { + t.Fatalf("unsupported video endpoint status=%d", unsupportedRecorder.Code) + } + contentRecorder := httptest.NewRecorder() + router.ServeHTTP(contentRecorder, httptest.NewRequest(http.MethodGet, "/v1/videos/request_1/content", nil)) + if contentRecorder.Code != http.StatusUnauthorized { + t.Fatalf("video content endpoint status=%d", contentRecorder.Code) + } +} + +func TestWriteVideoContentRejectsDeclaredOversizeMedia(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + writeVideoContent(context, strings.NewReader("ignored"), "video/mp4", maxMediaResponseTransferBytes+1) + if recorder.Code != http.StatusBadGateway || !strings.Contains(recorder.Body.String(), "media_too_large") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestVideoContentURLUsesConfiguredPublicAPIBase(t *testing.T) { + handler := NewHandler(nil, nil, 1<<20, "https://api.example.com/grok2api/") + response := videoGenerationResponse(mediadomain.Job{ID: "video_request_1", Status: mediadomain.StatusCompleted, UpstreamURL: "https://assets.grok.com/source.mp4"}, handler.videoContentURL("video_request_1")) + video, ok := response["video"].(gin.H) + if !ok || video["url"] != "https://api.example.com/grok2api/v1/videos/video_request_1/content" { + t.Fatalf("response = %#v", response) + } +} + +func TestVideoContentURLFollowsRuntimePublicAPIBase(t *testing.T) { + baseURL := "https://old.example.com" + handler := NewHandler(nil, nil, 1<<20, "https://static.example.com").SetPublicAPIBaseURLResolver(func() string { + return baseURL + }) + if got := handler.videoContentURL("video_request_1"); got != "https://old.example.com/v1/videos/video_request_1/content" { + t.Fatalf("initial URL = %q", got) + } + baseURL = "https://new.example.com/api/" + if got := handler.videoContentURL("video_request_2"); got != "https://new.example.com/api/v1/videos/video_request_2/content" { + t.Fatalf("updated URL = %q", got) + } +} + +func TestGatewayErrorDoesNotExposeInternalDetails(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/", func(c *gin.Context) { + writeGatewayError(c, errors.New("dial postgres://secret@internal:5432 failed")) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusBadGateway || strings.Contains(recorder.Body.String(), "postgres") || !strings.Contains(recorder.Body.String(), "上游服务暂不可用") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestGatewayErrorMapsOversizedVideoInputToBadRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/", func(c *gin.Context) { + writeGatewayError(c, gateway.ErrVideoInputTooLarge) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), `"code":"invalid_request"`) || !strings.Contains(recorder.Body.String(), "32 MiB") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestGatewayErrorMapsLedgerUnavailableToServiceUnavailable(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/", func(c *gin.Context) { + writeGatewayError(c, gateway.ErrLedgerUnavailable) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), `"code":"ledger_unavailable"`) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestGatewayErrorMapsDisallowedModelWithoutCallingItUpstreamUnavailable(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, test := range []struct { + name string + anthropic bool + wantType string + }{ + {name: "openai", wantType: `"code":"model_not_allowed"`}, + {name: "anthropic", anthropic: true, wantType: `"type":"permission_error"`}, + } { + t.Run(test.name, func(t *testing.T) { + router := gin.New() + router.GET("/", func(c *gin.Context) { + if test.anthropic { + writeGatewayAnthropicError(c, clientkeyapp.ErrModelNotAllowed) + return + } + writeGatewayError(c, clientkeyapp.ErrModelNotAllowed) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), test.wantType) || strings.Contains(recorder.Body.String(), "upstream_unavailable") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestGatewayErrorMapsResponseHeaderTimeout(t *testing.T) { + gin.SetMode(gin.TestMode) + openAIRouter := gin.New() + openAIRouter.GET("/", func(c *gin.Context) { + writeGatewayError(c, &gateway.UpstreamFailure{ + HTTPStatus: http.StatusGatewayTimeout, Code: "upstream_header_timeout", PublicMessage: "等待上游响应头超时", + }) + }) + openAIRecorder := httptest.NewRecorder() + openAIRouter.ServeHTTP(openAIRecorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if openAIRecorder.Code != http.StatusGatewayTimeout || !strings.Contains(openAIRecorder.Body.String(), `"code":"upstream_header_timeout"`) { + t.Fatalf("OpenAI status=%d body=%s", openAIRecorder.Code, openAIRecorder.Body.String()) + } + + anthropicRouter := gin.New() + anthropicRouter.GET("/", func(c *gin.Context) { + writeGatewayAnthropicError(c, &gateway.UpstreamFailure{ + HTTPStatus: http.StatusGatewayTimeout, Code: "upstream_header_timeout", PublicMessage: "等待上游响应头超时", + }) + }) + anthropicRecorder := httptest.NewRecorder() + anthropicRouter.ServeHTTP(anthropicRecorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if anthropicRecorder.Code != http.StatusGatewayTimeout || !strings.Contains(anthropicRecorder.Body.String(), `"type":"timeout_error"`) { + t.Fatalf("Anthropic status=%d body=%s", anthropicRecorder.Code, anthropicRecorder.Body.String()) + } +} + +func TestGatewayErrorHidesUpstreamCredentialStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + openAIRouter := gin.New() + openAIRouter.GET("/", func(c *gin.Context) { + writeGatewayError(c, &gateway.UpstreamFailure{ + HTTPStatus: http.StatusForbidden, Code: "upstream_forbidden", PublicMessage: "上游拒绝了该请求", + UpstreamCode: "permission-denied", + Cause: errors.New("secret upstream response"), + }) + }) + openAIRecorder := httptest.NewRecorder() + openAIRouter.ServeHTTP(openAIRecorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if openAIRecorder.Code != http.StatusServiceUnavailable || !strings.Contains(openAIRecorder.Body.String(), `"code":"permission-denied"`) || !strings.Contains(openAIRecorder.Body.String(), "上游服务暂不可用,聊天端点访问被拒绝") || strings.Contains(openAIRecorder.Body.String(), "secret") || strings.Contains(openAIRecorder.Body.String(), "上游拒绝了该请求") { + t.Fatalf("OpenAI status=%d body=%s", openAIRecorder.Code, openAIRecorder.Body.String()) + } + + anthropicRouter := gin.New() + anthropicRouter.GET("/", func(c *gin.Context) { + writeGatewayAnthropicError(c, &gateway.UpstreamFailure{ + HTTPStatus: http.StatusTooManyRequests, Code: "upstream_rate_limited", PublicMessage: "上游请求频率受限", + }) + }) + anthropicRecorder := httptest.NewRecorder() + anthropicRouter.ServeHTTP(anthropicRecorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if anthropicRecorder.Code != http.StatusTooManyRequests || !strings.Contains(anthropicRecorder.Body.String(), `"type":"rate_limit_error"`) { + t.Fatalf("Anthropic status=%d body=%s", anthropicRecorder.Code, anthropicRecorder.Body.String()) + } + + quotaRouter := gin.New() + quotaRouter.GET("/", func(c *gin.Context) { + writeGatewayAnthropicError(c, &gateway.UpstreamFailure{ + HTTPStatus: http.StatusTooManyRequests, Code: "upstream_rate_limited", PublicMessage: "official upgrade prompt", + QuotaExhausted: true, + }) + }) + quotaRecorder := httptest.NewRecorder() + quotaRouter.ServeHTTP(quotaRecorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if quotaRecorder.Code != http.StatusServiceUnavailable || !strings.Contains(quotaRecorder.Body.String(), `"type":"overloaded_error"`) || strings.Contains(quotaRecorder.Body.String(), "upgrade") { + t.Fatalf("Anthropic quota status=%d body=%s", quotaRecorder.Code, quotaRecorder.Body.String()) + } + + credentialRouter := gin.New() + credentialRouter.GET("/", func(c *gin.Context) { + writeGatewayAnthropicError(c, &gateway.UpstreamFailure{ + HTTPStatus: http.StatusUnauthorized, Code: "upstream_unauthorized", PublicMessage: "上游账号认证失败", + }) + }) + credentialRecorder := httptest.NewRecorder() + credentialRouter.ServeHTTP(credentialRecorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if credentialRecorder.Code != http.StatusServiceUnavailable || !strings.Contains(credentialRecorder.Body.String(), `"type":"overloaded_error"`) || strings.Contains(credentialRecorder.Body.String(), "认证") { + t.Fatalf("Anthropic credential status=%d body=%s", credentialRecorder.Code, credentialRecorder.Body.String()) + } +} + +func TestDirectUpstreamCredentialResponsesAreRewritten(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := NewHandler(nil, nil, 1<<20) + for _, tc := range []struct { + name string + status int + anthropic bool + media bool + body string + wantCode string + }{ + {name: "openai unauthorized", status: http.StatusUnauthorized, body: `{"error":"secret upstream credential detail"}`, wantCode: "upstream_unavailable"}, + {name: "anthropic forbidden", status: http.StatusForbidden, anthropic: true, body: `{"code":"permission-denied","error":"secret upstream credential detail"}`, wantCode: "permission-denied"}, + {name: "media forbidden", status: http.StatusForbidden, media: true, body: `{"code":"permission-denied","error":"secret upstream credential detail"}`, wantCode: "permission-denied"}, + } { + t.Run(tc.name, func(t *testing.T) { + finalCode := "" + result := &gateway.Result{ + StatusCode: tc.status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(tc.body)), + Finalize: func(_ gateway.Usage, _, code string) { + finalCode = code + }, + } + router := gin.New() + router.GET("/", func(c *gin.Context) { + switch { + case tc.media: + handler.writeMediaResult(c, result) + case tc.anthropic: + handler.writeAnthropicResult(c, result, false) + default: + handler.writeResult(c, result, false, streamProtocolResponses) + } + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), `"`+tc.wantCode+`"`) || strings.Contains(recorder.Body.String(), "secret") || finalCode != "upstream_unavailable" { + t.Fatalf("status=%d body=%s finalize=%s", recorder.Code, recorder.Body.String(), finalCode) + } + if tc.wantCode == "permission-denied" && !strings.Contains(recorder.Body.String(), "上游服务暂不可用,聊天端点访问被拒绝") { + t.Fatalf("permission message missing: %s", recorder.Body.String()) + } + }) + } +} + +func TestMessagesEndpointUsesAnthropicContract(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, 1<<20).Register(router.Group("/v1")) + + missingVersion := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.5","max_tokens":128,"messages":[{"role":"user","content":"hi"}]}`)) + missingVersion.Header.Set("Content-Type", "application/json") + missingRecorder := httptest.NewRecorder() + router.ServeHTTP(missingRecorder, missingVersion) + if missingRecorder.Code != http.StatusBadRequest || !strings.Contains(missingRecorder.Body.String(), `"type":"error"`) { + t.Fatalf("missing version status=%d body=%s", missingRecorder.Code, missingRecorder.Body.String()) + } + + valid := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.5","max_tokens":128,"messages":[{"role":"user","content":"hi"}]}`)) + valid.Header.Set("Content-Type", "application/json") + valid.Header.Set("anthropic-version", "2023-06-01") + validRecorder := httptest.NewRecorder() + router.ServeHTTP(validRecorder, valid) + if validRecorder.Code != http.StatusUnauthorized || !strings.Contains(validRecorder.Body.String(), `"type":"authentication_error"`) { + t.Fatalf("valid shape status=%d body=%s", validRecorder.Code, validRecorder.Body.String()) + } + + zeroTokens := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.5","max_tokens":0,"messages":[{"role":"user","content":"hi"}]}`)) + zeroTokens.Header.Set("Content-Type", "application/json") + zeroTokens.Header.Set("anthropic-version", "2023-06-01") + zeroRecorder := httptest.NewRecorder() + router.ServeHTTP(zeroRecorder, zeroTokens) + if zeroRecorder.Code != http.StatusBadRequest { + t.Fatalf("zero max_tokens status=%d body=%s", zeroRecorder.Code, zeroRecorder.Body.String()) + } +} + +func TestJSONInferenceEndpointsRejectWrongMediaTypeAndTrailingDocument(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, 1<<20).Register(router.Group("/v1")) + + for _, path := range []string{"/v1/responses", "/v1/images/generations"} { + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"test","prompt":"test"}`)) + request.Header.Set("Content-Type", "text/plain") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnsupportedMediaType { + t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String()) + } + } + + for _, test := range []struct { + path string + body string + }{ + {path: "/v1/images/generations", body: `{"model":"grok-imagine-image","prompt":"test"}{}`}, + {path: "/v1/images/edits", body: `{"model":"grok-imagine-image-edit","prompt":"test","image":{"url":"https://example.com/input.png"}}{}`}, + } { + request := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("%s status=%d body=%s", test.path, recorder.Code, recorder.Body.String()) + } + } +} + +func TestVideoDurationUsesOfficialFieldOnly(t *testing.T) { + if value, err := parseVideoDuration(nil); err != nil || value != 8 { + t.Fatalf("default duration=%d err=%v", value, err) + } + if value, err := parseVideoDuration(json.RawMessage(`"6"`)); err != nil || value != 6 { + t.Fatalf("duration=%d err=%v", value, err) + } +} + +func TestVideoGenerationResponseMatchesOfficialPollingShape(t *testing.T) { + now := time.Now().UTC() + pending := videoGenerationResponse(mediadomain.Job{Model: "grok-imagine-video", Status: mediadomain.StatusInProgress, Progress: 42}) + if pending["status"] != "pending" || pending["progress"] != 42 || pending["model"] != "grok-imagine-video" || pending["video"] != nil { + t.Fatalf("pending response=%#v", pending) + } + done := videoGenerationResponse(mediadomain.Job{Model: "grok-imagine-video", Status: mediadomain.StatusCompleted, Progress: 100, Seconds: 8, UpstreamURL: "https://assets.grok.com/video.mp4", CompletedAt: &now}) + video, ok := done["video"].(gin.H) + if done["status"] != "done" || done["progress"] != 100 || !ok || video["url"] != "https://assets.grok.com/video.mp4" || video["duration"] != 8 || video["respect_moderation"] != true { + t.Fatalf("done response=%#v", done) + } + failed := videoGenerationResponse(mediadomain.Job{Status: mediadomain.StatusFailed, ErrorCode: "account_unavailable", ErrorMessage: "try later"}) + errorValue, ok := failed["error"].(gin.H) + if failed["status"] != "failed" || !ok || errorValue["code"] != "service_unavailable" || failed["model"] != nil || failed["progress"] != nil { + t.Fatalf("failed response=%#v", failed) + } +} + +func TestImageGenerationEndpointValidatesXAIContractBeforeRouting(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, 1<<20).Register(router.Group("/v1")) + + for _, test := range []struct { + name string + body string + want string + }{ + {name: "zero n", body: `{"model":"grok-imagine-image","prompt":"test","n":0}`, want: "n 必须在 1 到 10 之间"}, + {name: "large n", body: `{"model":"grok-imagine-image","prompt":"test","n":11}`, want: "n 必须在 1 到 10 之间"}, + {name: "storage options", body: `{"model":"grok-imagine-image","prompt":"test","storage_options":{"filename":"test.jpg"}}`, want: "不支持 storage_options"}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), test.want) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/image", strings.NewReader(`{}`))) + if recorder.Code != http.StatusNotFound { + t.Fatalf("singular image endpoint status = %d", recorder.Code) + } +} + +func TestImageEditAcceptsOfficialJSONShape(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, 1<<20).Register(router.Group("/v1")) + + missingImage := httptest.NewRequest(http.MethodPost, "/v1/images/edits", strings.NewReader(`{ + "model":"grok-imagine-image-edit","prompt":"变成黑色 白字","n":1 + }`)) + missingImage.Header.Set("Content-Type", "application/json") + missingRecorder := httptest.NewRecorder() + router.ServeHTTP(missingRecorder, missingImage) + if missingRecorder.Code != http.StatusBadRequest || !strings.Contains(missingRecorder.Body.String(), "image 或 images") { + t.Fatalf("missing image status=%d body=%s", missingRecorder.Code, missingRecorder.Body.String()) + } + + validShape := httptest.NewRequest(http.MethodPost, "/v1/images/edits", strings.NewReader(`{ + "model":"grok-imagine-image-edit","prompt":"变成黑色 白字","n":1,"resolution":"1k", + "image":{"url":"https://example.com/input.png"},"aspect_ratio":"1:1", + "stream":true,"partial_images":1 + }`)) + validShape.Header.Set("Content-Type", "application/json") + validRecorder := httptest.NewRecorder() + router.ServeHTTP(validRecorder, validShape) + if validRecorder.Code != http.StatusUnauthorized || strings.Contains(validRecorder.Body.String(), "multipart") { + t.Fatalf("valid JSON shape status=%d body=%s", validRecorder.Code, validRecorder.Body.String()) + } + + invalidResolution := httptest.NewRequest(http.MethodPost, "/v1/images/edits", strings.NewReader(`{ + "model":"grok-imagine-image-edit","prompt":"test","resolution":"2k", + "image":{"url":"https://example.com/input.png"} + }`)) + invalidResolution.Header.Set("Content-Type", "application/json") + invalidResolutionRecorder := httptest.NewRecorder() + router.ServeHTTP(invalidResolutionRecorder, invalidResolution) + if invalidResolutionRecorder.Code != http.StatusBadRequest || !strings.Contains(invalidResolutionRecorder.Body.String(), "仅支持 resolution=1k") { + t.Fatalf("invalid resolution status=%d body=%s", invalidResolutionRecorder.Code, invalidResolutionRecorder.Body.String()) + } + + invalidCount := httptest.NewRequest(http.MethodPost, "/v1/images/edits", strings.NewReader(`{ + "model":"grok-imagine-image-edit","prompt":"test","n":2, + "image":{"url":"https://example.com/input.png"} + }`)) + invalidCount.Header.Set("Content-Type", "application/json") + invalidCountRecorder := httptest.NewRecorder() + router.ServeHTTP(invalidCountRecorder, invalidCount) + if invalidCountRecorder.Code != http.StatusBadRequest || !strings.Contains(invalidCountRecorder.Body.String(), "仅支持 n=1") { + t.Fatalf("invalid count status=%d body=%s", invalidCountRecorder.Code, invalidCountRecorder.Body.String()) + } + + for _, test := range []struct { + name string + body string + }{ + {name: "negative partial images", body: `{"model":"grok-imagine-image-edit","prompt":"test","stream":true,"partial_images":-1,"image":{"url":"https://example.com/input.png"}}`}, + {name: "too many partial images", body: `{"model":"grok-imagine-image-edit","prompt":"test","stream":true,"partial_images":4,"image":{"url":"https://example.com/input.png"}}`}, + {name: "partial images require stream", body: `{"model":"grok-imagine-image-edit","prompt":"test","partial_images":1,"image":{"url":"https://example.com/input.png"}}`}, + {name: "invalid aspect ratio", body: `{"model":"grok-imagine-image-edit","prompt":"test","aspect_ratio":"7:5","image":{"url":"https://example.com/input.png"}}`}, + {name: "invalid size", body: `{"model":"grok-imagine-image-edit","prompt":"test","size":"512x512","image":{"url":"https://example.com/input.png"}}`}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/images/edits", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } + + multipartRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", strings.NewReader("ignored")) + multipartRequest.Header.Set("Content-Type", "multipart/form-data; boundary=test") + multipartRecorder := httptest.NewRecorder() + router.ServeHTTP(multipartRecorder, multipartRequest) + if multipartRecorder.Code != http.StatusUnsupportedMediaType || !strings.Contains(multipartRecorder.Body.String(), "application/json") { + t.Fatalf("multipart status=%d body=%s", multipartRecorder.Code, multipartRecorder.Body.String()) + } +} + +func TestImageGenerationValidatesOpenAIPartialImages(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, 1<<20).Register(router.Group("/v1")) + + for _, test := range []struct { + name string + body string + }{ + {name: "negative", body: `{"model":"grok-imagine-image-quality","prompt":"cat","stream":true,"partial_images":-1}`}, + {name: "too many", body: `{"model":"grok-imagine-image-quality","prompt":"cat","stream":true,"partial_images":4}`}, + {name: "requires stream", body: `{"model":"grok-imagine-image-quality","prompt":"cat","partial_images":1}`}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "partial_images") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } + + invalidStreamingCount := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{ + "model":"grok-imagine-image-quality","prompt":"cat","n":2,"stream":true + }`)) + invalidStreamingCount.Header.Set("Content-Type", "application/json") + invalidStreamingCountRecorder := httptest.NewRecorder() + router.ServeHTTP(invalidStreamingCountRecorder, invalidStreamingCount) + if invalidStreamingCountRecorder.Code != http.StatusBadRequest { + t.Fatalf("stream n status=%d body=%s", invalidStreamingCountRecorder.Code, invalidStreamingCountRecorder.Body.String()) + } + var payload map[string]any + if json.Unmarshal(invalidStreamingCountRecorder.Body.Bytes(), &payload) != nil { + t.Fatalf("stream n body=%s", invalidStreamingCountRecorder.Body.String()) + } + errorValue, _ := payload["error"].(map[string]any) + if errorValue["message"] != "Streaming is only supported with n=1." || errorValue["type"] != "image_generation_user_error" || errorValue["param"] != "input" || errorValue["code"] != "unsupported_parameter" { + t.Fatalf("stream n error=%#v", errorValue) + } + + valid := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{ + "model":"grok-imagine-image-quality","prompt":"cat","n":1,"stream":true,"partial_images":1 + }`)) + valid.Header.Set("Content-Type", "application/json") + validRecorder := httptest.NewRecorder() + router.ServeHTTP(validRecorder, valid) + if validRecorder.Code != http.StatusUnauthorized { + t.Fatalf("valid status=%d body=%s", validRecorder.Code, validRecorder.Body.String()) + } +} + +func TestExtractUsageFromCompletedEvent(t *testing.T) { + metadata := extractMetadata([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"grok-4.5-build-free","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4},"output_tokens":5,"output_tokens_details":{"reasoning_tokens":2},"total_tokens":15,"cost_in_usd_ticks":158500,"num_sources_used":1,"num_server_side_tools_used":2,"context_details":{"input_tokens":9,"output_tokens":4}}}}`)) + usage := metadata.Usage + if usage.InputTokens != 10 || usage.OutputTokens != 5 || usage.TotalTokens != 15 { + t.Fatalf("usage = %#v", usage) + } + if usage.CachedInputTokens != 4 || usage.ReasoningTokens != 2 || metadata.ResponseID != "resp_1" { + t.Fatalf("metadata = %#v", metadata) + } + if usage.CostInUSDTicks != 158500 || usage.NumSourcesUsed != 1 || usage.NumServerSideToolsUsed != 2 || usage.ContextInputTokens != 9 || usage.ContextOutputTokens != 4 || usage.ResponseModel != "grok-4.5-build-free" { + t.Fatalf("observed usage = %#v", usage) + } +} + +func TestExtractUsageFromAnthropicMessagesCaches(t *testing.T) { + metadata := normalizeMetadataUsage(extractMetadata([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"grok-4.5","usage":{"input_tokens":20,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":80,"cost_in_usd_ticks":1000}}`)), streamProtocolAnthropic) + if metadata.Usage.CachedInputTokens != 80 || metadata.Usage.InputTokens != 100 || metadata.Usage.OutputTokens != 20 { + t.Fatalf("anthropic usage = %#v", metadata.Usage) + } + if metadata.Usage.TotalTokens != 120 { + t.Fatalf("anthropic total usage = %#v", metadata.Usage) + } +} + +func TestExtractUsageFromChatCompletionsCaches(t *testing.T) { + // OpenAI Chat Completions 用 prompt_tokens_details.cached_tokens。 + metadata := extractMetadata([]byte(`{"id":"chatcmpl_1","object":"chat.completion","model":"grok-4.5","usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60,"prompt_tokens_details":{"cached_tokens":30},"completion_tokens_details":{"reasoning_tokens":5}}}`)) + if metadata.Usage.CachedInputTokens != 30 || metadata.Usage.InputTokens != 50 || metadata.Usage.OutputTokens != 10 || metadata.Usage.ReasoningTokens != 5 || metadata.Usage.TotalTokens != 60 { + t.Fatalf("chat usage = %#v", metadata.Usage) + } +} + +func TestExtractUsagePrefersResponsesCachedTokensOverAnthropicField(t *testing.T) { + // 同时存在时优先 Responses 字段(正常路径不会并存,防回归)。 + metadata := extractMetadata([]byte(`{"usage":{"input_tokens":10,"output_tokens":1,"input_tokens_details":{"cached_tokens":7},"cache_read_input_tokens":99}}`)) + if metadata.Usage.CachedInputTokens != 7 { + t.Fatalf("prefer responses cached = %#v", metadata.Usage) + } +} + +func TestStreamInspectorMergesCachedTokensAcrossFrames(t *testing.T) { + inspector := &responseInspector{protocol: streamProtocolAnthropic} + inspector.Inspect([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":20,\"output_tokens\":20}}\n\n")) + inspector.Inspect([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"cache_read_input_tokens\":80}}\n\n")) + inspector.Inspect([]byte("data: {\"type\":\"message_stop\"}\n\n")) + inspector.Finish() + usage := inspector.Metadata().Usage + if usage.InputTokens != 100 || usage.OutputTokens != 20 || usage.CachedInputTokens != 80 || usage.TotalTokens != 120 { + t.Fatalf("merged stream usage = %#v", usage) + } +} + +func TestStreamInspectorMarksFirstGeneratedTokenOnce(t *testing.T) { + tests := []struct { + name string + protocol streamProtocol + prelude string + delta string + }{ + { + name: "responses text", protocol: streamProtocolResponses, + prelude: `data: {"type":"response.created","response":{"id":"resp_1"}}` + "\n\n" + `data: {"type":"response.output_text.delta","delta":""}` + "\n\n", + delta: `data: {"type":"response.output_text.delta","delta":"hello"}` + "\n\n", + }, + { + name: "responses custom tool input", protocol: streamProtocolResponses, + prelude: `data: {"type":"response.custom_tool_call_input.delta","output_index":1,"item_id":"ctc_1","delta":""}` + "\n\n", + delta: `data: {"type":"response.custom_tool_call_input.delta","output_index":1,"item_id":"ctc_1","delta":"{}"}` + "\n\n", + }, + { + name: "chat reasoning", protocol: streamProtocolChat, + prelude: `data: {"choices":[{"delta":{"role":"assistant"}}]}` + "\n\n", + delta: `data: {"choices":[{"delta":{"reasoning_content":"thinking"}}]}` + "\n\n", + }, + { + name: "anthropic tool input", protocol: streamProtocolAnthropic, + prelude: `data: {"type":"message_start","message":{"id":"msg_1"}}` + "\n\n", + delta: `data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}` + "\n\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + marked := 0 + inspector := &responseInspector{protocol: test.protocol, onFirstToken: func() { marked++ }} + inspector.Inspect([]byte(test.prelude)) + inspector.markFirstTokenForwarded() + if marked != 0 { + t.Fatalf("metadata marked first token %d times", marked) + } + inspector.Inspect([]byte(test.delta + test.delta)) + if marked != 0 { + t.Fatalf("generated delta was marked before forwarding %d times", marked) + } + inspector.markFirstTokenForwarded() + inspector.markFirstTokenForwarded() + if marked != 1 { + t.Fatalf("generated delta marked first token %d times", marked) + } + }) + } +} + +func TestStreamInspectorDoesNotMarkImageEvents(t *testing.T) { + marked := 0 + inspector := &responseInspector{protocol: streamProtocolImage, onFirstToken: func() { marked++ }} + inspector.Inspect([]byte(`data: {"type":"image_generation.partial_image","partial_image_b64":"abc"}` + "\n\n")) + inspector.markFirstTokenForwarded() + if marked != 0 { + t.Fatalf("image stream marked first token %d times", marked) + } +} + +func TestCopyStreamMarksFirstTokenAfterFlush(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + body := `data: {"type":"response.reasoning_text.delta","delta":"thinking"}` + "\n\n" + + `data: {"type":"response.completed","response":{"usage":{"output_tokens":1}}}` + "\n\n" + marked := 0 + _, err := copyStream(context.Writer, strings.NewReader(body), streamProtocolResponses, func() { + marked++ + if !recorder.Flushed || !strings.Contains(recorder.Body.String(), `"delta":"thinking"`) { + t.Fatalf("first token was marked before the generated delta was flushed: flushed=%v body=%q", recorder.Flushed, recorder.Body.String()) + } + }) + if err != nil { + t.Fatal(err) + } + if marked != 1 { + t.Fatalf("first token marked %d times", marked) + } +} + +func BenchmarkFirstTokenInspection(b *testing.B) { + tests := []struct { + name string + protocol streamProtocol + data []byte + }{ + {name: "responses", protocol: streamProtocolResponses, data: []byte(`{"type":"response.output_text.delta","delta":"hello"}`)}, + {name: "responses custom tool", protocol: streamProtocolResponses, data: []byte(`{"type":"response.custom_tool_call_input.delta","delta":"{}"}`)}, + {name: "chat", protocol: streamProtocolChat, data: []byte(`{"choices":[{"delta":{"content":"hello"}}]}`)}, + {name: "anthropic", protocol: streamProtocolAnthropic, data: []byte(`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}`)}, + } + for _, test := range tests { + b.Run(test.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if !containsGeneratedDelta(test.data, test.protocol) { + b.Fatal("generated delta not detected") + } + } + }) + } +} + +func TestAnthropicUsageReconstructsCacheCreationAndSaturates(t *testing.T) { + metadata := responseMetadata{ + Usage: gateway.Usage{InputTokens: 20, CachedInputTokens: 70, OutputTokens: 5}, + cacheCreationInputTokens: 10, + } + usage := normalizeMetadataUsage(metadata, streamProtocolAnthropic).Usage + if usage.InputTokens != 100 || usage.CachedInputTokens != 70 || usage.TotalTokens != 105 { + t.Fatalf("anthropic reconstructed usage = %#v", usage) + } + + overflow := responseMetadata{Usage: gateway.Usage{InputTokens: math.MaxInt64, CachedInputTokens: 1, OutputTokens: 1}} + usage = normalizeMetadataUsage(overflow, streamProtocolAnthropic).Usage + if usage.InputTokens != math.MaxInt64 || usage.TotalTokens != math.MaxInt64 { + t.Fatalf("anthropic saturated usage = %#v", usage) + } +} + +func TestCopyJSONReconstructsAnthropicTotalInputForAudit(t *testing.T) { + payload := []byte(`{"id":"msg_1","type":"message","model":"grok-4.5","usage":{"input_tokens":10899,"output_tokens":227,"cache_creation_input_tokens":0,"cache_read_input_tokens":229504}}`) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + + metadata, err := copyJSON(context.Writer, bytes.NewReader(payload), streamProtocolAnthropic) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(recorder.Body.Bytes(), payload) { + t.Fatalf("forwarded body = %s", recorder.Body.String()) + } + usage := metadata.Usage + if usage.InputTokens != 240403 || usage.CachedInputTokens != 229504 || usage.OutputTokens != 227 || usage.TotalTokens != 240630 { + t.Fatalf("anthropic audit usage = %#v", usage) + } +} + +func TestStreamInspectorAcceptsChatCachedOnlyFrame(t *testing.T) { + inspector := &responseInspector{protocol: streamProtocolChat} + inspector.Inspect([]byte("data: {\"usage\":{\"prompt_tokens\":40,\"completion_tokens\":5,\"total_tokens\":45,\"prompt_tokens_details\":{\"cached_tokens\":25}}}\n\n")) + inspector.Inspect([]byte("data: [DONE]\n\n")) + inspector.Finish() + usage := inspector.Metadata().Usage + if usage.CachedInputTokens != 25 || usage.InputTokens != 40 || usage.TotalTokens != 45 { + t.Fatalf("chat stream cached usage = %#v", usage) + } +} + +func TestUsageInspectorHandlesChunkedSSE(t *testing.T) { + inspector := &responseInspector{} + inspector.Inspect([]byte("data: {\"response\":{\"id\":\"resp_stream\",\"usage\":{\"input_tokens\":2,")) + inspector.Inspect([]byte("\"output_tokens\":3}}}\n\n")) + metadata := inspector.Metadata() + usage := metadata.Usage + if usage.TotalTokens != 5 { + t.Fatalf("usage = %#v", usage) + } + if metadata.ResponseID != "resp_stream" { + t.Fatalf("response ID = %q", metadata.ResponseID) + } +} + +func TestUsageInspectorHandlesFinalEventWithoutNewline(t *testing.T) { + inspector := &responseInspector{} + inspector.Inspect([]byte(`data: {"response":{"id":"resp_final","usage":{"input_tokens":7,"output_tokens":4}}}`)) + inspector.Finish() + metadata := inspector.Metadata() + if metadata.ResponseID != "resp_final" || metadata.Usage.TotalTokens != 11 { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestCopyStreamRequiresProtocolTerminalEvent(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + protocol streamProtocol + body string + wantErr error + wantDiagnostic bool + }{ + { + name: "responses completed", protocol: streamProtocolResponses, + body: `data: {"type":"response.completed","response":{"id":"resp_ok","usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}` + "\n\n", + }, + { + name: "responses eof before completed", protocol: streamProtocolResponses, + body: `data: {"type":"response.created","response":{"id":"resp_cut"}}` + "\n\n", + wantErr: errUpstreamStreamIncomplete, + }, + { + name: "responses failed", protocol: streamProtocolResponses, + body: `data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","error":{"code":"upstream_error","message":"failed"},"output":[{"type":"reasoning","encrypted_content":"must-not-be-audited"}]}}` + "\n\n", + wantErr: errUpstreamStreamFailed, wantDiagnostic: true, + }, + {name: "chat done", protocol: streamProtocolChat, body: "data: [DONE]\n\n"}, + {name: "chat error", protocol: streamProtocolChat, body: `data: {"type":"error","error":{"code":"server_error","message":"chat failed"}}` + "\n\n", wantErr: errUpstreamStreamFailed, wantDiagnostic: true}, + {name: "anthropic stop", protocol: streamProtocolAnthropic, body: `data: {"type":"message_stop"}` + "\n\n"}, + {name: "anthropic error", protocol: streamProtocolAnthropic, body: `data: {"type":"error","error":{"type":"api_error","message":"messages failed"}}` + "\n\n", wantErr: errUpstreamStreamFailed, wantDiagnostic: true}, + {name: "image completed", protocol: streamProtocolImage, body: `data: {"type":"image_generation.completed"}` + "\n\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + metadata, err := copyStream(context.Writer, strings.NewReader(test.body), test.protocol, nil) + if test.wantErr == nil && err != nil { + t.Fatal(err) + } + if test.wantErr != nil && !errors.Is(err, test.wantErr) { + t.Fatalf("error = %#v, want %v", err, test.wantErr) + } + if test.name == "responses completed" && (metadata.ResponseID != "resp_ok" || metadata.Usage.TotalTokens != 5) { + t.Fatalf("metadata = %#v", metadata) + } + if test.wantDiagnostic { + if metadata.StreamFailure == nil || !strings.Contains(string(metadata.StreamFailure.Body), "failed") || strings.Contains(string(metadata.StreamFailure.Body), "must-not-be-audited") { + t.Fatalf("stream failure diagnostic = %#v", metadata.StreamFailure) + } + } else if metadata.StreamFailure != nil { + t.Fatalf("unexpected stream failure diagnostic = %#v", metadata.StreamFailure) + } + if recorder.Body.String() != test.body { + t.Fatalf("forwarded = %q", recorder.Body.String()) + } + }) + } +} + +func TestWriteResultRecordsStreamFailureDiagnostic(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := NewHandler(nil, nil, 1<<20) + stream := `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"server_error","message":"upstream failed"}}}` + "\n\n" + var finalCode string + var diagnostic *gateway.StreamFailureDiagnostic + result := &gateway.Result{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(stream)), + RecordStreamFailure: func(value gateway.StreamFailureDiagnostic) { + diagnostic = &value + }, + Finalize: func(_ gateway.Usage, _, code string) { + finalCode = code + }, + } + router := gin.New() + router.GET("/", func(c *gin.Context) { + handler.writeResult(c, result, true, streamProtocolResponses) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusOK || recorder.Body.String() != stream || finalCode != "upstream_stream_error" { + t.Fatalf("status=%d body=%q final=%q", recorder.Code, recorder.Body.String(), finalCode) + } + if diagnostic == nil || !strings.Contains(string(diagnostic.Body), `"code":"server_error"`) { + t.Fatalf("diagnostic = %#v", diagnostic) + } +} + +func TestProjectStreamFailureDiagnosticBoundsErrorMessage(t *testing.T) { + diagnostic := projectStreamFailureDiagnostic([]byte(`{"type":"error","error":{"code":"server_error","message":"` + strings.Repeat("错误", maxStreamFailureDiagnosticBytes) + `"},"output":"must-not-be-audited"}`)) + if !diagnostic.BodyTruncated || len(diagnostic.Body) > maxStreamFailureDiagnosticBytes || len(diagnostic.Body) == 0 || !utf8.Valid(diagnostic.Body) || strings.Contains(string(diagnostic.Body), "must-not-be-audited") { + t.Fatalf("diagnostic length=%d truncated=%v", len(diagnostic.Body), diagnostic.BodyTruncated) + } +} + +func TestExtractMetadataPreservesLargeCostTicks(t *testing.T) { + metadata := extractMetadata([]byte(`{"id":"resp_cost","model":"grok-4.5","usage":{"input_tokens":1,"output_tokens":1,"cost_in_usd_ticks":9007199254740993}}`)) + if metadata.Usage.CostInUSDTicks != 9_007_199_254_740_993 { + t.Fatalf("cost ticks = %d", metadata.Usage.CostInUSDTicks) + } +} + +func TestCopyHeadersFiltersHopByHopAndUpstreamCookies(t *testing.T) { + source := http.Header{ + "Connection": {"X-Upstream-Internal"}, + "Content-Type": {"application/json"}, + "Set-Cookie": {"upstream_session=secret"}, + "X-Models-Etag": {`"upstream-account-catalog"`}, + "X-Request-Id": {"req_123"}, + "X-Upstream-Internal": {"hidden"}, + } + destination := make(http.Header) + + copyHeaders(destination, source) + + if destination.Get("Content-Type") != "application/json" || destination.Get("X-Request-Id") != "req_123" { + t.Fatalf("forwarded headers = %#v", destination) + } + if destination.Get("Set-Cookie") != "" || destination.Get("X-Models-Etag") != "" || destination.Get("X-Upstream-Internal") != "" || destination.Get("Connection") != "" { + t.Fatalf("filtered headers leaked = %#v", destination) + } +} + +func TestCopyJSONForwardsBodyBeyondMetadataInspectionLimit(t *testing.T) { + payload := make([]byte, 0, maxJSONMetadataInspectionBytes+1024) + payload = append(payload, []byte(`{"padding":"`)...) + payload = append(payload, bytes.Repeat([]byte("a"), maxJSONMetadataInspectionBytes)...) + payload = append(payload, []byte(`"}`)...) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + + metadata, err := copyJSON(context.Writer, bytes.NewReader(payload), streamProtocolResponses) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(recorder.Body.Bytes(), payload) { + t.Fatalf("forwarded body size = %d, want %d", recorder.Body.Len(), len(payload)) + } + if metadata.ResponseID != "" || metadata.Usage.TotalTokens != 0 { + t.Fatalf("metadata should be skipped after inspection limit: %#v", metadata) + } +} + +func TestCopyMediaRejectsUnknownLengthOverflowWithoutWritingPastLimit(t *testing.T) { + payload := bytes.Repeat([]byte("v"), 33) + var destination bytes.Buffer + err := copyMedia(&destination, bytes.NewReader(payload), 32) + if !errors.Is(err, errResponseTransferLimit) { + t.Fatalf("copy error = %v", err) + } + if destination.Len() != 32 { + t.Fatalf("forwarded media size = %d", destination.Len()) + } +} + +func TestCopyMediaAllowsExactLimit(t *testing.T) { + payload := bytes.Repeat([]byte("v"), 32) + var destination bytes.Buffer + if err := copyMedia(&destination, bytes.NewReader(payload), 32); err != nil { + t.Fatal(err) + } + if !bytes.Equal(destination.Bytes(), payload) { + t.Fatalf("forwarded media = %q", destination.Bytes()) + } +} + +func TestSelectionErrorResponseDistinguishesCoolingAndSaturation(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, test := range []struct { + name string + failure *gateway.SelectionUnavailableError + status int + code string + retryAfter string + }{ + {name: "cooling", failure: &gateway.SelectionUnavailableError{Reason: gateway.SelectionCooling, RetryAfter: 1500 * time.Millisecond}, status: http.StatusTooManyRequests, code: "upstream_cooling", retryAfter: "2"}, + {name: "model cooling", failure: &gateway.SelectionUnavailableError{Reason: gateway.SelectionModelCooling, RetryAfter: time.Second}, status: http.StatusTooManyRequests, code: "upstream_model_cooling", retryAfter: "1"}, + {name: "saturated", failure: &gateway.SelectionUnavailableError{Reason: gateway.SelectionSaturated, RetryAfter: time.Second}, status: http.StatusServiceUnavailable, code: "upstream_saturated", retryAfter: "1"}, + {name: "scoped account range", failure: &gateway.SelectionUnavailableError{Reason: gateway.SelectionNoAccounts, Scope: clientkeydomain.AccountScope{Providers: clientkeydomain.ProviderScopeBuild, Tiers: clientkeydomain.TierScopeFree}}, status: http.StatusServiceUnavailable, code: "client_key_account_scope_unavailable"}, + } { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + status, code, _ := selectionErrorResponse(context, test.failure) + if status != test.status || code != test.code || recorder.Header().Get("Retry-After") != test.retryAfter { + t.Fatalf("status=%d code=%q retry-after=%q", status, code, recorder.Header().Get("Retry-After")) + } + }) + } +} diff --git a/backend/internal/transport/http/inference/model_list_test.go b/backend/internal/transport/http/inference/model_list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..639739e85c1c8d99935a8900c6c094873d057842 --- /dev/null +++ b/backend/internal/transport/http/inference/model_list_test.go @@ -0,0 +1,191 @@ +package inference + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + clientkeydomain "github.com/chenyme/grok2api/backend/internal/domain/clientkey" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/gin-gonic/gin" +) + +func TestNewModelListItemsDeduplicatesSharedPublicName(t *testing.T) { + now := time.Unix(100, 0).UTC() + items := newModelListItems([]modeldomain.Route{ + {PublicID: "Build/grok-shared", Provider: account.ProviderBuild, CreatedAt: now}, + {PublicID: "Console/grok-shared", Provider: account.ProviderConsole, CreatedAt: now.Add(time.Second)}, + {PublicID: "Web/grok-chat-fast", Provider: account.ProviderWeb, CreatedAt: now}, + }) + if len(items) != 2 || items[0].ID != "grok-shared" || items[1].ID != "grok-chat-fast" { + t.Fatalf("model list = %#v", items) + } +} + +func TestFilterModelRoutesForClientKeyUsesProviderAndModelIntersection(t *testing.T) { + routes := []modeldomain.Route{ + {ID: 1, Provider: account.ProviderBuild, PublicID: "Build/grok-shared"}, + {ID: 2, Provider: account.ProviderWeb, PublicID: "Web/grok-shared"}, + {ID: 3, Provider: account.ProviderConsole, PublicID: "Console/grok-shared"}, + } + key := clientkeydomain.Key{ProviderScope: clientkeydomain.ProviderScopeWeb | clientkeydomain.ProviderScopeConsole, AllowedModels: []uint64{2, 3}} + filtered := filterModelRoutesForClientKey(routes, key) + if len(filtered) != 2 || filtered[0].ID != 2 || filtered[1].ID != 3 { + t.Fatalf("filtered routes = %#v", filtered) + } +} + +func TestAppendReasoningModelAliasesUsesRealSupportedLevels(t *testing.T) { + now := time.Unix(100, 0).UTC() + base := newModelListItems([]modeldomain.Route{ + {PublicID: "Build/grok-4.5", Provider: account.ProviderBuild, Capability: modeldomain.CapabilityResponses, CreatedAt: now}, + {PublicID: "Console/grok-4.3", Provider: account.ProviderConsole, Capability: modeldomain.CapabilityResponses, CreatedAt: now}, + {PublicID: "Build/grok-build-0.1", Provider: account.ProviderBuild, Capability: modeldomain.CapabilityResponses, CreatedAt: now}, + }) + expanded := appendReasoningModelAliases(base) + ids := make(map[string]bool, len(expanded)) + for _, item := range expanded { + ids[item.ID] = true + } + for _, want := range []string{"grok-4.5", "grok-4.5-low", "grok-4.5-medium", "grok-4.5-high", "grok-4.3-none", "grok-4.3-low", "grok-4.3-medium", "grok-4.3-high", "grok-build-0.1"} { + if !ids[want] { + t.Fatalf("missing model %q in %#v", want, expanded) + } + } + for _, reject := range []string{"grok-4.5-none", "grok-4.5-xhigh", "grok-4.5-max", "grok-4.3-xhigh", "grok-build-0.1-none"} { + if ids[reject] { + t.Fatalf("unexpected unsupported alias %q", reject) + } + } +} + +func TestNewCodexModelCatalogIncludesRequiredProtocolFields(t *testing.T) { + now := time.Unix(100, 0).UTC() + items := newModelListItems([]modeldomain.Route{ + {PublicID: "Build/grok-4.5", Provider: account.ProviderBuild, Capability: modeldomain.CapabilityResponses, CreatedAt: now}, + }) + catalog := newCodexModelCatalog(items) + if len(catalog.Models) != 1 || catalog.Models[0].Slug != "grok-4.5" { + t.Fatalf("models = %#v", catalog.Models) + } + entry := catalog.Models[0] + if entry.DisplayName != "Grok 4.5" { + t.Fatalf("display_name = %q", entry.DisplayName) + } + if len(entry.SupportedReasoningLevels) != 3 || entry.SupportedReasoningLevels[0].Effort != "low" { + t.Fatalf("reasoning levels = %#v", entry.SupportedReasoningLevels) + } + if entry.ContextWindow != 500000 || entry.MaxContextWindow != 500000 { + t.Fatalf("context window = %d/%d", entry.ContextWindow, entry.MaxContextWindow) + } + if entry.EffectiveContextWindowPercent != 95 { + t.Fatalf("effective context window percent = %d, want 95", entry.EffectiveContextWindowPercent) + } + if entry.DefaultReasoningLevel != "medium" { + t.Fatalf("default reasoning = %q, want medium", entry.DefaultReasoningLevel) + } + if entry.BaseInstructions == "" || entry.ExperimentalSupportedTools == nil { + t.Fatalf("required fields missing: %#v", entry) + } + if entry.ApplyPatchToolType == nil || !entry.SupportsParallelToolCalls { + t.Fatalf("Build tool metadata = %#v", entry) + } + body, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + var envelope map[string]json.RawMessage + if err = json.Unmarshal(body, &envelope); err != nil || len(envelope) != 1 || envelope["models"] == nil { + t.Fatalf("catalog envelope = %s, err = %v", body, err) + } +} + +func TestNewCodexModelCacheRespectsNonReasoningModel(t *testing.T) { + now := time.Unix(100, 0).UTC() + items := newModelListItems([]modeldomain.Route{ + {PublicID: "Build/grok-build-0.1", Provider: account.ProviderBuild, Capability: modeldomain.CapabilityResponses, CreatedAt: now}, + }) + entry := newCodexModelCatalog(items).Models[0] + if entry.DefaultReasoningLevel != "none" { + t.Fatalf("default reasoning = %q", entry.DefaultReasoningLevel) + } + if len(entry.SupportedReasoningLevels) != 1 || entry.SupportedReasoningLevels[0].Effort != "none" { + t.Fatalf("reasoning levels = %#v", entry.SupportedReasoningLevels) + } + if entry.ContextWindow != 256000 { + t.Fatalf("context window = %d", entry.ContextWindow) + } +} + +func TestCodexCatalogHidesMediaModels(t *testing.T) { + now := time.Unix(100, 0).UTC() + routes := []modeldomain.Route{ + {PublicID: "Build/grok-4.5", Provider: account.ProviderBuild, Capability: modeldomain.CapabilityResponses, CreatedAt: now}, + {PublicID: "Web/grok-imagine-image", Provider: account.ProviderWeb, Capability: modeldomain.CapabilityImage, CreatedAt: now}, + {PublicID: "Web/grok-imagine-video", Provider: account.ProviderWeb, Capability: modeldomain.CapabilityVideo, CreatedAt: now}, + } + catalog := newCodexModelCatalog(newModelListItems(routes)) + if len(catalog.Models) != 3 { + t.Fatalf("model count = %d, want 3; slugs = %#v", len(catalog.Models), codexSlugs(catalog.Models)) + } + for _, entry := range catalog.Models { + switch entry.Slug { + case "grok-4.5": + if entry.Visibility != "list" { + t.Fatalf("visibility for %s = %q, want list", entry.Slug, entry.Visibility) + } + case "grok-imagine-image", "grok-imagine-video": + if entry.Visibility != "hide" { + t.Fatalf("visibility for %s = %q, want hide", entry.Slug, entry.Visibility) + } + } + } +} + +func TestCodexCatalogUsesConservativeUnknownModelDefaults(t *testing.T) { + entry := newCodexModelCatalog([]modelListItem{{ + ID: "custom-model", Provider: account.ProviderWeb, Capability: modeldomain.CapabilityChat, + }}).Models[0] + if entry.ContextWindow != 128000 || entry.DefaultReasoningLevel != "none" { + t.Fatalf("unknown model metadata = %#v", entry) + } + if len(entry.InputModalities) != 1 || entry.InputModalities[0] != "text" || entry.ApplyPatchToolType != nil || entry.SupportsParallelToolCalls || entry.SupportsSearchTool || entry.SupportVerbosity { + t.Fatalf("unknown model over-advertises capabilities: %#v", entry) + } +} + +func TestWriteCodexModelCatalogSetsETagAndHandlesNotModified(t *testing.T) { + gin.SetMode(gin.TestMode) + catalog := newCodexModelCatalog([]modelListItem{{ + ID: "grok-4.5", Provider: account.ProviderBuild, Capability: modeldomain.CapabilityResponses, + }}) + router := gin.New() + router.GET("/v1/models", func(c *gin.Context) { writeCodexModelCatalog(c, catalog) }) + + first := httptest.NewRecorder() + firstRequest := httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.145.0", nil) + router.ServeHTTP(first, firstRequest) + etag := first.Header().Get("ETag") + if first.Code != http.StatusOK || etag == "" { + t.Fatalf("first response = %d, etag = %q", first.Code, etag) + } + + second := httptest.NewRecorder() + secondRequest := httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.145.0", nil) + secondRequest.Header.Set("If-None-Match", etag) + router.ServeHTTP(second, secondRequest) + if second.Code != http.StatusNotModified || second.Body.Len() != 0 || second.Header().Get("ETag") != etag { + t.Fatalf("conditional response = %d, body = %q, etag = %q", second.Code, second.Body.String(), second.Header().Get("ETag")) + } +} + +func codexSlugs(models []codexModelEntry) []string { + slugs := make([]string, 0, len(models)) + for _, m := range models { + slugs = append(slugs, m.Slug) + } + return slugs +} diff --git a/backend/internal/transport/http/inference/prompt_cache.go b/backend/internal/transport/http/inference/prompt_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..28448e51cb2d8d807cdb948b5afd80ad9725ee41 --- /dev/null +++ b/backend/internal/transport/http/inference/prompt_cache.go @@ -0,0 +1,242 @@ +package inference + +import ( + "encoding/json" + "net/http" + "strings" +) + +const ( + maxPromptCacheSeedBytes = 1024 + maxCodexTurnMetadataSize = 16 << 10 +) + +// extractPromptCacheSeed extracts the client session identifier; the gateway isolates and hashes the key sent upstream. +// It supports Claude Code, Codex, and OpenAI-compatible session_id, conversation_id, and prompt_cache_key signals. +func extractPromptCacheSeed(headers http.Header, body []byte) string { + // Claude Code sends auxiliary requests such as title generation concurrently and reuses the main session ID. + // If an auxiliary request uses the explicit session seed, it shares reasoning replay with the main conversation. + // Falling back to soft identity preserves account affinity without allowing encrypted reasoning to leak across requests. + if isClaudeCodeTitleRequest(headers, body) { + return "" + } + if headers != nil { + // Prefer standard session signals from Claude Code, Codex, and OpenAI-compatible clients. + if seed := normalizePromptCacheSeed(headers.Get("X-Claude-Code-Session-Id")); seed != "" { + return claudeCodePromptCacheSeed(seed, headers) + } + if seed := codexPromptCacheSeedFromHeaders(headers); seed != "" { + return seed + } + for _, name := range []string{ + "X-Session-Id", "Session-Id", "Session_id", + "X-Conversation-Id", "Conversation-Id", "Conversation_id", + // Support session signals forwarded by reverse proxies. + "X-Client-Session-Id", "X-Grok-Conv-Id", + } { + if seed := normalizePromptCacheSeed(headers.Get(name)); seed != "" { + return seed + } + } + } + var payload struct { + PromptCacheKey string `json:"prompt_cache_key"` + ConversationID string `json:"conversation_id"` + ConversationIDCamel string `json:"conversationId"` + SessionID string `json:"session_id"` + SessionIDCamel string `json:"sessionId"` + Metadata struct { + SessionID string `json:"session_id"` + SessionIDCamel string `json:"sessionId"` + UserID string `json:"user_id"` + } `json:"metadata"` + ClientMetadata map[string]json.RawMessage `json:"client_metadata"` + } + if json.Unmarshal(body, &payload) != nil { + return "" + } + // The handler also writes body.prompt_cache_key to PromptCacheKey. Extract it here as well so middleware + // and logs that depend only on the seed path can observe it. + if seed := normalizePromptCacheSeed(payload.PromptCacheKey); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(payload.Metadata.SessionID); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(payload.Metadata.SessionIDCamel); seed != "" { + return seed + } + if seed := promptCacheSeedFromUserID(payload.Metadata.UserID); seed != "" { + return claudeCodePromptCacheSeed(seed, headers) + } + if seed := codexPromptCacheSeedFromRawTurnMetadata(payload.ClientMetadata["x-codex-turn-metadata"]); seed != "" { + return seed + } + if seed := normalizeRawPromptCacheSeed(payload.ClientMetadata["x-codex-window-id"]); seed != "" { + return "codex:window:" + seed + } + if seed := normalizePromptCacheSeed(payload.SessionID); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(payload.SessionIDCamel); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(payload.ConversationID); seed != "" { + return seed + } + return normalizePromptCacheSeed(payload.ConversationIDCamel) +} + +func claudeCodePromptCacheSeed(sessionID string, headers http.Header) string { + sessionID = normalizePromptCacheSeed(sessionID) + if sessionID == "" { + return "" + } + agentID := "main" + if headers != nil { + if value := normalizePromptCacheSeed(headers.Get("X-Claude-Code-Agent-Id")); value != "" { + agentID = value + } + } + return "claude:" + sessionID + ":agent:" + agentID +} + +func codexPromptCacheSeedFromHeaders(headers http.Header) string { + if headers == nil { + return "" + } + if seed := codexPromptCacheSeedFromTurnMetadata(headers.Get("X-Codex-Turn-Metadata")); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(headers.Get("X-Codex-Window-Id")); seed != "" { + return "codex:window:" + seed + } + return "" +} + +func codexPromptCacheSeedFromTurnMetadata(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > maxCodexTurnMetadataSize { + return "" + } + var metadata struct { + PromptCacheKey string `json:"prompt_cache_key"` + WindowID string `json:"window_id"` + } + if json.Unmarshal([]byte(value), &metadata) != nil { + return "" + } + if seed := normalizePromptCacheSeed(metadata.PromptCacheKey); seed != "" { + // Keep the same seed as body.prompt_cache_key so a Codex session does not rotate its upstream cache identity + // when the signal source changes. + return seed + } + if seed := normalizePromptCacheSeed(metadata.WindowID); seed != "" { + return "codex:window:" + seed + } + return "" +} + +func codexPromptCacheSeedFromRawTurnMetadata(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var value string + if json.Unmarshal(raw, &value) == nil { + return codexPromptCacheSeedFromTurnMetadata(value) + } + return codexPromptCacheSeedFromTurnMetadata(string(raw)) +} + +func normalizeRawPromptCacheSeed(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var value string + if json.Unmarshal(raw, &value) != nil { + return "" + } + return normalizePromptCacheSeed(value) +} + +func allowBuildClientToolCacheRoute(headers http.Header) bool { + if headers == nil { + return false + } + if normalizePromptCacheSeed(headers.Get("X-Claude-Code-Session-Id")) != "" { + return true + } + if codexPromptCacheSeedFromHeaders(headers) != "" { + return true + } + return strings.Contains(strings.ToLower(headers.Get("User-Agent")), "codex") +} + +func isClaudeCodeTitleRequest(headers http.Header, body []byte) bool { + if headers == nil || normalizePromptCacheSeed(headers.Get("X-Claude-Code-Session-Id")) == "" || len(body) == 0 { + return false + } + var payload struct { + System json.RawMessage `json:"system"` + } + if json.Unmarshal(body, &payload) != nil || len(payload.System) == 0 { + return false + } + var texts []string + var text string + if json.Unmarshal(payload.System, &text) == nil { + texts = append(texts, text) + } else { + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(payload.System, &blocks) != nil { + return false + } + for _, block := range blocks { + if block.Type == "text" { + texts = append(texts, block.Text) + } + } + } + for _, value := range texts { + value = strings.ToLower(strings.TrimSpace(value)) + if strings.Contains(value, "generate a concise") && strings.Contains(value, "title") && strings.Contains(value, "coding session") { + return true + } + } + return false +} + +func promptCacheSeedFromUserID(userID string) string { + userID = strings.TrimSpace(userID) + if userID == "" { + return "" + } + var embedded struct { + SessionID string `json:"session_id"` + SessionIDCamel string `json:"sessionId"` + } + if json.Unmarshal([]byte(userID), &embedded) == nil { + if seed := normalizePromptCacheSeed(embedded.SessionID); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(embedded.SessionIDCamel); seed != "" { + return seed + } + } + const marker = "_session_" + if index := strings.LastIndex(userID, marker); index >= 0 { + return normalizePromptCacheSeed(userID[index+len(marker):]) + } + return "" +} + +func normalizePromptCacheSeed(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > maxPromptCacheSeedBytes { + return "" + } + return value +} diff --git a/backend/internal/transport/http/inference/prompt_cache_test.go b/backend/internal/transport/http/inference/prompt_cache_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b5f9c351a59730384c4e958df854f25866c8fb43 --- /dev/null +++ b/backend/internal/transport/http/inference/prompt_cache_test.go @@ -0,0 +1,89 @@ +package inference + +import ( + "net/http" + "strings" + "testing" +) + +func TestExtractPromptCacheSeedSupportsClaudeCodeForms(t *testing.T) { + tests := []struct { + name string + headers http.Header + body string + want string + }{ + {name: "claude header", headers: http.Header{"X-Claude-Code-Session-Id": {"claude-session"}, "X-Session-Id": {"generic-session"}}, body: `{"metadata":{"session_id":"body-session"}}`, want: "claude:claude-session:agent:main"}, + {name: "claude agent header", headers: http.Header{"X-Claude-Code-Session-Id": {"claude-session"}, "X-Claude-Code-Agent-Id": {"agent-7"}}, want: "claude:claude-session:agent:agent-7"}, + {name: "codex turn prompt cache", headers: http.Header{"X-Codex-Turn-Metadata": {`{"prompt_cache_key":"codex-cache","window_id":"window-1"}`}}, want: "codex-cache"}, + {name: "codex turn window", headers: http.Header{"X-Codex-Turn-Metadata": {`{"window_id":"window-1"}`}}, want: "codex:window:window-1"}, + {name: "codex window header", headers: http.Header{"X-Codex-Window-Id": {"window-2"}}, want: "codex:window:window-2"}, + {name: "generic header", headers: http.Header{"X-Session-Id": {"generic-session"}}, want: "generic-session"}, + {name: "codex hyphen header", headers: http.Header{"Session-Id": {"codex-session"}}, want: "codex-session"}, + {name: "underscore session header", headers: http.Header{"Session_id": {"underscore-session"}}, want: "underscore-session"}, + {name: "metadata snake case", body: `{"metadata":{"session_id":"snake-session"}}`, want: "snake-session"}, + {name: "metadata camel case", body: `{"metadata":{"sessionId":"camel-session"}}`, want: "camel-session"}, + {name: "embedded json user id", body: `{"metadata":{"user_id":"{\"device_id\":\"d1\",\"session_id\":\"embedded-session\"}"}}`, want: "claude:embedded-session:agent:main"}, + {name: "suffix user id", body: `{"metadata":{"user_id":"user_account_session_123e4567-e89b-12d3-a456-426614174000"}}`, want: "claude:123e4567-e89b-12d3-a456-426614174000:agent:main"}, + {name: "conversation snake case", body: `{"conversation_id":"conversation-session"}`, want: "conversation-session"}, + {name: "conversation camel case", body: `{"conversationId":"camel-conversation"}`, want: "camel-conversation"}, + {name: "body prompt_cache_key", body: `{"prompt_cache_key":"client-session"}`, want: "client-session"}, + {name: "body codex turn metadata", body: `{"client_metadata":{"x-codex-turn-metadata":"{\"prompt_cache_key\":\"body-codex-cache\"}"}}`, want: "body-codex-cache"}, + {name: "body codex turn metadata object", body: `{"client_metadata":{"x-codex-turn-metadata":{"prompt_cache_key":"body-codex-object"}}}`, want: "body-codex-object"}, + {name: "body codex window", body: `{"client_metadata":{"x-codex-window-id":"body-window"}}`, want: "codex:window:body-window"}, + // http.Header canonicalizes "session_id" to Session-Id. + {name: "compatible client session_id via Set", headers: func() http.Header { h := make(http.Header); h.Set("session_id", "client-header-session"); return h }(), want: "client-header-session"}, + {name: "grok conv header", headers: http.Header{"X-Grok-Conv-Id": {"grok-conv-session"}}, want: "grok-conv-session"}, + {name: "per request id ignored", headers: http.Header{"X-Client-Request-Id": {"request-123"}}, want: ""}, + {name: "ordinary user id", body: `{"metadata":{"user_id":"user-123"}}`, want: ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := extractPromptCacheSeed(test.headers, []byte(test.body)); got != test.want { + t.Fatalf("seed = %q, want %q", got, test.want) + } + }) + } +} + +func TestExtractPromptCacheSeedIsolatesClaudeCodeTitleRequest(t *testing.T) { + headers := http.Header{"X-Claude-Code-Session-Id": {"claude-session"}} + body := []byte(`{ + "system":[ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.170; cch=abcde;"}, + {"type":"text","text":"Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session."} + ], + "messages":[{"role":"user","content":"hello"}] + }`) + if seed := extractPromptCacheSeed(headers, body); seed != "" { + t.Fatalf("title request seed = %q, want empty explicit seed", seed) + } +} + +func TestExtractPromptCacheSeedRejectsOversizedValues(t *testing.T) { + headers := make(http.Header) + headers.Set("X-Claude-Code-Session-Id", strings.Repeat("x", maxPromptCacheSeedBytes+1)) + if seed := extractPromptCacheSeed(headers, nil); seed != "" { + t.Fatalf("oversized seed = %q", seed) + } +} + +func TestAllowBuildClientToolCacheRouteUsesKnownCLISignals(t *testing.T) { + tests := []struct { + name string + headers http.Header + want bool + }{ + {name: "claude", headers: http.Header{"X-Claude-Code-Session-Id": {"session"}}, want: true}, + {name: "codex metadata", headers: http.Header{"X-Codex-Turn-Metadata": {`{"window_id":"window-1"}`}}, want: true}, + {name: "codex user agent", headers: http.Header{"User-Agent": {"Codex Desktop/1.0"}}, want: true}, + {name: "generic", headers: http.Header{"User-Agent": {"custom-client/1.0"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := allowBuildClientToolCacheRoute(test.headers); got != test.want { + t.Fatalf("allowed = %t, want %t", got, test.want) + } + }) + } +} diff --git a/backend/internal/transport/http/media/dto.go b/backend/internal/transport/http/media/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..4c3c8d5875f39944b12a80fc214ea6a6dd6eae80 --- /dev/null +++ b/backend/internal/transport/http/media/dto.go @@ -0,0 +1,41 @@ +package media + +type mediaAssetDTO struct { + ID string `json:"id"` + Kind string `json:"kind"` + MimeType string `json:"mimeType"` + SizeBytes int64 `json:"sizeBytes"` + SHA256 string `json:"sha256"` + CreatedAt string `json:"createdAt"` + URL string `json:"url"` +} + +type imageStatsDTO struct { + TotalImages int64 `json:"totalImages"` + TotalBytes int64 `json:"totalBytes"` +} + +type mediaJobDTO struct { + ID string `json:"id"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Status string `json:"status"` + Progress int `json:"progress"` + Seconds int `json:"seconds"` + Size string `json:"size"` + Quality string `json:"quality"` + AccountName string `json:"accountName"` + ClientKeyName string `json:"clientKeyName"` + CreatedAt string `json:"createdAt"` + CompletedAt *string `json:"completedAt"` + ErrorMessage string `json:"errorMessage"` + AssetID string `json:"assetId"` +} + +type videoStatsDTO struct { + TotalJobs int64 `json:"totalJobs"` + Completed int64 `json:"completed"` + Failed int64 `json:"failed"` + InProgress int64 `json:"inProgress"` + Queued int64 `json:"queued"` +} diff --git a/backend/internal/transport/http/media/handler.go b/backend/internal/transport/http/media/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..b00e2bb6c4f3b451d79a5432ff76ce1c19aa4d9b --- /dev/null +++ b/backend/internal/transport/http/media/handler.go @@ -0,0 +1,240 @@ +package media + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + + mediaapp "github.com/chenyme/grok2api/backend/internal/application/media" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *mediaapp.Service +} + +func NewHandler(service *mediaapp.Service) *Handler { return &Handler{service: service} } + +// RegisterPublic 注册使用不可猜测资源 ID 的公开图片读取与视频上传接收端点。 +// 上传 PUT 不使用客户端 API key:xAI 无法携带,票据本身即授权。 +func (h *Handler) RegisterPublic(router *gin.Engine) { + router.GET("/v1/media/images/:assetId", h.getImage) + router.HEAD("/v1/media/images/:assetId", h.getImage) + router.GET("/v1/media/videos/:assetId", h.getVideo) + router.HEAD("/v1/media/videos/:assetId", h.getVideo) + router.PUT("/v1/media/uploads/:token", h.putVideoUpload) +} + +// RegisterAdmin 注册管理端媒体列表和统计端点。 +func (h *Handler) RegisterAdmin(router *gin.RouterGroup) { + router.GET("/media/images", h.listImages) + router.DELETE("/media/images", h.deleteImages) + router.GET("/media/images/stats", h.imageStats) + router.GET("/media/videos", h.listVideos) + router.DELETE("/media/videos", h.deleteVideos) + router.GET("/media/videos/stats", h.videoStats) +} + +type deleteImagesRequest struct { + IDs []string `json:"ids" binding:"required"` +} + +type deleteVideosRequest struct { + IDs []string `json:"ids" binding:"required"` +} + +func (h *Handler) getImage(c *gin.Context) { + asset, body, err := h.service.OpenImage(c.Request.Context(), c.Param("assetId")) + if errors.Is(err, mediaapp.ErrAssetNotFound) { + c.Status(http.StatusNotFound) + return + } + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + defer body.Close() + etag := `"` + asset.SHA256 + `"` + if strings.TrimSpace(c.GetHeader("If-None-Match")) == etag { + c.Header("ETag", etag) + c.Status(http.StatusNotModified) + return + } + c.Header("Content-Type", asset.MIMEType) + c.Header("Content-Length", strconv.FormatInt(asset.SizeBytes, 10)) + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Header("ETag", etag) + c.Header("X-Content-Type-Options", "nosniff") + if c.Request.Method == http.MethodHead { + c.Status(http.StatusOK) + return + } + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, body) +} + +func (h *Handler) getVideo(c *gin.Context) { + asset, body, err := h.service.OpenVideo(c.Request.Context(), c.Param("assetId")) + if errors.Is(err, mediaapp.ErrAssetNotFound) { + c.Status(http.StatusNotFound) + return + } + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + defer body.Close() + seeker, ok := body.(io.ReadSeeker) + if !ok { + c.Status(http.StatusInternalServerError) + return + } + c.Header("Content-Type", asset.MIMEType) + c.Header("Content-Disposition", `inline; filename="`+asset.ID+`"`) + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Header("ETag", `"`+asset.SHA256+`"`) + c.Header("X-Content-Type-Options", "nosniff") + http.ServeContent(c.Writer, c.Request, asset.ID, asset.CreatedAt, seeker) +} + +// putVideoUpload 接收 XAI ZDR 视频 PUT。响应与错误不得回显完整票据。 +func (h *Handler) putVideoUpload(c *gin.Context) { + _, err := h.service.ReceiveVideoUpload(c.Request.Context(), c.Param("token"), c.GetHeader("Content-Type"), c.Request.Body) + switch { + case err == nil: + c.Status(http.StatusNoContent) + case errors.Is(err, mediaapp.ErrUploadTicketNotFound): + c.Status(http.StatusNotFound) + case errors.Is(err, mediaapp.ErrUploadTicketExpired): + c.Status(http.StatusGone) + case errors.Is(err, mediaapp.ErrUploadTicketConsumed): + c.Status(http.StatusConflict) + case errors.Is(err, mediaapp.ErrVideoUploadTooLarge): + // 体积超限优先于通用无效上传,返回 413。 + c.Status(http.StatusRequestEntityTooLarge) + case errors.Is(err, mediaapp.ErrInvalidVideoUpload): + c.Status(http.StatusBadRequest) + case errors.Is(err, mediaapp.ErrUploadTicketsUnavailable): + c.Status(http.StatusServiceUnavailable) + default: + c.Status(http.StatusInternalServerError) + } +} + +func (h *Handler) listImages(c *gin.Context) { + page, pageSize := parsePagination(c) + assets, total, err := h.service.AdminListImages(c.Request.Context(), page, pageSize, c.Query("search")) + if err != nil { + response.Error(c, http.StatusInternalServerError, "mediaListImagesFailed", "读取图片列表失败") + return + } + items := make([]mediaAssetDTO, 0, len(assets)) + for _, a := range assets { + items = append(items, mediaAssetDTO{ + ID: a.ID, Kind: a.Kind, MimeType: a.MIMEType, SizeBytes: a.SizeBytes, + SHA256: a.SHA256, CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), + URL: h.service.PublicImageURL(a.ID), + }) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total}) +} + +func (h *Handler) imageStats(c *gin.Context) { + stats, err := h.service.AdminImageStats(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusInternalServerError, "mediaImageStatsFailed", "读取图片统计失败") + return + } + response.Success(c, http.StatusOK, imageStatsDTO{TotalImages: stats.TotalImages, TotalBytes: stats.TotalBytes}) +} + +func (h *Handler) deleteImages(c *gin.Context) { + var request deleteImagesRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + deleted, err := h.service.AdminDeleteImages(c.Request.Context(), request.IDs) + if errors.Is(err, mediaapp.ErrInvalidImageSelection) { + response.Error(c, http.StatusBadRequest, "invalidImageSelection", err.Error()) + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "mediaDeleteImagesFailed", "删除图片失败") + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": deleted}) +} + +func (h *Handler) listVideos(c *gin.Context) { + page, pageSize := parsePagination(c) + jobs, total, err := h.service.AdminListVideoJobs(c.Request.Context(), page, pageSize, c.Query("search"), c.Query("status"), repository.SortQuery{Field: c.Query("sortBy"), Direction: repository.SortDirection(c.Query("sortOrder"))}) + if errors.Is(err, mediaapp.ErrInvalidFilter) { + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "mediaListVideosFailed", "读取视频任务列表失败") + return + } + items := make([]mediaJobDTO, 0, len(jobs)) + for _, j := range jobs { + var completedAt *string + assetID := "" + if j.CompletedAt != nil { + formatted := j.CompletedAt.Format("2006-01-02T15:04:05Z") + completedAt = &formatted + } + if j.Status == "completed" { + assetID = j.ResultAssetID + } + items = append(items, mediaJobDTO{ + ID: j.ID, Model: j.Model, Prompt: j.Prompt, Status: string(j.Status), + Progress: j.Progress, Seconds: j.Seconds, Size: j.Size, Quality: j.Quality, + AccountName: j.AccountName, ClientKeyName: j.ClientKeyName, + CreatedAt: j.CreatedAt.Format("2006-01-02T15:04:05Z"), + CompletedAt: completedAt, ErrorMessage: j.ErrorMessage, AssetID: assetID, + }) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total}) +} + +func (h *Handler) videoStats(c *gin.Context) { + stats, err := h.service.AdminVideoStats(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusInternalServerError, "mediaVideoStatsFailed", "读取视频统计失败") + return + } + response.Success(c, http.StatusOK, videoStatsDTO{ + TotalJobs: stats.TotalJobs, Completed: stats.Completed, Failed: stats.Failed, + InProgress: stats.InProgress, Queued: stats.Queued, + }) +} + +func (h *Handler) deleteVideos(c *gin.Context) { + var request deleteVideosRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + deleted, err := h.service.AdminDeleteVideoJobs(c.Request.Context(), request.IDs) + if errors.Is(err, mediaapp.ErrInvalidVideoSelection) || errors.Is(err, mediaapp.ErrActiveVideoSelection) { + response.Error(c, http.StatusBadRequest, "invalidVideoSelection", err.Error()) + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "mediaDeleteVideosFailed", "删除视频任务失败") + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": deleted}) +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + return repository.NormalizePage(page, pageSize, repository.DefaultPageSize) +} diff --git a/backend/internal/transport/http/media/handler_test.go b/backend/internal/transport/http/media/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..37b86296c3feb2e53a7b0d8001a7365f1e1146e6 --- /dev/null +++ b/backend/internal/transport/http/media/handler_test.go @@ -0,0 +1,292 @@ +package media + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + mediaapp "github.com/chenyme/grok2api/backend/internal/application/media" + localmedia "github.com/chenyme/grok2api/backend/internal/infra/media" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/gin-gonic/gin" +) + +func TestPublicImageSupportsGetHeadAndETag(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-http.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects")) + if err != nil { + t.Fatal(err) + } + service := mediaapp.NewService(relational.NewMediaAssetRepository(database), relational.NewMediaJobRepository(database), objects, nil, mediaapp.Config{ + PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, + CleanupThresholdPercent: 80, CleanupInterval: 10 * time.Minute, + }) + raw, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + asset, err := service.SaveImage(ctx, raw) + if err != nil { + t.Fatal(err) + } + router := gin.New() + NewHandler(service).RegisterPublic(router) + path := "/v1/media/images/" + asset.ID + + get := httptest.NewRecorder() + router.ServeHTTP(get, httptest.NewRequest(http.MethodGet, path, nil)) + if get.Code != http.StatusOK || get.Header().Get("Content-Type") != "image/png" || get.Body.Len() != len(raw) || get.Header().Get("ETag") == "" { + t.Fatalf("GET status=%d headers=%#v size=%d", get.Code, get.Header(), get.Body.Len()) + } + head := httptest.NewRecorder() + router.ServeHTTP(head, httptest.NewRequest(http.MethodHead, path, nil)) + if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") == "" { + t.Fatalf("HEAD status=%d headers=%#v size=%d", head.Code, head.Header(), head.Body.Len()) + } + notModifiedRequest := httptest.NewRequest(http.MethodGet, path, nil) + notModifiedRequest.Header.Set("If-None-Match", get.Header().Get("ETag")) + notModified := httptest.NewRecorder() + router.ServeHTTP(notModified, notModifiedRequest) + if notModified.Code != http.StatusNotModified || notModified.Body.Len() != 0 { + t.Fatalf("conditional GET status=%d size=%d", notModified.Code, notModified.Body.Len()) + } +} + +func TestPublicVideoAssetSupportsGetHeadAndRange(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-video-http.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "video-objects")) + if err != nil { + t.Fatal(err) + } + service := mediaapp.NewService(relational.NewMediaAssetRepository(database), relational.NewMediaJobRepository(database), objects, nil, mediaapp.Config{ + PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, + CleanupThresholdPercent: 80, CleanupInterval: 10 * time.Minute, + }) + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x03}, 64)...) + asset, err := service.SaveVideo(ctx, "", "video/mp4", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + router := gin.New() + NewHandler(service).RegisterPublic(router) + path := "/v1/media/videos/" + asset.ID + + get := httptest.NewRecorder() + router.ServeHTTP(get, httptest.NewRequest(http.MethodGet, path, nil)) + if get.Code != http.StatusOK || get.Body.Len() != len(payload) || get.Header().Get("Content-Type") != "video/mp4" || get.Header().Get("ETag") == "" { + t.Fatalf("GET status=%d size=%d headers=%#v", get.Code, get.Body.Len(), get.Header()) + } + head := httptest.NewRecorder() + router.ServeHTTP(head, httptest.NewRequest(http.MethodHead, path, nil)) + if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") == "" { + t.Fatalf("HEAD status=%d size=%d headers=%#v", head.Code, head.Body.Len(), head.Header()) + } + rangeRequest := httptest.NewRequest(http.MethodGet, path, nil) + rangeRequest.Header.Set("Range", "bytes=0-3") + partial := httptest.NewRecorder() + router.ServeHTTP(partial, rangeRequest) + if partial.Code != http.StatusPartialContent || partial.Body.Len() != 4 || partial.Header().Get("Content-Range") == "" { + t.Fatalf("Range status=%d size=%d headers=%#v", partial.Code, partial.Body.Len(), partial.Header()) + } +} + +func TestAdminDeleteImagesRemovesObjectMetadataAndStats(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-delete.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-delete")) + if err != nil { + t.Fatal(err) + } + service := mediaapp.NewService( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + objects, + nil, + mediaapp.Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + raw, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + deletedAsset, err := service.SaveImage(ctx, raw) + if err != nil { + t.Fatal(err) + } + keptAsset, err := service.SaveImage(ctx, raw) + if err != nil { + t.Fatal(err) + } + + router := gin.New() + NewHandler(service).RegisterAdmin(router.Group("/api/admin/v1")) + request := httptest.NewRequest(http.MethodDelete, "/api/admin/v1/media/images", bytes.NewBufferString(`{"ids":["`+deletedAsset.ID+`"]}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("DELETE status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if _, _, err := service.OpenImage(ctx, deletedAsset.ID); !errors.Is(err, mediaapp.ErrAssetNotFound) { + t.Fatalf("deleted image error = %v, want ErrAssetNotFound", err) + } + _, body, err := service.OpenImage(ctx, keptAsset.ID) + if err != nil { + t.Fatalf("kept image error = %v", err) + } + defer body.Close() + stats, err := service.AdminImageStats(ctx) + if err != nil { + t.Fatal(err) + } + if stats.TotalImages != 1 || stats.TotalBytes != keptAsset.SizeBytes { + t.Fatalf("stats = %#v, want one kept image", stats) + } +} + +func TestPutVideoUploadReturns413WhenBodyTooLarge(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-upload-413.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-413")) + if err != nil { + t.Fatal(err) + } + tickets := relational.NewMediaUploadTicketRepository(database) + service := mediaapp.NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + tickets, objects, nil, + mediaapp.Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + tokenRaw := make([]byte, 32) + for i := range tokenRaw { + tokenRaw[i] = byte(i + 7) + } + token := hex.EncodeToString(tokenRaw) + sum := sha256.Sum256([]byte(token)) + now := time.Now().UTC() + if err := tickets.CreateUploadTicket(ctx, repository.MediaUploadTicket{ + TokenHash: hex.EncodeToString(sum[:]), AssetID: "vid_http_413_00000001", JobID: "job_413", + MaxBytes: 32, AllowedMIME: "video/mp4", ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, bytes.Repeat([]byte{0x0a}, 64)...) + router := gin.New() + NewHandler(service).RegisterPublic(router) + req := httptest.NewRequest(http.MethodPut, "/v1/media/uploads/"+token, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "video/mp4") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + if recorder.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413, body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestPutVideoUploadReturns400ForInvalidMIME(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-upload-400.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + objects, err := localmedia.NewLocalStore(filepath.Join(t.TempDir(), "objects-400")) + if err != nil { + t.Fatal(err) + } + service := mediaapp.NewServiceWithTickets( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + relational.NewMediaUploadTicketRepository(database), objects, nil, + mediaapp.Config{PublicBaseURL: "https://api.example", MaxImageBytes: 32 << 20, MaxTotalBytes: 1 << 30, CleanupThresholdPercent: 80, CleanupInterval: time.Minute}, + ) + uploadURL, _, err := service.IssueVideoUpload(ctx, "job_400_mime") + if err != nil { + t.Fatal(err) + } + token := uploadURL[len("https://api.example/v1/media/uploads/"):] + router := gin.New() + NewHandler(service).RegisterPublic(router) + payload := append([]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p'}, bytes.Repeat([]byte{1}, 16)...) + req := httptest.NewRequest(http.MethodPut, "/v1/media/uploads/"+token, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "video/webm") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", recorder.Code) + } +} + +func TestAdminVideoListRejectsInvalidFilters(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "media-admin-http.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + service := mediaapp.NewService( + relational.NewMediaAssetRepository(database), + relational.NewMediaJobRepository(database), + nil, + nil, + mediaapp.Config{}, + ) + router := gin.New() + NewHandler(service).RegisterAdmin(router.Group("/api/admin/v1")) + + for _, path := range []string{ + "/api/admin/v1/media/videos?status=unknown", + "/api/admin/v1/media/videos?sortBy=input_json&sortOrder=asc", + "/api/admin/v1/media/videos?sortBy=createdAt&sortOrder=sideways", + } { + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("GET %s status = %d, body = %s", path, recorder.Code, recorder.Body.String()) + } + } +} diff --git a/backend/internal/transport/http/middleware/auth.go b/backend/internal/transport/http/middleware/auth.go new file mode 100644 index 0000000000000000000000000000000000000000..b3826020ffaa03b6d4eeed4c08800657af3465b2 --- /dev/null +++ b/backend/internal/transport/http/middleware/auth.go @@ -0,0 +1,113 @@ +package middleware + +import ( + "errors" + "net/http" + "strings" + + "github.com/chenyme/grok2api/backend/internal/application/adminauth" + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +const ( + AdminKey = "admin" + ClientKey = "clientKey" +) + +// AdminAuth 校验管理员 Bearer JWT。 +func AdminAuth(service *adminauth.Service) gin.HandlerFunc { + return func(c *gin.Context) { + raw, ok := bearerToken(c.GetHeader("Authorization")) + if !ok { + response.Error(c, http.StatusUnauthorized, "adminUnauthorized", "管理员登录已失效") + return + } + value, err := service.AuthenticateAccess(c.Request.Context(), raw) + if err != nil { + if errors.Is(err, adminauth.ErrRuntimeUnavailable) { + response.Error(c, http.StatusServiceUnavailable, "authRuntimeUnavailable", "管理员认证服务暂不可用") + return + } + response.Error(c, http.StatusUnauthorized, "adminUnauthorized", "管理员登录已失效") + return + } + c.Set(AdminKey, value) + c.Next() + } +} + +// ClientAuth 校验下游 API Key,并在请求结束时释放并发租约。 +func ClientAuth(service *clientkeyapp.Service) gin.HandlerFunc { + return func(c *gin.Context) { + raw, ok := bearerToken(c.GetHeader("Authorization")) + if !ok { + raw = strings.TrimSpace(c.GetHeader("X-API-Key")) + } + value, release, err := service.Authenticate(c.Request.Context(), raw) + if err != nil { + writeOpenAIError(c, clientErrorStatus(err), clientErrorCode(err), clientErrorMessage(err)) + return + } + defer release() + c.Set(ClientKey, value) + c.Next() + } +} + +func bearerToken(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", false + } + token := parts[1] + return token, token != "" +} + +func clientErrorStatus(err error) int { + switch { + case errors.Is(err, clientkeyapp.ErrRuntimeUnavailable): + return http.StatusServiceUnavailable + case errors.Is(err, clientkeyapp.ErrRateLimited), errors.Is(err, clientkeyapp.ErrConcurrencyLimit), errors.Is(err, clientkeyapp.ErrBillingLimit): + return http.StatusTooManyRequests + default: + return http.StatusUnauthorized + } +} + +func clientErrorCode(err error) string { + switch { + case errors.Is(err, clientkeyapp.ErrRuntimeUnavailable): + return "runtime_store_unavailable" + case errors.Is(err, clientkeyapp.ErrRateLimited): + return "rate_limit_exceeded" + case errors.Is(err, clientkeyapp.ErrConcurrencyLimit): + return "concurrency_limit_exceeded" + case errors.Is(err, clientkeyapp.ErrBillingLimit): + return "billing_limit_exceeded" + default: + return "invalid_api_key" + } +} + +func clientErrorMessage(err error) string { + if errors.Is(err, clientkeyapp.ErrRuntimeUnavailable) { + return "网关运行态暂不可用,请稍后重试" + } + return err.Error() +} + +func writeOpenAIError(c *gin.Context, status int, code, message string) { + if c.Request.URL.Path == "/v1/messages" { + errorType := "authentication_error" + if status == http.StatusTooManyRequests { + errorType = "rate_limit_error" + } else if status >= 500 { + errorType = "api_error" + } + c.AbortWithStatusJSON(status, gin.H{"type": "error", "error": gin.H{"type": errorType, "message": message}}) + return + } + c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"message": message, "type": "invalid_request_error", "code": code, "param": nil}}) +} diff --git a/backend/internal/transport/http/middleware/auth_test.go b/backend/internal/transport/http/middleware/auth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..efeb591452ff85c859ff3ec0ee306786913b630c --- /dev/null +++ b/backend/internal/transport/http/middleware/auth_test.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "errors" + "net/http" + "testing" + + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" +) + +func TestClientRuntimeStoreFailureUsesServiceUnavailable(t *testing.T) { + err := errors.Join(clientkeyapp.ErrRuntimeUnavailable, errors.New("redis unavailable")) + if status := clientErrorStatus(err); status != http.StatusServiceUnavailable { + t.Fatalf("status = %d", status) + } + if code := clientErrorCode(err); code != "runtime_store_unavailable" { + t.Fatalf("code = %q", code) + } + if message := clientErrorMessage(err); message == err.Error() { + t.Fatal("runtime implementation detail leaked to client") + } +} + +func TestBearerTokenAcceptsCaseInsensitiveSchemeAndWhitespace(t *testing.T) { + token, ok := bearerToken(" bearer\tsecret-token ") + if !ok || token != "secret-token" { + t.Fatalf("token = %q, ok = %v", token, ok) + } + for _, value := range []string{"", "Bearer", "Basic token", "Bearer token extra"} { + if _, ok := bearerToken(value); ok { + t.Fatalf("header %q unexpectedly accepted", value) + } + } +} diff --git a/backend/internal/transport/http/middleware/body_memory.go b/backend/internal/transport/http/middleware/body_memory.go new file mode 100644 index 0000000000000000000000000000000000000000..baab60a983d5b078a2e9d00e128b679712148b6e --- /dev/null +++ b/backend/internal/transport/http/middleware/body_memory.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "io" + "net/http" + "strings" + "sync/atomic" + + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/gin-gonic/gin" +) + +type bodyMemoryObserver struct { + active atomic.Int64 + registry *perfmetrics.Registry +} + +type observedRequestBody struct { + io.ReadCloser + observer *bodyMemoryObserver + read atomic.Int64 + peak atomic.Int64 +} + +// ObserveBodyMemory records request-body pressure without delaying or +// rejecting requests. The tracked bytes remain active until the handler +// returns because JSON handlers retain their decoded body for that lifetime. +func ObserveBodyMemory() gin.HandlerFunc { + return newBodyMemoryObserver().Middleware() +} + +func newBodyMemoryObserver() *bodyMemoryObserver { + registry := perfmetrics.Default + observer := &bodyMemoryObserver{registry: registry} + registry.RegisterDynamicGauge("http_request_body_active_bytes", perfmetrics.Labels{Subsystem: "http"}, observer.active.Load) + return observer +} + +func (o *bodyMemoryObserver) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body == nil || c.Request.Body == http.NoBody { + c.Next() + return + } + mode := "unknown_length" + if c.Request.ContentLength >= 0 { + mode = "known_length" + } + operation := inferenceBodyOperation(c.Request.URL.Path) + labels := perfmetrics.Labels{Subsystem: "http", Operation: operation, Stage: mode} + if c.Request.ContentLength > 0 { + o.registry.Add("http_request_body_declared_bytes", labels, c.Request.ContentLength) + } + body := &observedRequestBody{ReadCloser: c.Request.Body, observer: o} + c.Request.Body = body + defer func() { + read := body.read.Load() + o.active.Add(-read) + o.registry.Inc("http_request_body_total", labels) + o.registry.Add("http_request_body_read_bytes", labels, read) + o.registry.Add("http_request_body_active_peak_bytes", labels, body.peak.Load()) + }() + c.Next() + } +} + +func (b *observedRequestBody) Read(buffer []byte) (int, error) { + read, err := b.ReadCloser.Read(buffer) + if read <= 0 { + return read, err + } + b.read.Add(int64(read)) + current := b.observer.active.Add(int64(read)) + for { + peak := b.peak.Load() + if current <= peak || b.peak.CompareAndSwap(peak, current) { + break + } + } + return read, err +} + +func inferenceBodyOperation(path string) string { + switch strings.TrimSuffix(path, "/") { + case "/v1/responses": + return "responses" + case "/v1/responses/compact": + return "responses_compact" + case "/v1/chat/completions": + return "chat" + case "/v1/messages": + return "messages" + case "/v1/images/generations": + return "image_generation" + case "/v1/images/edits": + return "image_edit" + case "/v1/videos/generations": + return "video_generation" + default: + return "other" + } +} diff --git a/backend/internal/transport/http/middleware/concurrency.go b/backend/internal/transport/http/middleware/concurrency.go new file mode 100644 index 0000000000000000000000000000000000000000..804cd23ff2b16864197d403995374db8161e4a6c --- /dev/null +++ b/backend/internal/transport/http/middleware/concurrency.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" +) + +// ConcurrencyGate 为单实例推理入口提供可热更新、立即拒绝的总容量保护。 +type ConcurrencyGate struct { + mu sync.Mutex + limit int + active int +} + +// NewConcurrencyGate 创建指定上限的推理入口并发闸门。 +func NewConcurrencyGate(limit int) *ConcurrencyGate { + if limit < 1 { + panic("middleware: 并发上限必须大于零") + } + return &ConcurrencyGate{limit: limit} +} + +// UpdateLimit 热更新并发上限;降低上限不会中断正在执行的请求。 +func (g *ConcurrencyGate) UpdateLimit(limit int) { + if limit < 1 { + panic("middleware: 并发上限必须大于零") + } + g.mu.Lock() + g.limit = limit + g.mu.Unlock() +} + +// Middleware 返回绑定当前 Gate 状态的 Gin 中间件。 +func (g *ConcurrencyGate) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + g.mu.Lock() + if g.active >= g.limit { + g.mu.Unlock() + c.Header("Retry-After", "1") + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "code": "server_overloaded", "message": "服务并发已达到上限,请稍后重试", "param": nil, "type": "server_error", + }}) + return + } + g.active++ + g.mu.Unlock() + defer func() { + g.mu.Lock() + g.active-- + g.mu.Unlock() + }() + c.Next() + } +} diff --git a/backend/internal/transport/http/middleware/concurrency_test.go b/backend/internal/transport/http/middleware/concurrency_test.go new file mode 100644 index 0000000000000000000000000000000000000000..db11d8559fdc9a7358baf71143d26de0c9feb723 --- /dev/null +++ b/backend/internal/transport/http/middleware/concurrency_test.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestConcurrencyGateHotReloadsAndReleasesCapacity(t *testing.T) { + gin.SetMode(gin.TestMode) + started := make(chan struct{}) + release := make(chan struct{}) + router := gin.New() + gate := NewConcurrencyGate(1) + router.Use(gate.Middleware()) + router.GET("/v1/responses", func(c *gin.Context) { + if c.Query("block") == "true" { + close(started) + <-release + } + c.Status(http.StatusOK) + }) + + firstDone := make(chan struct{}) + go func() { + defer close(firstDone) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/responses?block=true", nil)) + if recorder.Code != http.StatusOK { + t.Errorf("first status = %d", recorder.Code) + } + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("first request did not acquire capacity") + } + + overflow := httptest.NewRecorder() + router.ServeHTTP(overflow, httptest.NewRequest(http.MethodGet, "/v1/responses", nil)) + if overflow.Code != http.StatusServiceUnavailable || overflow.Header().Get("Retry-After") != "1" { + t.Fatalf("overflow status = %d, retry-after = %q", overflow.Code, overflow.Header().Get("Retry-After")) + } + + gate.UpdateLimit(2) + expanded := httptest.NewRecorder() + router.ServeHTTP(expanded, httptest.NewRequest(http.MethodGet, "/v1/responses", nil)) + if expanded.Code != http.StatusOK { + t.Fatalf("expanded status = %d", expanded.Code) + } + gate.UpdateLimit(1) + reduced := httptest.NewRecorder() + router.ServeHTTP(reduced, httptest.NewRequest(http.MethodGet, "/v1/responses", nil)) + if reduced.Code != http.StatusServiceUnavailable { + t.Fatalf("reduced status = %d", reduced.Code) + } + close(release) + <-firstDone + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/responses", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("released status = %d", recorder.Code) + } +} diff --git a/backend/internal/transport/http/middleware/request.go b/backend/internal/transport/http/middleware/request.go new file mode 100644 index 0000000000000000000000000000000000000000..18acf22c4f667ec953e28d90a2b6a602a8c4b333 --- /dev/null +++ b/backend/internal/transport/http/middleware/request.go @@ -0,0 +1,101 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/gin-gonic/gin" +) + +const RequestIDKey = "requestId" +const maxRequestIDLength = 64 + +// RequestID 为每个请求生成稳定关联 ID,并写入响应头。 +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := strings.TrimSpace(c.GetHeader("X-Request-ID")) + if !validRequestID(requestID) { + requestID, _ = security.NewOpaqueToken(12) + if requestID == "" { + requestID = "req-" + strconv.FormatInt(time.Now().UnixNano(), 36) + } + } + c.Set(RequestIDKey, requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} + +// validRequestID 只接受适合写入日志和审计索引的短 ASCII 标识。 +func validRequestID(value string) bool { + if value == "" || len(value) > maxRequestIDLength { + return false + } + for index := range len(value) { + character := value[index] + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') { + continue + } + switch character { + case '-', '_', '.', ':': + default: + return false + } + } + return true +} + +// Timeout 为 HTTP 请求设置统一生命周期上限。 +func Timeout(duration time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), duration) + defer cancel() + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} + +// MaxBodyBytes 对所有请求体应用统一硬上限,避免管理端绑定无界读取。 +// XAI 视频 PUT(/v1/media/uploads/)使用更高的视频安全上限,由票据再次限长。 +func MaxBodyBytes(limit int64) gin.HandlerFunc { + const mediaUploadPathPrefix = "/v1/media/uploads/" + const mediaUploadMaxBytes = 256 << 20 + return func(c *gin.Context) { + if c.Request.Body != nil && limit > 0 { + effective := limit + if strings.HasPrefix(c.Request.URL.Path, mediaUploadPathPrefix) && c.Request.Method == http.MethodPut { + if mediaUploadMaxBytes > effective { + effective = mediaUploadMaxBytes + } + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, effective) + } + c.Next() + } +} + +// SecurityHeaders 为 API 和媒体响应添加通用浏览器安全边界。 +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("Referrer-Policy", "no-referrer") + c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + c.Next() + } +} + +// AccessLog 只记录路径、状态和耗时,不读取请求或响应正文。 +func AccessLog(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + startedAt := time.Now() + c.Next() + requestID, _ := c.Get(RequestIDKey) + logger.Info("http_request", "request_id", requestID, "method", c.Request.Method, "path", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(startedAt).Milliseconds()) + } +} diff --git a/backend/internal/transport/http/middleware/request_test.go b/backend/internal/transport/http/middleware/request_test.go new file mode 100644 index 0000000000000000000000000000000000000000..711c330531e65e1f6d2557e96f64fb65d24a74e9 --- /dev/null +++ b/backend/internal/transport/http/middleware/request_test.go @@ -0,0 +1,231 @@ +package middleware + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics" + "github.com/gin-gonic/gin" +) + +func TestValidRequestID(t *testing.T) { + valid := []string{"req_123", "550e8400-e29b-41d4-a716-446655440000", "trace:span.1"} + for _, value := range valid { + if !validRequestID(value) { + t.Fatalf("request ID %q should be valid", value) + } + } + invalid := []string{"", "contains space", "含中文", string(make([]byte, maxRequestIDLength+1))} + for _, value := range invalid { + if validRequestID(value) { + t.Fatalf("request ID %q should be invalid", value) + } + } +} + +func TestMaxBodyBytesLimitsAllRequestBodies(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(MaxBodyBytes(4)) + router.POST("/", func(c *gin.Context) { + _, err := io.ReadAll(c.Request.Body) + if err != nil { + c.Status(http.StatusRequestEntityTooLarge) + return + } + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("12345")) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d", response.Code) + } +} + +func TestObserveBodyMemoryRecordsActualAndDeclaredBytes(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(ObserveBodyMemory()) + router.POST("/v1/responses", func(c *gin.Context) { + if _, err := io.ReadAll(c.Request.Body); err != nil { + c.Status(http.StatusBadRequest) + return + } + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader("12345")) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d", response.Code) + } + + samples := registry.CollectAndReset() + assertBodyMetric(t, samples, "http_request_body_total", "responses", "known_length", 1, 1, 1) + assertBodyMetric(t, samples, "http_request_body_declared_bytes", "responses", "known_length", 1, 5, 5) + assertBodyMetric(t, samples, "http_request_body_read_bytes", "responses", "known_length", 1, 5, 5) + assertBodyMetric(t, samples, "http_request_body_active_peak_bytes", "responses", "known_length", 1, 5, 5) + assertBodyGauge(t, samples, "http_request_body_active_bytes", 0) +} + +func TestObserveBodyMemoryMeasuresConcurrentRetention(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + + gin.SetMode(gin.TestMode) + started := make(chan struct{}, 2) + release := make(chan struct{}) + router := gin.New() + router.Use(newBodyMemoryObserver().Middleware()) + router.POST("/v1/messages", func(c *gin.Context) { + _, _ = io.ReadAll(c.Request.Body) + started <- struct{}{} + <-release + c.Status(http.StatusNoContent) + }) + + done := make(chan struct{}, 2) + for range 2 { + go func() { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("1234")) + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Errorf("status = %d", response.Code) + } + done <- struct{}{} + }() + } + for range 2 { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("request did not retain its body") + } + } + activeSamples := registry.CollectAndReset() + assertBodyGauge(t, activeSamples, "http_request_body_active_bytes", 8) + close(release) + for range 2 { + <-done + } + + samples := registry.CollectAndReset() + assertBodyMetric(t, samples, "http_request_body_active_peak_bytes", "messages", "known_length", 2, 12, 8) + assertBodyGauge(t, samples, "http_request_body_active_bytes", 0) +} + +func TestObserveBodyMemoryReleasesPressureAfterRecoveredPanic(t *testing.T) { + registry := perfmetrics.NewRegistry() + previous := perfmetrics.Default + perfmetrics.Default = registry + t.Cleanup(func() { perfmetrics.Default = previous }) + + gin.SetMode(gin.TestMode) + observer := newBodyMemoryObserver() + router := gin.New() + router.Use(gin.Recovery(), observer.Middleware()) + router.POST("/v1/responses", func(c *gin.Context) { + _, _ = io.ReadAll(c.Request.Body) + panic("boom") + }) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader("12345")) + router.ServeHTTP(response, request) + if response.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", response.Code) + } + if current := observer.active.Load(); current != 0 { + t.Fatalf("active body bytes after recovered panic = %d", current) + } + + samples := registry.CollectAndReset() + assertBodyMetric(t, samples, "http_request_body_total", "responses", "known_length", 1, 1, 1) + assertBodyMetric(t, samples, "http_request_body_read_bytes", "responses", "known_length", 1, 5, 5) + assertBodyGauge(t, samples, "http_request_body_active_bytes", 0) +} + +func assertBodyMetric(t *testing.T, samples []perfmetrics.Sample, name, operation, stage string, count uint64, total, maximum int64) { + t.Helper() + for _, sample := range samples { + if sample.Name == name && sample.Labels.Operation == operation && sample.Labels.Stage == stage { + if sample.Count != count || sample.Total != total || sample.Maximum != maximum { + t.Fatalf("%s sample = %#v", name, sample) + } + return + } + } + t.Fatalf("metric %s/%s/%s not found: %#v", name, operation, stage, samples) +} + +func assertBodyGauge(t *testing.T, samples []perfmetrics.Sample, name string, value int64) { + t.Helper() + for _, sample := range samples { + if sample.Name == name { + if !sample.HasGauge || sample.Gauge != value { + t.Fatalf("%s gauge = %#v", name, sample) + } + return + } + } + t.Fatalf("gauge %s not found: %#v", name, samples) +} + +func BenchmarkRequestBodyObservation(b *testing.B) { + payload := strings.Repeat("x", 4<<10) + for _, observed := range []bool{false, true} { + name := "plain" + if observed { + name = "observed" + } + b.Run(name, func(b *testing.B) { + gin.SetMode(gin.TestMode) + router := gin.New() + if observed { + router.Use(ObserveBodyMemory()) + } + router.POST("/v1/responses", func(c *gin.Context) { + _, _ = io.Copy(io.Discard, c.Request.Body) + c.Status(http.StatusNoContent) + }) + b.ReportAllocs() + b.RunParallel(func(worker *testing.PB) { + for worker.Next() { + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(payload)) + router.ServeHTTP(httptest.NewRecorder(), request) + } + }) + }) + } +} + +func TestSecurityHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(SecurityHeaders()) + router.GET("/", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil)) + for name, expected := range map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + } { + if value := response.Header().Get(name); value != expected { + t.Fatalf("%s = %q", name, value) + } + } +} diff --git a/backend/internal/transport/http/model/handler.go b/backend/internal/transport/http/model/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..6a18528120620c7f9aa4ab302caa760442b501cf --- /dev/null +++ b/backend/internal/transport/http/model/handler.go @@ -0,0 +1,304 @@ +package model + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + modelapp "github.com/chenyme/grok2api/backend/internal/application/model" + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" + "github.com/chenyme/grok2api/backend/internal/repository" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct{ service *modelapp.Service } + +func NewHandler(service *modelapp.Service) *Handler { return &Handler{service: service} } + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/models", h.list) + router.GET("/models/accounts", h.listAccounts) + router.POST("/models", h.create) + router.POST("/models/sync", h.sync) + router.PATCH("/models/batch", h.batchUpdate) + router.DELETE("/models", h.batchDelete) + router.PATCH("/models/:id", h.update) + router.DELETE("/models/:id", h.delete) +} + +type updateRequest struct { + PublicID *string `json:"publicId"` + Enabled *bool `json:"enabled"` + AccountIDs *[]string `json:"accountIds"` +} + +type createRequest struct { + PublicID string `json:"publicId" binding:"required"` + Provider string `json:"provider" binding:"required"` + UpstreamModel string `json:"upstreamModel" binding:"required"` + Capability string `json:"capability" binding:"required"` + Enabled bool `json:"enabled"` + AccountIDs []string `json:"accountIds"` +} + +type batchUpdateRequest struct { + IDs []string `json:"ids" binding:"required"` + Enabled bool `json:"enabled"` +} + +type batchDeleteRequest struct { + IDs []string `json:"ids" binding:"required"` +} + +type modelResponse struct { + ID uint64 `json:"id,string"` + PublicID string `json:"publicId"` + Provider string `json:"provider"` + UpstreamModel string `json:"upstreamModel"` + Capability string `json:"capability"` + Enabled bool `json:"enabled"` + Origin string `json:"origin"` + AccountIDs []string `json:"accountIds"` + BindingMode bool `json:"bindingMode"` + SupportedAccounts int `json:"supportedAccounts"` + SyncedAccounts int `json:"syncedAccounts"` + TotalAccounts int `json:"totalAccounts"` + CapabilityKnown bool `json:"capabilityKnown"` + Available bool `json:"available"` + LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"` +} + +type accountOptionResponse struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` +} + +func (h *Handler) list(c *gin.Context) { + page, pageSize := pagination(c) + activeScope, ok := parseOptionalBool(c.Query("activeScope")) + if !ok { + response.Error(c, http.StatusBadRequest, "invalidFilter", "activeScope 必须是 true 或 false") + return + } + values, total, err := h.service.List(c.Request.Context(), page, pageSize, c.Query("search"), modelapp.ListFilter{Provider: c.Query("provider"), Providers: splitScopeQuery(c.QueryArray("providerScope")), Tiers: splitScopeQuery(c.QueryArray("tierScope")), Status: c.Query("status"), ActiveScope: activeScope, Sort: repository.SortQuery{Field: c.Query("sortBy"), Direction: repository.SortDirection(c.Query("sortOrder"))}}) + if errors.Is(err, modelapp.ErrInvalidFilter) { + response.Error(c, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "modelListFailed", "读取模型失败") + return + } + items := make([]modelResponse, 0, len(values)) + for _, value := range values { + items = append(items, newModelResponse(value)) + } + response.Success(c, http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total}) +} + +func splitScopeQuery(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + } + return result +} + +func parseOptionalBool(value string) (bool, bool) { + switch strings.TrimSpace(value) { + case "", "false": + return false, true + case "true": + return true, true + default: + return false, false + } +} + +func (h *Handler) listAccounts(c *gin.Context) { + values, err := h.service.ListBindableAccounts(c.Request.Context(), account.Provider(c.Query("provider"))) + if err != nil { + h.writeServiceError(c, "modelAccountListFailed", err) + return + } + items := make([]accountOptionResponse, 0, len(values)) + for _, value := range values { + items = append(items, accountOptionResponse{ID: value.ID, Name: value.Name}) + } + response.Success(c, http.StatusOK, gin.H{"items": items}) +} + +func (h *Handler) create(c *gin.Context) { + var request createRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + accountIDs, err := parseIDs(request.AccountIDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + value, err := h.service.Create(c.Request.Context(), modelapp.CreateInput{ + PublicID: request.PublicID, Provider: account.Provider(request.Provider), UpstreamModel: request.UpstreamModel, + Capability: modeldomain.Capability(request.Capability), Enabled: request.Enabled, AccountIDs: accountIDs, + }) + if err != nil { + h.writeServiceError(c, "modelCreateFailed", err) + return + } + response.Success(c, http.StatusCreated, newModelResponse(value)) +} + +func (h *Handler) batchUpdate(c *gin.Context) { + var request batchUpdateRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids := make([]uint64, 0, len(request.IDs)) + for _, value := range request.IDs { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", fmt.Sprintf("无效模型 ID: %s", value)) + return + } + ids = append(ids, id) + } + updated, err := h.service.BatchSetEnabled(c.Request.Context(), ids, request.Enabled) + if err != nil { + h.writeServiceError(c, "modelBatchUpdateFailed", err) + return + } + response.Success(c, http.StatusOK, gin.H{"updated": updated}) +} + +func (h *Handler) batchDelete(c *gin.Context) { + var request batchDeleteRequest + if c.ShouldBindJSON(&request) != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + ids, err := parseIDs(request.IDs) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalidId", err.Error()) + return + } + deleted, err := h.service.BatchDelete(c.Request.Context(), ids) + if err != nil { + h.writeServiceError(c, "modelBatchDeleteFailed", err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": deleted}) +} + +func (h *Handler) sync(c *gin.Context) { + count, err := h.service.Sync(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusBadGateway, "modelSyncFailed", "同步上游模型失败") + return + } + response.Success(c, http.StatusOK, gin.H{"synced": count}) +} + +func (h *Handler) update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", "ID 无效") + return + } + var request updateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效") + return + } + var accountIDs *[]uint64 + if request.AccountIDs != nil { + parsed, parseErr := parseIDs(*request.AccountIDs) + if parseErr != nil { + response.Error(c, http.StatusBadRequest, "invalidId", parseErr.Error()) + return + } + accountIDs = &parsed + } + value, err := h.service.Update(c.Request.Context(), id, modelapp.UpdateInput{PublicID: request.PublicID, Enabled: request.Enabled, AccountIDs: accountIDs}) + if err != nil { + h.writeServiceError(c, "modelUpdateFailed", err) + return + } + response.Success(c, http.StatusOK, newModelResponse(value)) +} + +func (h *Handler) delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalidId", "ID 无效") + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + h.writeServiceError(c, "modelDeleteFailed", err) + return + } + response.Success(c, http.StatusOK, gin.H{"deleted": true}) +} + +// writeServiceError 仅暴露明确的模型业务错误,避免泄露持久化细节。 +func (h *Handler) writeServiceError(c *gin.Context, code string, err error) { + switch { + case errors.Is(err, modelapp.ErrInvalidInput): + response.Error(c, http.StatusBadRequest, code, err.Error()) + case errors.Is(err, modelapp.ErrNotFound): + response.Error(c, http.StatusNotFound, "modelNotFound", err.Error()) + case errors.Is(err, modelapp.ErrConflict): + response.Error(c, http.StatusConflict, "modelConflict", err.Error()) + default: + response.Error(c, http.StatusInternalServerError, code, "模型操作失败") + } +} + +func newModelResponse(value modeldomain.Route) modelResponse { + manualBinding := len(value.BoundAccountIDs) > 0 + capabilityKnown := manualBinding || value.SyncedAccounts > 0 + available := value.TotalAccounts > 0 && (value.SupportedAccounts > 0 || (!manualBinding && value.SyncedAccounts < value.TotalAccounts)) + accountIDs := make([]string, 0, len(value.BoundAccountIDs)) + for _, id := range value.BoundAccountIDs { + accountIDs = append(accountIDs, strconv.FormatUint(id, 10)) + } + return modelResponse{ + ID: value.ID, PublicID: modeldomain.ExternalPublicID(value.Provider, value.PublicID), Provider: string(value.Provider), UpstreamModel: modeldomain.DisplayUpstreamModel(value.Provider, value.UpstreamModel), Capability: string(value.Capability), + Enabled: value.Enabled, Origin: string(value.Origin), AccountIDs: accountIDs, BindingMode: manualBinding, SupportedAccounts: value.SupportedAccounts, + SyncedAccounts: value.SyncedAccounts, TotalAccounts: value.TotalAccounts, CapabilityKnown: capabilityKnown, + Available: available, LastSyncedAt: value.LastSyncedAt, + } +} + +func parseIDs(values []string) ([]uint64, error) { + ids := make([]uint64, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return nil, fmt.Errorf("无效 ID: %s", value) + } + ids = append(ids, id) + } + return ids, nil +} + +func pagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + return repository.NormalizePage(page, pageSize, repository.DefaultPageSize) +} diff --git a/backend/internal/transport/http/model/handler_test.go b/backend/internal/transport/http/model/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..abb6713f78b6f192aeb6f1b4830accac0614cdaa --- /dev/null +++ b/backend/internal/transport/http/model/handler_test.go @@ -0,0 +1,37 @@ +package model + +import ( + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model" +) + +func TestNewModelResponseSeparatesPublicAndUpstreamNames(t *testing.T) { + response := newModelResponse(modeldomain.Route{ + ID: 1, PublicID: "Build/grok-4.5", Provider: account.ProviderBuild, UpstreamModel: "grok-4.5", + Capability: modeldomain.CapabilityResponses, Enabled: true, + }) + if response.PublicID != "grok-4.5" || response.UpstreamModel != "Build/grok-4.5" { + t.Fatalf("model response = %#v", response) + } +} + +func TestParseOptionalBoolRejectsAmbiguousValues(t *testing.T) { + for _, test := range []struct { + input string + value bool + valid bool + }{ + {input: "", valid: true}, + {input: "false", valid: true}, + {input: "true", value: true, valid: true}, + {input: "1", valid: false}, + {input: "yes", valid: false}, + } { + value, valid := parseOptionalBool(test.input) + if value != test.value || valid != test.valid { + t.Fatalf("parseOptionalBool(%q) = (%v, %v), want (%v, %v)", test.input, value, valid, test.value, test.valid) + } + } +} diff --git a/backend/internal/transport/http/server.go b/backend/internal/transport/http/server.go new file mode 100644 index 0000000000000000000000000000000000000000..09ab1d872318e9c6566e3f9e483b901c881fe3e1 --- /dev/null +++ b/backend/internal/transport/http/server.go @@ -0,0 +1,181 @@ +package httpserver + +import ( + "context" + "log/slog" + "net/http" + "time" + + _ "github.com/chenyme/grok2api/backend/docs" + accountapp "github.com/chenyme/grok2api/backend/internal/application/account" + accountsyncapp "github.com/chenyme/grok2api/backend/internal/application/accountsync" + adminauthapp "github.com/chenyme/grok2api/backend/internal/application/adminauth" + auditapp "github.com/chenyme/grok2api/backend/internal/application/audit" + clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey" + dashboardapp "github.com/chenyme/grok2api/backend/internal/application/dashboard" + egressapp "github.com/chenyme/grok2api/backend/internal/application/egress" + "github.com/chenyme/grok2api/backend/internal/application/gateway" + mediaapp "github.com/chenyme/grok2api/backend/internal/application/media" + modelapp "github.com/chenyme/grok2api/backend/internal/application/model" + settingsapp "github.com/chenyme/grok2api/backend/internal/application/settings" + updatecheckapp "github.com/chenyme/grok2api/backend/internal/application/updatecheck" + accounthttp "github.com/chenyme/grok2api/backend/internal/transport/http/account" + adminauthhttp "github.com/chenyme/grok2api/backend/internal/transport/http/adminauth" + audithttp "github.com/chenyme/grok2api/backend/internal/transport/http/audit" + clientkeyhttp "github.com/chenyme/grok2api/backend/internal/transport/http/clientkey" + dashboardhttp "github.com/chenyme/grok2api/backend/internal/transport/http/dashboard" + egresshttp "github.com/chenyme/grok2api/backend/internal/transport/http/egress" + "github.com/chenyme/grok2api/backend/internal/transport/http/inference" + mediahttp "github.com/chenyme/grok2api/backend/internal/transport/http/media" + "github.com/chenyme/grok2api/backend/internal/transport/http/middleware" + modelhttp "github.com/chenyme/grok2api/backend/internal/transport/http/model" + settingshttp "github.com/chenyme/grok2api/backend/internal/transport/http/settings" + systemhttp "github.com/chenyme/grok2api/backend/internal/transport/http/system" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +type Dependencies struct { + Logger *slog.Logger + RequestTimeout time.Duration + MaxBodyBytes int64 + ConcurrencyGate *middleware.ConcurrencyGate + SecureCookies bool + SwaggerEnabled bool + PublicAPIBaseURL string + FrontendStaticPath string + // Readiness 返回可观测的分层就绪状态。Ready 仅为旧调用方保留。 + Readiness func(context.Context) ReadinessSnapshot + Ready func(context.Context) bool + TrafficReady func() bool + AdminAuth *adminauthapp.Service + Accounts *accountapp.Service + AccountSync *accountsyncapp.Service + Models *modelapp.Service + ClientKeys *clientkeyapp.Service + Audits *auditapp.Service + Dashboard *dashboardapp.Service + Gateway *gateway.Service + Media *mediaapp.Service + Settings *settingsapp.Service + Egress *egressapp.Service + Updates *updatecheckapp.Service +} + +type ReadinessComponent struct { + State string `json:"state"` + Detail string `json:"detail,omitempty"` +} + +// ReadinessCredentialReport 表示可公开的启动凭据恢复统计。 +type ReadinessCredentialReport struct { + SchedulesBackfilled int `json:"schedulesBackfilled"` + CriticalFound int `json:"criticalFound"` + Refreshed int `json:"refreshed"` + Failed int `json:"failed"` +} + +// ReadinessStartupReport 表示可公开的启动恢复统计,不包含内部错误文本。 +type ReadinessStartupReport struct { + StartedAt time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + Credentials ReadinessCredentialReport `json:"credentials"` + CooldownsRestored int `json:"cooldownsRestored"` + QuotaRecoveriesRestored int `json:"quotaRecoveriesRestored"` + DueWebQuotasQueued int `json:"dueWebQuotasQueued"` + StatsigKeysWarmed int `json:"statsigKeysWarmed"` + StaleWebQuotasFound int `json:"staleWebQuotasFound"` + StaleWebQuotasSynced int `json:"staleWebQuotasSynced"` + StaleModelCatalogsFound int `json:"staleModelCatalogsFound"` + StaleModelCatalogsSynced int `json:"staleModelCatalogsSynced"` + ErrorCount int `json:"errorCount"` +} + +// ReadinessSnapshot 表示公开就绪端点的稳定响应契约。 +type ReadinessSnapshot struct { + Ready bool `json:"ready"` + State string `json:"state"` + UpdatedAt time.Time `json:"updatedAt"` + Components map[string]ReadinessComponent `json:"components,omitempty"` + Startup *ReadinessStartupReport `json:"startup,omitempty"` +} + +// New 创建完整 HTTP 路由并明确区分公共、管理员和客户端鉴权边界。 +func New(deps Dependencies) *gin.Engine { + if deps.ConcurrencyGate == nil { + panic("httpserver: ConcurrencyGate 不能为空") + } + gin.SetMode(gin.ReleaseMode) + if deps.Logger == nil { + deps.Logger = slog.Default() + } + router := gin.New() + router.Use(gin.Recovery(), middleware.RequestID(), middleware.SecurityHeaders(), middleware.MaxBodyBytes(deps.MaxBodyBytes), middleware.Timeout(deps.RequestTimeout), middleware.AccessLog(deps.Logger)) + router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + router.GET("/readyz", func(c *gin.Context) { + if deps.Readiness != nil { + snapshot := deps.Readiness(c.Request.Context()) + status := http.StatusServiceUnavailable + if snapshot.Ready { + status = http.StatusOK + } + c.JSON(status, snapshot) + return + } + if deps.Ready != nil && deps.Ready(c.Request.Context()) { + c.JSON(http.StatusOK, gin.H{"ready": true, "state": "ready"}) + return + } + c.JSON(http.StatusServiceUnavailable, gin.H{"ready": false, "state": "not_ready"}) + }) + if deps.SwaggerEnabled { + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } + mediaHandler := mediahttp.NewHandler(deps.Media) + mediaHandler.RegisterPublic(router) + + adminRoot := router.Group("/api/admin/v1") + authHandler := adminauthhttp.NewHandler(deps.AdminAuth, deps.SecureCookies) + authHandler.RegisterPublic(adminRoot) + adminProtected := adminRoot.Group("") + adminProtected.Use(middleware.AdminAuth(deps.AdminAuth)) + authHandler.RegisterAuthenticated(adminProtected) + accounthttp.NewHandler(deps.Accounts, deps.AccountSync).Register(adminProtected) + modelhttp.NewHandler(deps.Models).Register(adminProtected) + clientkeyhttp.NewHandler(deps.ClientKeys).Register(adminProtected) + audithttp.NewHandler(deps.Audits).Register(adminProtected) + dashboardhttp.NewHandler(deps.Dashboard).Register(adminProtected) + mediaHandler.RegisterAdmin(adminProtected) + settingshttp.NewHandler(deps.Settings).Register(adminProtected) + egresshttp.NewHandler(deps.Egress).Register(adminProtected) + systemhttp.NewHandler(func() string { + if deps.Settings != nil { + return deps.Settings.PublicAPIBaseURL() + } + return deps.PublicAPIBaseURL + }, deps.Updates).Register(adminProtected) + + v1 := router.Group("/v1") + v1.Use(deps.ConcurrencyGate.Middleware()) + v1.Use(middleware.ObserveBodyMemory()) + if deps.TrafficReady != nil { + v1.Use(func(c *gin.Context) { + if deps.TrafficReady() { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "code": "service_reconciling", "message": "服务正在完成启动恢复,请稍后重试", "param": nil, "type": "server_error", + }}) + }) + } + v1.Use(middleware.ClientAuth(deps.ClientKeys)) + inferenceHandler := inference.NewHandler(deps.Gateway, deps.Models, deps.MaxBodyBytes, deps.PublicAPIBaseURL) + if deps.Settings != nil { + inferenceHandler.SetPublicAPIBaseURLResolver(deps.Settings.PublicAPIBaseURL) + } + inferenceHandler.Register(v1) + registerFrontend(router, deps.FrontendStaticPath) + return router +} diff --git a/backend/internal/transport/http/server_test.go b/backend/internal/transport/http/server_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6a08e1050e9aa28bf25b46d2e6e59b7e8de8a7a0 --- /dev/null +++ b/backend/internal/transport/http/server_test.go @@ -0,0 +1,173 @@ +package httpserver + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/transport/http/middleware" +) + +func testDependencies() Dependencies { + return Dependencies{RequestTimeout: time.Second, MaxBodyBytes: 1024, ConcurrencyGate: middleware.NewConcurrencyGate(1024)} +} + +func TestReadinessEndpointReturnsStructuredDegradedStateAsReady(t *testing.T) { + deps := testDependencies() + deps.Readiness = func(context.Context) ReadinessSnapshot { + return ReadinessSnapshot{ + Ready: true, State: "degraded", UpdatedAt: time.Now().UTC(), + Components: map[string]ReadinessComponent{ + "grok_build": {State: "ready"}, + "grok_web": {State: "unavailable"}, + }, + } + } + router := New(deps) + request := httptest.NewRequest(http.MethodGet, "/readyz", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + var body ReadinessSnapshot + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Ready || body.State != "degraded" || body.Components["grok_build"].State != "ready" { + t.Fatalf("body = %#v", body) + } +} + +func TestReadinessEndpointReturns503WhileReconciling(t *testing.T) { + deps := testDependencies() + deps.Readiness = func(context.Context) ReadinessSnapshot { + return ReadinessSnapshot{Ready: false, State: "reconciling", UpdatedAt: time.Now().UTC()} + } + router := New(deps) + request := httptest.NewRequest(http.MethodGet, "/readyz", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), `"state":"reconciling"`) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestInferenceTrafficIsRejectedWhileReconciling(t *testing.T) { + deps := testDependencies() + deps.TrafficReady = func() bool { return false } + router := New(deps) + request := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), `"code":"service_reconciling"`) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestSystemEndpointsRequireAdminAuthentication(t *testing.T) { + deps := testDependencies() + deps.PublicAPIBaseURL = "https://api.example.com" + router := New(deps) + for _, route := range []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/api/admin/v1/system"}, + {method: http.MethodGet, path: "/api/admin/v1/system/version"}, + {method: http.MethodPost, path: "/api/admin/v1/system/update/check"}, + } { + request := httptest.NewRequest(route.method, route.path, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("%s %s status = %d, want %d", route.method, route.path, recorder.Code, http.StatusUnauthorized) + } + } +} + +func TestFrontendStaticFilesAndSPAFallback(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("app"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "assets", "app.js"), []byte("console.log('app')"), 0o600); err != nil { + t.Fatal(err) + } + deps := testDependencies() + deps.Logger = slog.Default() + deps.FrontendStaticPath = root + router := New(deps) + + for _, test := range []struct { + path string + status int + body string + cachePrefix string + }{ + {path: "/assets/app.js", status: http.StatusOK, body: "console.log('app')", cachePrefix: "public"}, + {path: "/dashboard", status: http.StatusOK, body: "app", cachePrefix: "no-cache"}, + {path: "/assets/missing.js", status: http.StatusNotFound}, + {path: "/api/admin/v1/missing", status: http.StatusNotFound}, + {path: "/swagger/index.html", status: http.StatusNotFound}, + } { + t.Run(test.path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != test.status { + t.Fatalf("status = %d, want %d", recorder.Code, test.status) + } + if test.body != "" && !strings.Contains(recorder.Body.String(), test.body) { + t.Fatalf("body = %q", recorder.Body.String()) + } + if test.cachePrefix != "" && !strings.HasPrefix(recorder.Header().Get("Cache-Control"), test.cachePrefix) { + t.Fatalf("cache-control = %q", recorder.Header().Get("Cache-Control")) + } + }) + } +} + +func TestSwaggerRegistrationFollowsStartupConfig(t *testing.T) { + disabledDeps := testDependencies() + disabledDeps.Logger = slog.Default() + disabled := New(disabledDeps) + disabledRequest := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil) + disabledRecorder := httptest.NewRecorder() + disabled.ServeHTTP(disabledRecorder, disabledRequest) + if disabledRecorder.Code != http.StatusNotFound { + t.Fatalf("disabled swagger status = %d, want %d", disabledRecorder.Code, http.StatusNotFound) + } + + enabledDeps := testDependencies() + enabledDeps.Logger = slog.Default() + enabledDeps.SwaggerEnabled = true + enabled := New(enabledDeps) + enabledRequest := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil) + enabledRecorder := httptest.NewRecorder() + enabled.ServeHTTP(enabledRecorder, enabledRequest) + if enabledRecorder.Code != http.StatusOK { + t.Fatalf("enabled swagger status = %d, want %d", enabledRecorder.Code, http.StatusOK) + } + var document struct { + Info struct { + Title string `json:"title"` + } `json:"info"` + } + if err := json.Unmarshal(enabledRecorder.Body.Bytes(), &document); err != nil { + t.Fatalf("decode swagger document: %v", err) + } + if document.Info.Title != "Grok2API" { + t.Fatalf("swagger title = %q, want %q", document.Info.Title, "Grok2API") + } +} diff --git a/backend/internal/transport/http/settings/handler.go b/backend/internal/transport/http/settings/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..aaac4d7bd50eb326dee70d09349cdb3483f56b17 --- /dev/null +++ b/backend/internal/transport/http/settings/handler.go @@ -0,0 +1,348 @@ +package settings + +import ( + "errors" + "net/http" + "strings" + "time" + + settingsapp "github.com/chenyme/grok2api/backend/internal/application/settings" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct{ service *settingsapp.Service } + +func NewHandler(service *settingsapp.Service) *Handler { return &Handler{service: service} } + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/settings", h.get) + router.PUT("/settings", h.update) +} + +type settingsConfigDTO struct { + Server serverConfigDTO `json:"server"` + ProviderBuild providerBuildConfigDTO `json:"providerBuild"` + ProviderWeb providerWebConfigDTO `json:"providerWeb"` + ProviderConsole providerConsoleConfigDTO `json:"providerConsole"` + Batch batchConfigDTO `json:"batch"` + Media mediaConfigDTO `json:"media"` + Frontend frontendConfigDTO `json:"frontend"` + Routing routingConfigDTO `json:"routing"` + Audit auditConfigDTO `json:"audit"` + ClientKeyDefaults clientKeyDefaultsConfigDTO `json:"clientKeyDefaults"` + Accounts *accountsConfigDTO `json:"accounts,omitempty"` +} + +type serverConfigDTO struct { + MaxConcurrentRequests int `json:"maxConcurrentRequests"` +} + +type providerConsoleConfigDTO struct { + BaseURL string `json:"baseURL"` + ChatTimeout string `json:"chatTimeout"` +} + +type mediaConfigDTO struct { + MaxImageBytes int64 `json:"maxImageBytes"` + MaxTotalBytes int64 `json:"maxTotalBytes"` + CleanupThresholdPercent int `json:"cleanupThresholdPercent"` + CleanupInterval string `json:"cleanupInterval"` +} + +type frontendConfigDTO struct { + PublicAPIBaseURL string `json:"publicApiBaseURL"` +} + +type providerBuildConfigDTO struct { + BaseURL string `json:"baseURL"` + FallbackBaseURL string `json:"fallbackBaseURL"` + ClientVersion string `json:"clientVersion"` + ClientIdentifier string `json:"clientIdentifier"` + TokenAuth string `json:"tokenAuth"` + TokenAuthConfigured bool `json:"tokenAuthConfigured"` + UserAgent string `json:"userAgent"` + ResponseHeaderTimeout string `json:"responseHeaderTimeout"` +} + +type providerWebConfigDTO struct { + BaseURL string `json:"baseURL"` + StatsigMode string `json:"statsigMode"` + StatsigManualValue string `json:"statsigManualValue,omitempty"` + StatsigManualConfigured bool `json:"statsigManualConfigured"` + StatsigSignerURL string `json:"statsigSignerURL"` + ClearanceMode *string `json:"clearanceMode,omitempty"` + FlareSolverrURL *string `json:"flareSolverrURL,omitempty"` + ClearanceTimeout *string `json:"clearanceTimeout,omitempty"` + ClearanceRefresh *string `json:"clearanceRefresh,omitempty"` + QuotaTimeout string `json:"quotaTimeout"` + ChatTimeout string `json:"chatTimeout"` + ImageTimeout string `json:"imageTimeout"` + VideoTimeout string `json:"videoTimeout"` + MediaConcurrency int `json:"mediaConcurrency"` + AllowNSFW bool `json:"allowNSFW"` + RecoveryBackoffBase string `json:"recoveryBackoffBase"` + RecoveryBackoffMax string `json:"recoveryBackoffMax"` +} + +type batchConfigDTO struct { + ImportConcurrency int `json:"importConcurrency"` + ConversionConcurrency int `json:"conversionConcurrency"` + SyncConcurrency int `json:"syncConcurrency"` + RefreshConcurrency int `json:"refreshConcurrency"` + RandomDelay string `json:"randomDelay"` +} + +type routingConfigDTO struct { + StickyTTL string `json:"stickyTTL"` + CooldownBase string `json:"cooldownBase"` + CooldownMax string `json:"cooldownMax"` + CapacityWait string `json:"capacityWait"` + MaxAttempts int `json:"maxAttempts"` + PreferFreeBuild bool `json:"preferFreeBuild"` + SegmentedSelector *segmentedSelectorConfigDTO `json:"segmentedSelector,omitempty"` +} + +type segmentedSelectorConfigDTO struct { + Enabled bool `json:"enabled"` + MinCandidates int `json:"minCandidates"` + WindowSize int `json:"windowSize"` +} + +type auditConfigDTO struct { + BufferSize int `json:"bufferSize"` + BatchSize int `json:"batchSize"` + FlushInterval string `json:"flushInterval"` + CommitDelayMS int `json:"commitDelayMS"` +} + +type clientKeyDefaultsConfigDTO struct { + RPMLimit int `json:"rpmLimit"` + MaxConcurrent int `json:"maxConcurrent"` +} + +type accountsConfigDTO struct { + MarkBuildForbiddenReauth *bool `json:"markBuildForbiddenReauth,omitempty"` + BuildForbiddenReauthCodes *[]string `json:"buildForbiddenReauthCodes,omitempty"` + AutoCleanReauthEnabled bool `json:"autoCleanReauthEnabled"` + AutoCleanReauthInterval string `json:"autoCleanReauthInterval"` + AutoCleanReauthMinAge string `json:"autoCleanReauthMinAge"` + AutoCleanIncludeDisabled bool `json:"autoCleanIncludeDisabled"` +} + +type settingsResponse struct { + Config settingsConfigDTO `json:"config"` + RecommendedProviderBuild providerBuildRecommendationDTO `json:"recommendedProviderBuild"` + UpdatedAt time.Time `json:"updatedAt"` + Revision uint64 `json:"revision,string"` + RestartRequired []string `json:"restartRequired"` +} + +type providerBuildRecommendationDTO struct { + ClientVersion string `json:"clientVersion"` + UserAgent string `json:"userAgent"` +} + +type updateRequest struct { + Revision uint64 `json:"revision,string"` + Config settingsConfigDTO `json:"config" binding:"required"` +} + +func (h *Handler) get(c *gin.Context) { + response.Success(c, http.StatusOK, newSettingsResponse(h.service.Get())) +} + +func (h *Handler) update(c *gin.Context) { + var request updateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.Error(c, http.StatusBadRequest, "invalidRequest", "请求参数无效: "+err.Error()) + return + } + result, err := h.service.Update(c.Request.Context(), request.Revision, request.Config.toApplication()) + if err != nil { + if errors.Is(err, settingsapp.ErrInvalidInput) { + response.Error(c, http.StatusBadRequest, "settingsUpdateFailed", err.Error()) + return + } + if errors.Is(err, settingsapp.ErrConflict) { + response.Error(c, http.StatusConflict, "settingsConflict", "设置已被其他会话更新,请刷新后重试") + return + } + response.Error(c, http.StatusInternalServerError, "settingsUpdateFailed", "保存运行设置失败") + return + } + response.Success(c, http.StatusOK, newSettingsResponse(result)) +} + +func (value settingsConfigDTO) toApplication() settingsapp.EditableConfig { + clearanceProvided := value.ProviderWeb.ClearanceMode != nil || value.ProviderWeb.FlareSolverrURL != nil || + value.ProviderWeb.ClearanceTimeout != nil || value.ProviderWeb.ClearanceRefresh != nil + result := settingsapp.EditableConfig{ + Server: settingsapp.ServerConfig{MaxConcurrentRequests: value.Server.MaxConcurrentRequests}, + ProviderBuild: settingsapp.ProviderBuildConfig{ + BaseURL: value.ProviderBuild.BaseURL, FallbackBaseURL: value.ProviderBuild.FallbackBaseURL, + ClientVersion: value.ProviderBuild.ClientVersion, ClientIdentifier: value.ProviderBuild.ClientIdentifier, + TokenAuth: value.ProviderBuild.TokenAuth, UserAgent: value.ProviderBuild.UserAgent, + ResponseHeaderTimeout: value.ProviderBuild.ResponseHeaderTimeout, + }, + ProviderWeb: settingsapp.ProviderWebConfig{ + BaseURL: value.ProviderWeb.BaseURL, QuotaTimeout: value.ProviderWeb.QuotaTimeout, + StatsigMode: value.ProviderWeb.StatsigMode, StatsigManualValue: value.ProviderWeb.StatsigManualValue, + StatsigManualConfigured: value.ProviderWeb.StatsigManualConfigured, StatsigSignerURL: value.ProviderWeb.StatsigSignerURL, + ClearanceMode: optionalString(value.ProviderWeb.ClearanceMode), FlareSolverrURL: optionalString(value.ProviderWeb.FlareSolverrURL), + ClearanceTimeout: optionalString(value.ProviderWeb.ClearanceTimeout), ClearanceRefresh: optionalString(value.ProviderWeb.ClearanceRefresh), + ClearanceProvided: clearanceProvided, + ChatTimeout: value.ProviderWeb.ChatTimeout, ImageTimeout: value.ProviderWeb.ImageTimeout, + VideoTimeout: value.ProviderWeb.VideoTimeout, + MediaConcurrency: value.ProviderWeb.MediaConcurrency, AllowNSFW: value.ProviderWeb.AllowNSFW, + RecoveryBackoffBase: value.ProviderWeb.RecoveryBackoffBase, RecoveryBackoffMax: value.ProviderWeb.RecoveryBackoffMax, + }, + ProviderConsole: settingsapp.ProviderConsoleConfig{ + BaseURL: value.ProviderConsole.BaseURL, ChatTimeout: value.ProviderConsole.ChatTimeout, + }, + Batch: settingsapp.BatchConfig{ + ImportConcurrency: value.Batch.ImportConcurrency, ConversionConcurrency: value.Batch.ConversionConcurrency, + SyncConcurrency: value.Batch.SyncConcurrency, RefreshConcurrency: value.Batch.RefreshConcurrency, + RandomDelay: value.Batch.RandomDelay, + }, + Media: settingsapp.MediaConfig{ + MaxImageBytes: value.Media.MaxImageBytes, MaxTotalBytes: value.Media.MaxTotalBytes, + CleanupThresholdPercent: value.Media.CleanupThresholdPercent, CleanupInterval: value.Media.CleanupInterval, + }, + Frontend: settingsapp.FrontendConfig{ + PublicAPIBaseURL: value.Frontend.PublicAPIBaseURL, + }, + Routing: settingsapp.RoutingConfig{ + StickyTTL: value.Routing.StickyTTL, CooldownBase: value.Routing.CooldownBase, + CooldownMax: value.Routing.CooldownMax, CapacityWait: value.Routing.CapacityWait, MaxAttempts: value.Routing.MaxAttempts, + PreferFreeBuild: value.Routing.PreferFreeBuild, + }, + Audit: settingsapp.AuditConfig{ + BufferSize: value.Audit.BufferSize, BatchSize: value.Audit.BatchSize, FlushInterval: value.Audit.FlushInterval, CommitDelayMS: value.Audit.CommitDelayMS, + }, + ClientKeyDefaults: settingsapp.ClientKeyDefaultsConfig{ + RPMLimit: value.ClientKeyDefaults.RPMLimit, MaxConcurrent: value.ClientKeyDefaults.MaxConcurrent, + }, + } + if value.Routing.SegmentedSelector != nil { + result.Routing.SegmentedSelector = settingsapp.SegmentedSelectorConfig{ + Enabled: value.Routing.SegmentedSelector.Enabled, MinCandidates: value.Routing.SegmentedSelector.MinCandidates, + WindowSize: value.Routing.SegmentedSelector.WindowSize, + } + result.Routing.SegmentedSelectorProvided = true + } + if value.Accounts != nil { + result.Accounts = settingsapp.AccountsConfig{ + MarkBuildForbiddenReauth: boolValue(value.Accounts.MarkBuildForbiddenReauth), + BuildForbiddenReauthCodes: stringSliceValue(value.Accounts.BuildForbiddenReauthCodes), + MarkBuildForbiddenReauthProvided: value.Accounts.MarkBuildForbiddenReauth != nil, + BuildForbiddenReauthCodesProvided: value.Accounts.BuildForbiddenReauthCodes != nil, + AutoCleanReauthEnabled: value.Accounts.AutoCleanReauthEnabled, + AutoCleanReauthInterval: value.Accounts.AutoCleanReauthInterval, + AutoCleanReauthMinAge: value.Accounts.AutoCleanReauthMinAge, + AutoCleanIncludeDisabled: value.Accounts.AutoCleanIncludeDisabled, + } + result.AccountsProvided = true + } + return result +} + +func newSettingsResponse(value settingsapp.Snapshot) settingsResponse { + config := value.Config + return settingsResponse{ + Config: settingsConfigDTO{ + Server: serverConfigDTO{MaxConcurrentRequests: config.Server.MaxConcurrentRequests}, + ProviderBuild: providerBuildConfigDTO{ + BaseURL: config.ProviderBuild.BaseURL, FallbackBaseURL: config.ProviderBuild.FallbackBaseURL, + ClientVersion: config.ProviderBuild.ClientVersion, ClientIdentifier: config.ProviderBuild.ClientIdentifier, + TokenAuth: config.ProviderBuild.TokenAuth, + TokenAuthConfigured: strings.TrimSpace(config.ProviderBuild.TokenAuth) != "", UserAgent: config.ProviderBuild.UserAgent, + ResponseHeaderTimeout: config.ProviderBuild.ResponseHeaderTimeout, + }, + ProviderWeb: providerWebConfigDTO{ + BaseURL: config.ProviderWeb.BaseURL, QuotaTimeout: config.ProviderWeb.QuotaTimeout, + StatsigMode: config.ProviderWeb.StatsigMode, StatsigManualConfigured: config.ProviderWeb.StatsigManualConfigured, + StatsigSignerURL: config.ProviderWeb.StatsigSignerURL, + ClearanceMode: stringPointer(config.ProviderWeb.ClearanceMode), FlareSolverrURL: stringPointer(config.ProviderWeb.FlareSolverrURL), + ClearanceTimeout: stringPointer(config.ProviderWeb.ClearanceTimeout), ClearanceRefresh: stringPointer(config.ProviderWeb.ClearanceRefresh), + ChatTimeout: config.ProviderWeb.ChatTimeout, ImageTimeout: config.ProviderWeb.ImageTimeout, + VideoTimeout: config.ProviderWeb.VideoTimeout, + MediaConcurrency: config.ProviderWeb.MediaConcurrency, AllowNSFW: config.ProviderWeb.AllowNSFW, + RecoveryBackoffBase: config.ProviderWeb.RecoveryBackoffBase, RecoveryBackoffMax: config.ProviderWeb.RecoveryBackoffMax, + }, + ProviderConsole: providerConsoleConfigDTO{ + BaseURL: config.ProviderConsole.BaseURL, ChatTimeout: config.ProviderConsole.ChatTimeout, + }, + Batch: batchConfigDTO{ + ImportConcurrency: config.Batch.ImportConcurrency, ConversionConcurrency: config.Batch.ConversionConcurrency, + SyncConcurrency: config.Batch.SyncConcurrency, RefreshConcurrency: config.Batch.RefreshConcurrency, + RandomDelay: config.Batch.RandomDelay, + }, + Media: mediaConfigDTO{ + MaxImageBytes: config.Media.MaxImageBytes, MaxTotalBytes: config.Media.MaxTotalBytes, + CleanupThresholdPercent: config.Media.CleanupThresholdPercent, CleanupInterval: config.Media.CleanupInterval, + }, + Frontend: frontendConfigDTO{ + PublicAPIBaseURL: config.Frontend.PublicAPIBaseURL, + }, + Routing: routingConfigDTO{ + StickyTTL: config.Routing.StickyTTL, CooldownBase: config.Routing.CooldownBase, + CooldownMax: config.Routing.CooldownMax, CapacityWait: config.Routing.CapacityWait, MaxAttempts: config.Routing.MaxAttempts, + PreferFreeBuild: config.Routing.PreferFreeBuild, + SegmentedSelector: &segmentedSelectorConfigDTO{ + Enabled: config.Routing.SegmentedSelector.Enabled, MinCandidates: config.Routing.SegmentedSelector.MinCandidates, + WindowSize: config.Routing.SegmentedSelector.WindowSize, + }, + }, + Audit: auditConfigDTO{ + BufferSize: config.Audit.BufferSize, BatchSize: config.Audit.BatchSize, FlushInterval: config.Audit.FlushInterval, CommitDelayMS: config.Audit.CommitDelayMS, + }, + ClientKeyDefaults: clientKeyDefaultsConfigDTO{ + RPMLimit: config.ClientKeyDefaults.RPMLimit, MaxConcurrent: config.ClientKeyDefaults.MaxConcurrent, + }, + Accounts: &accountsConfigDTO{ + MarkBuildForbiddenReauth: boolPointer(config.Accounts.MarkBuildForbiddenReauth), + BuildForbiddenReauthCodes: stringSlicePointer(config.Accounts.BuildForbiddenReauthCodes), + AutoCleanReauthEnabled: config.Accounts.AutoCleanReauthEnabled, + AutoCleanReauthInterval: config.Accounts.AutoCleanReauthInterval, + AutoCleanReauthMinAge: config.Accounts.AutoCleanReauthMinAge, + AutoCleanIncludeDisabled: config.Accounts.AutoCleanIncludeDisabled, + }, + }, + RecommendedProviderBuild: providerBuildRecommendationDTO{ + ClientVersion: value.RecommendedProviderBuild.ClientVersion, + UserAgent: value.RecommendedProviderBuild.UserAgent, + }, + UpdatedAt: value.UpdatedAt, Revision: value.Revision, RestartRequired: value.RestartRequired, + } +} + +func optionalString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func stringPointer(value string) *string { return &value } + +func boolPointer(value bool) *bool { return &value } + +func boolValue(value *bool) bool { + if value == nil { + return false + } + return *value +} + +func stringSliceValue(value *[]string) []string { + if value == nil { + return nil + } + return append([]string(nil), (*value)...) +} + +func stringSlicePointer(value []string) *[]string { + cloned := append([]string(nil), value...) + return &cloned +} diff --git a/backend/internal/transport/http/settings/security_test.go b/backend/internal/transport/http/settings/security_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a2c5fb3bc45a98ca016c2cd39f30f0a1d498d157 --- /dev/null +++ b/backend/internal/transport/http/settings/security_test.go @@ -0,0 +1,136 @@ +package settings + +import ( + "encoding/json" + "strings" + "testing" + + settingsapp "github.com/chenyme/grok2api/backend/internal/application/settings" +) + +func TestSettingsDTOExcludesBrowserIdentityFields(t *testing.T) { + data, err := json.Marshal(settingsConfigDTO{}) + if err != nil { + t.Fatal(err) + } + lower := strings.ToLower(string(data)) + for _, forbidden := range []string{"grok_device_id", "x-anonuserid", "x-userid", "x-challenge", "x-signature"} { + if strings.Contains(lower, forbidden) { + t.Fatalf("settings response contains forbidden field %q", forbidden) + } + } +} + +func TestSettingsResponseDoesNotExposeManualStatsigValue(t *testing.T) { + response := newSettingsResponse(settingsapp.Snapshot{Config: settingsapp.EditableConfig{ProviderWeb: settingsapp.ProviderWebConfig{ + StatsigMode: "manual", StatsigManualValue: "must-not-leak", StatsigManualConfigured: true, + }}}) + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "must-not-leak") || strings.Contains(string(data), "statsigManualValue") { + t.Fatalf("settings response leaked manual Statsig: %s", data) + } +} + +func TestSettingsResponseIncludesBuildTokenAuth(t *testing.T) { + response := newSettingsResponse(settingsapp.Snapshot{Config: settingsapp.EditableConfig{ProviderBuild: settingsapp.ProviderBuildConfig{TokenAuth: "xai-grok-cli"}}}) + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"tokenAuth":"xai-grok-cli"`) || !strings.Contains(string(data), `"tokenAuthConfigured":true`) { + t.Fatalf("settings response lost Build token auth: %s", data) + } +} + +func TestSettingsResponseIncludesRecommendedBuildBaseline(t *testing.T) { + response := newSettingsResponse(settingsapp.Snapshot{RecommendedProviderBuild: settingsapp.ProviderBuildRecommendation{ + ClientVersion: "0.2.111", UserAgent: "grok-shell/0.2.111 (linux; x86_64)", + }}) + if response.RecommendedProviderBuild.ClientVersion != "0.2.111" || response.RecommendedProviderBuild.UserAgent == "" { + t.Fatalf("recommended build = %#v", response.RecommendedProviderBuild) + } +} + +func TestSettingsResponseIncludesPreferFreeBuild(t *testing.T) { + response := newSettingsResponse(settingsapp.Snapshot{Config: settingsapp.EditableConfig{ + Routing: settingsapp.RoutingConfig{PreferFreeBuild: true}, + }}) + if !response.Config.Routing.PreferFreeBuild { + t.Fatal("preferFreeBuild was lost from settings response") + } +} + +func TestLegacySettingsRequestMayOmitAccounts(t *testing.T) { + var dto settingsConfigDTO + if err := json.Unmarshal([]byte(`{"server":{"maxConcurrentRequests":64}}`), &dto); err != nil { + t.Fatal(err) + } + input := dto.toApplication() + if input.AccountsProvided { + t.Fatal("missing accounts field was treated as an explicit update") + } +} + +func TestLegacySettingsRequestPreservesBuildForbiddenCodesWhenOmitted(t *testing.T) { + var dto settingsConfigDTO + if err := json.Unmarshal([]byte(`{"accounts":{"markBuildForbiddenReauth":true,"autoCleanReauthEnabled":false,"autoCleanReauthInterval":"10m","autoCleanReauthMinAge":"1h","autoCleanIncludeDisabled":false}}`), &dto); err != nil { + t.Fatal(err) + } + input := dto.toApplication() + if !input.AccountsProvided || !input.Accounts.MarkBuildForbiddenReauthProvided || input.Accounts.BuildForbiddenReauthCodesProvided { + t.Fatalf("legacy field presence was not preserved: %#v", input.Accounts) + } +} + +func TestSettingsResponseIncludesBuildForbiddenCodes(t *testing.T) { + response := newSettingsResponse(settingsapp.Snapshot{Config: settingsapp.EditableConfig{ + Accounts: settingsapp.AccountsConfig{ + MarkBuildForbiddenReauth: true, + BuildForbiddenReauthCodes: []string{"permission-denied", "team-access-denied"}, + }, + }}) + if response.Config.Accounts == nil || response.Config.Accounts.BuildForbiddenReauthCodes == nil { + t.Fatal("Build forbidden codes were omitted from the settings response") + } + codes := *response.Config.Accounts.BuildForbiddenReauthCodes + if len(codes) != 2 || codes[0] != "permission-denied" || codes[1] != "team-access-denied" { + t.Fatalf("Build forbidden codes = %#v", codes) + } +} + +func TestLegacySettingsRequestMayOmitSegmentedSelector(t *testing.T) { + var dto settingsConfigDTO + if err := json.Unmarshal([]byte(`{"routing":{"stickyTTL":"1h"}}`), &dto); err != nil { + t.Fatal(err) + } + input := dto.toApplication() + if input.Routing.SegmentedSelectorProvided { + t.Fatal("missing segmented selector was treated as an explicit update") + } +} + +func TestSettingsResponseIncludesSegmentedSelector(t *testing.T) { + response := newSettingsResponse(settingsapp.Snapshot{Config: settingsapp.EditableConfig{ + Routing: settingsapp.RoutingConfig{SegmentedSelector: settingsapp.SegmentedSelectorConfig{ + Enabled: true, MinCandidates: 3000, WindowSize: 64, + }}, + }}) + selector := response.Config.Routing.SegmentedSelector + if selector == nil || !selector.Enabled || selector.MinCandidates != 3000 || selector.WindowSize != 64 { + t.Fatalf("segmented selector = %#v", selector) + } +} + +func TestLegacySettingsRequestMayOmitManagedClearance(t *testing.T) { + var dto settingsConfigDTO + if err := json.Unmarshal([]byte(`{"providerWeb":{"baseURL":"https://grok.com"}}`), &dto); err != nil { + t.Fatal(err) + } + input := dto.toApplication() + if input.ProviderWeb.ClearanceProvided { + t.Fatal("missing managed-clearance fields were treated as an explicit update") + } +} diff --git a/backend/internal/transport/http/swagger_annotations.go b/backend/internal/transport/http/swagger_annotations.go new file mode 100644 index 0000000000000000000000000000000000000000..143048740f0f5bf662730bd264496b76b599df3d --- /dev/null +++ b/backend/internal/transport/http/swagger_annotations.go @@ -0,0 +1,229 @@ +package httpserver + +// SwaggerMessage 表示 Chat Completions 请求中的一条消息。 +type SwaggerMessage struct { + Role string `json:"role" example:"user"` + Content any `json:"content"` +} + +// SwaggerResponsesRequest 表示最小 Responses 请求。 +type SwaggerResponsesRequest struct { + Model string `json:"model" example:"grok-chat-auto"` + Input any `json:"input"` + Stream bool `json:"stream" example:"false"` + Store bool `json:"store" example:"false"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` +} + +// SwaggerChatRequest 表示最小 Chat Completions 请求。 +type SwaggerChatRequest struct { + Model string `json:"model" example:"grok-chat-fast"` + Messages []SwaggerMessage `json:"messages"` + Stream bool `json:"stream" example:"false"` +} + +// SwaggerMessagesRequest 表示最小 Anthropic Messages 请求。 +type SwaggerMessagesRequest struct { + Model string `json:"model" example:"grok-chat-expert"` + MaxTokens int `json:"max_tokens" example:"1024"` + Messages []SwaggerMessage `json:"messages"` + Stream bool `json:"stream" example:"false"` +} + +// SwaggerImageGenerationRequest 表示图片生成请求。 +type SwaggerImageGenerationRequest struct { + Model string `json:"model" example:"grok-imagine-image-quality"` + Prompt string `json:"prompt" example:"A cinematic city at night"` + N int `json:"n" example:"1"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"` + Resolution string `json:"resolution,omitempty" example:"2k"` + ResponseFormat string `json:"response_format,omitempty" example:"url"` + Stream bool `json:"stream,omitempty" example:"false"` + PartialImages int `json:"partial_images,omitempty" example:"0"` +} + +// SwaggerImageReference 表示图片 URL 输入。 +type SwaggerImageReference struct { + URL string `json:"url" example:"https://example.com/source.png"` +} + +// SwaggerImageEditRequest 表示图片编辑请求。 +type SwaggerImageEditRequest struct { + Model string `json:"model" example:"grok-imagine-image-edit"` + Prompt string `json:"prompt" example:"Change the background to black"` + Image SwaggerImageReference `json:"image"` + N int `json:"n" example:"1"` + Size string `json:"size,omitempty" example:"1024x1024"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"1:1"` + Resolution string `json:"resolution,omitempty" example:"1k"` + ResponseFormat string `json:"response_format,omitempty" example:"url"` + Stream bool `json:"stream,omitempty" example:"false"` + PartialImages int `json:"partial_images,omitempty" example:"0"` +} + +// SwaggerVideoGenerationRequest 表示视频生成请求。 +type SwaggerVideoGenerationRequest struct { + Model string `json:"model" example:"grok-imagine-video"` + Prompt string `json:"prompt" example:"A cinematic tracking shot in the rain"` + Duration int `json:"duration" example:"8"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"` + Resolution string `json:"resolution,omitempty" example:"720p"` +} + +// swaggerHealth godoc +// @Summary 存活检查 +// @Tags System +// @Produce json +// @Success 200 {object} map[string]bool +// @Router /healthz [get] +func swaggerHealth() {} + +// swaggerReady godoc +// @Summary 就绪检查 +// @Tags System +// @Produce json +// @Success 200 {object} map[string]bool +// @Failure 503 {object} map[string]bool +// @Router /readyz [get] +func swaggerReady() {} + +// swaggerModels godoc +// @Summary 获取可用模型 +// @Tags Models +// @Security BearerAuth +// @Produce json +// @Success 200 {object} map[string]any +// @Failure 401 {object} map[string]any +// @Router /v1/models [get] +func swaggerModels() {} + +// swaggerResponses godoc +// @Summary 创建 Response +// @Description 支持 JSON 与 SSE;stream=true 时返回 text/event-stream。 +// @Tags Responses +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body SwaggerResponsesRequest true "请求" +// @Success 200 {object} map[string]any +// @Failure 400 {object} map[string]any +// @Failure 401 {object} map[string]any +// @Router /v1/responses [post] +func swaggerResponses() {} + +// swaggerCompactResponse godoc +// @Summary 压缩 Response 上下文 +// @Tags Responses +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body SwaggerResponsesRequest true "请求" +// @Success 200 {object} map[string]any +// @Router /v1/responses/compact [post] +func swaggerCompactResponse() {} + +// swaggerGetResponse godoc +// @Summary 查询 Response +// @Tags Responses +// @Security BearerAuth +// @Produce json +// @Param response_id path string true "Response ID" +// @Success 200 {object} map[string]any +// @Failure 404 {object} map[string]any +// @Router /v1/responses/{response_id} [get] +func swaggerGetResponse() {} + +// swaggerDeleteResponse godoc +// @Summary 删除 Response +// @Tags Responses +// @Security BearerAuth +// @Produce json +// @Param response_id path string true "Response ID" +// @Success 200 {object} map[string]any +// @Failure 404 {object} map[string]any +// @Router /v1/responses/{response_id} [delete] +func swaggerDeleteResponse() {} + +// swaggerChat godoc +// @Summary 创建 Chat Completion +// @Description 支持 JSON 与 SSE、图片输入和函数工具。 +// @Tags Chat +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body SwaggerChatRequest true "请求" +// @Success 200 {object} map[string]any +// @Failure 400 {object} map[string]any +// @Router /v1/chat/completions [post] +func swaggerChat() {} + +// swaggerMessages godoc +// @Summary 创建 Anthropic Message +// @Tags Messages +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param anthropic-version header string true "Anthropic API version" default(2023-06-01) +// @Param request body SwaggerMessagesRequest true "请求" +// @Success 200 {object} map[string]any +// @Failure 400 {object} map[string]any +// @Router /v1/messages [post] +func swaggerMessages() {} + +// swaggerGenerateImage godoc +// @Summary 生成图片 +// @Tags Images +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body SwaggerImageGenerationRequest true "请求" +// @Success 200 {object} map[string]any +// @Failure 400 {object} map[string]any +// @Router /v1/images/generations [post] +func swaggerGenerateImage() {} + +// swaggerEditImage godoc +// @Summary 编辑图片 +// @Tags Images +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body SwaggerImageEditRequest true "请求" +// @Success 200 {object} map[string]any +// @Failure 400 {object} map[string]any +// @Router /v1/images/edits [post] +func swaggerEditImage() {} + +// swaggerGetImage godoc +// @Summary 获取归档图片 +// @Tags Images +// @Produce image/png +// @Param asset_id path string true "Asset ID" +// @Success 200 {file} binary +// @Failure 404 +// @Router /v1/media/images/{asset_id} [get] +func swaggerGetImage() {} + +// swaggerGenerateVideo godoc +// @Summary 创建异步视频任务 +// @Tags Videos +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body SwaggerVideoGenerationRequest true "请求" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]any +// @Router /v1/videos/generations [post] +func swaggerGenerateVideo() {} + +// swaggerGetVideo godoc +// @Summary 查询异步视频任务 +// @Tags Videos +// @Security BearerAuth +// @Produce json +// @Param request_id path string true "Request ID" +// @Success 200 {object} map[string]any +// @Failure 404 {object} map[string]any +// @Router /v1/videos/{request_id} [get] +func swaggerGetVideo() {} diff --git a/backend/internal/transport/http/system/handler.go b/backend/internal/transport/http/system/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..c2a58c6e86f7cb523c515228e5f04590b1cc3bd4 --- /dev/null +++ b/backend/internal/transport/http/system/handler.go @@ -0,0 +1,43 @@ +package system + +import ( + "net/http" + "strings" + + updatecheckapp "github.com/chenyme/grok2api/backend/internal/application/updatecheck" + "github.com/chenyme/grok2api/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +type Handler struct { + publicAPIBaseURL func() string + updates *updatecheckapp.Service +} + +func NewHandler(publicAPIBaseURL func() string, updates *updatecheckapp.Service) *Handler { + if publicAPIBaseURL == nil { + publicAPIBaseURL = func() string { return "" } + } + if updates == nil { + updates = updatecheckapp.NewService("dev", nil) + } + return &Handler{publicAPIBaseURL: publicAPIBaseURL, updates: updates} +} + +func (h *Handler) Register(router *gin.RouterGroup) { + router.GET("/system", h.get) + router.GET("/system/version", h.version) + router.POST("/system/update/check", h.checkUpdate) +} + +func (h *Handler) version(c *gin.Context) { + response.Success(c, http.StatusOK, h.updates.Snapshot()) +} + +func (h *Handler) checkUpdate(c *gin.Context) { + response.Success(c, http.StatusOK, h.updates.Check(c.Request.Context())) +} + +func (h *Handler) get(c *gin.Context) { + response.Success(c, http.StatusOK, gin.H{"publicApiBaseURL": strings.TrimRight(strings.TrimSpace(h.publicAPIBaseURL()), "/")}) +} diff --git a/backend/internal/transport/http/system/handler_test.go b/backend/internal/transport/http/system/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9ab0bf4593efbadf0679dea8530cff3c09967efb --- /dev/null +++ b/backend/internal/transport/http/system/handler_test.go @@ -0,0 +1,78 @@ +package system + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + updatecheckapp "github.com/chenyme/grok2api/backend/internal/application/updatecheck" + "github.com/gin-gonic/gin" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func TestHandlerReturnsOnlyPublicFrontendConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(func() string { return "https://api.example.com/" }, updatecheckapp.NewService("v3.0.0", nil)).Register(router.Group("/api/admin/v1")) + request := httptest.NewRequest(http.MethodGet, "/api/admin/v1/system", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + var payload struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if len(payload.Data) != 1 || payload.Data["publicApiBaseURL"] != "https://api.example.com" { + t.Fatalf("data = %#v", payload.Data) + } +} + +func TestHandlerReturnsAndChecksVersion(t *testing.T) { + gin.SetMode(gin.TestMode) + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v3.0.1","body":"Notes"}`))}, nil + })} + router := gin.New() + updates := updatecheckapp.NewService("v3.0.0", client) + NewHandler(nil, updates).Register(router.Group("/api/admin/v1")) + + for _, test := range []struct { + method string + path string + status string + }{ + {method: http.MethodGet, path: "/api/admin/v1/system/version", status: "unchecked"}, + {method: http.MethodPost, path: "/api/admin/v1/system/update/check", status: "update_available"}, + } { + request := httptest.NewRequest(test.method, test.path, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("%s %s status = %d", test.method, test.path, recorder.Code) + } + var payload struct { + Data struct { + CurrentVersion string `json:"currentVersion"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.CurrentVersion != "v3.0.0" || payload.Data.Status != test.status { + t.Fatalf("%s %s data = %#v", test.method, test.path, payload.Data) + } + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cc5562418ecc41b8766454616d4c40f204b271de --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,107 @@ +# 配置标记: +# - [必须修改] 首次启动前必须替换,否则服务会拒绝启动或存在安全风险。 +# - [按需修改] 仅在部署方式发生变化时调整。 +# - [通常不要修改] 已采用推荐默认值。 + +server: + # [通常不要修改] Docker 镜像会通过启动参数覆盖为 0.0.0.0:8000。 + listen: "127.0.0.1:8000" + # [通常不要修改] 单次请求体上限 32 MiB。 + maxBodyBytes: 33554432 + # [通常不要修改] 仅限制客户端上传完整请求体的时间,不限制推理或流式响应。 + readTimeout: 15m + # [通常不要修改] 管理 API 和非流式请求的最长生命周期。 + requestTimeout: 2h + # [按需修改] 仅在本地或受信任环境开启,生产环境保持 false。 + swaggerEnabled: false + +auth: + # [通常不要修改] 管理端访问与刷新令牌有效期。 + accessTokenTTL: 15m + refreshTokenTTL: 720h + # [按需修改] 外部地址使用 HTTPS 时必须设为 true。 + secureCookies: false + +secrets: + # [必须修改] 至少 32 个字符。快速生成:openssl rand -hex 32 + jwtSecret: "replace-with-at-least-32-characters" + # [必须修改] Base64 编码的 32 字节密钥。快速生成:openssl rand -base64 32 + # 首次写入账号后必须长期保留,后续更换会导致已有凭据无法解密。 + credentialEncryptionKey: "replace-with-base64-key" + +bootstrapAdmin: + # [按需修改] 仅在数据库中没有管理员时使用。 + username: "admin" + # [必须修改] 首次启动前替换为强密码;管理员创建成功后建议删除本段。 + password: "replace-with-a-strong-password" + +frontend: + # [通常不要修改] Go 服务同源托管的前端构建目录。 + staticPath: "./frontend/dist" + +database: + # [按需修改] 单实例使用 sqlite;多实例改为 postgres。 + driver: sqlite # sqlite | postgres + sqlite: + # [通常不要修改] 相对路径以本配置文件所在目录为基准。 + path: "./data/backend.db" + postgres: + # [必须修改:仅 driver=postgres] 填写真实 DSN,并按需启用 sslmode。 + dsn: "postgres://user:password@127.0.0.1:5432/grok2api?sslmode=disable" + # [按需修改] 根据数据库连接容量调整。 + maxOpenConns: 50 + maxIdleConns: 10 + +runtimeStore: + # [按需修改] 单实例使用 memory;多实例改为 redis。 + driver: memory # memory | redis + redis: + # [必须修改:仅 driver=redis] 填写 Redis 连接信息。 + address: "127.0.0.1:6379" + username: "" + password: "" + database: 0 + # [通常不要修改] 同一 Redis 中部署多个实例组时才需要更换。 + keyPrefix: "grok2api:" + # [按需修改] Redis 使用 TLS 时设为 true。 + tls: false + +deployment: + # [通常不要修改] 单实例保持 1;多实例必须同时使用 PostgreSQL、Redis 和共享媒体目录。 + replicas: 1 + # [必须修改:仅 replicas>1] 每个实例使用不同的稳定标识。 + instanceID: "" + # [必须修改:仅 replicas>1] 同一实例组和共享媒体目录必须使用相同标识。 + clusterID: "grok2api" + # [必须修改:仅 replicas>1] 确认 media.local.path 已挂载为所有实例共享且可读写的存储。 + sharedMedia: false + +media: + # [通常不要修改] 当前仅支持 local;容量和清理策略在管理端设置。 + driver: local + local: + # [按需修改] 图片二进制目录;数据库只保存资源元数据。 + path: "./data/media" + +routing: + # [按需修改] 服务端推理回放缓存:多轮时自动补回客户端未携带的 encrypted_content。 + reasoningReplayEnabled: true + reasoningReplayTTL: 1h + reasoningReplayMaxEntries: 10240 + # [通常不要修改] 大账号池分段选号默认关闭;启用后仍保留完整池回退和原子并发门禁。 + segmentedSelectorEnabled: false + segmentedSelectorMinCandidates: 3000 + segmentedSelectorWindowSize: 64 + +audit: + # [通常不要修改] 审计批量写入参数;bufferSize 的修改需重启生效。 + bufferSize: 16384 + batchSize: 256 + flushInterval: 250ms + # [通常不要修改] 需要调用方确认提交的审计最多聚合等待该时长;建议保持 1ms–50ms。 + commitDelay: 5ms + # [通常不要修改] enforce 会在异常持续超过宽限期后暂停新的推理请求;确认发生数据丢失时会立即暂停。 + ledgerMode: enforce # observe | enforce + ledgerFailureThreshold: 1 + ledgerUnhealthyGrace: 10s + ledgerQueueHighWatermarkPercent: 90 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a92d0e4550fb46bcbd3a0ed50218e52dc13991c5 --- /dev/null +++ b/config.yaml @@ -0,0 +1,78 @@ +# ============================================================ +# Grok2API - Hugging Face Spaces 部署配置 (v3.0.11) +# 该文件会被打进 Docker 镜像并复制到 /run/grok2api/config.yaml +# 注意:此仓库为公开空间时,以下密钥对所有人可见! +# 建议把 Space 设为 Private,或首次登录后在管理端修改密码。 +# 数据默认存于 /data(HF Persistent Storage 挂载点) +# ============================================================ + +server: + listen: "0.0.0.0:8000" + maxBodyBytes: 33554432 + readTimeout: 15m + requestTimeout: 2h + swaggerEnabled: false + +auth: + accessTokenTTL: 15m + refreshTokenTTL: 720h + secureCookies: false + +secrets: + jwtSecret: "ef8516093b5160dfd2666af9c6c68e37360296ade649b319130805fbbc6c4f47" + credentialEncryptionKey: "n6ZgFOd0wpreLiVphfWQzcCZVUXn5chv58Xe1SHrKM4=" + +bootstrapAdmin: + username: "admin" + password: "0Mzt0RiS9WqEP6STIA7x" + +frontend: + staticPath: "./frontend/dist" + +database: + driver: sqlite # sqlite | postgres + sqlite: + path: "/data/backend.db" + postgres: + dsn: "postgres://user:password@127.0.0.1:5432/grok2api?sslmode=disable" + maxOpenConns: 50 + maxIdleConns: 10 + +runtimeStore: + driver: memory # memory | redis + redis: + address: "127.0.0.1:6379" + username: "" + password: "" + database: 0 + keyPrefix: "grok2api:" + tls: false + +deployment: + replicas: 1 + instanceID: "" + clusterID: "grok2api" + sharedMedia: false + +media: + driver: local + local: + path: "/data/media" + +routing: + reasoningReplayEnabled: true + reasoningReplayTTL: 1h + reasoningReplayMaxEntries: 10240 + segmentedSelectorEnabled: false + segmentedSelectorMinCandidates: 3000 + segmentedSelectorWindowSize: 64 + +audit: + bufferSize: 16384 + batchSize: 256 + flushInterval: 250ms + commitDelay: 5ms + ledgerMode: enforce # observe | enforce + ledgerFailureThreshold: 1 + ledgerUnhealthyGrace: 10s + ledgerQueueHighWatermarkPercent: 90 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d2d731d77faa9172f8c7a3a39b1a281754460d2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +name: grok2api + +services: + grok2api: + container_name: grok2api + image: "${GROK2API_IMAGE:-ghcr.io/chenyme/grok2api:latest}" + ports: + - "${GROK2API_PORT:-8000}:8000" + environment: + TZ: "${TZ:-Asia/Shanghai}" + volumes: + - "${GROK2API_CONFIG:-./config.yaml}:/run/grok2api/config.yaml:ro" + - grok2api-data:/app/data + restart: unless-stopped + init: true + stop_grace_period: 30s + security_opt: + - no-new-privileges:true + + # ================================ WARP ================================ + # 如果出口 IP 不干净,可取消以下注释使用 WARP 作为落地代理。 + # 启用后,在管理端“设置 -> 出口代理”中新增 socks5://warp:1080。 + + # warp: + # container_name: warp + # image: caomingjun/warp:latest + # profiles: ["warp"] + # restart: unless-stopped + # ports: + # - "127.0.0.1:1080:1080" + # environment: + # - WARP_SLEEP=2 + # cap_add: + # - NET_ADMIN + + # ================================ FLARESOLVERR ================================ + # 在管理端将 Clearance 模式设为 FlareSolverr,并使用 http://flaresolverr:8191。 + # 启动命令:docker compose --profile flaresolverr up -d + # Podman:podman compose --profile flaresolverr up -d + + # flaresolverr: + # container_name: flaresolverr + # image: ghcr.io/flaresolverr/flaresolverr:latest + # profiles: ["flaresolverr"] + # environment: + # TZ: Asia/Shanghai + # LOG_LEVEL: info + # restart: unless-stopped + +volumes: + grok2api-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000000000000000000000000000000000000..693983c78738dfec41332e8402447eed2edc5765 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +umask 077 + +# Hugging Face Spaces: /data 是 Persistent Storage 挂载点(未启用时是普通目录) +# 容器以 root 启动,这里确保应用用户 grok2api 对 /data 有写权限 +mkdir -p /data +chown -R grok2api:grok2api /data + +if [ ! -f "${GROK2API_CONFIG_SOURCE}" ]; then + echo "missing config: ${GROK2API_CONFIG_SOURCE}" >&2 + echo "mount config.yaml to /run/grok2api/config.yaml" >&2 + exit 1 +fi + +cp "${GROK2API_CONFIG_SOURCE}" /app/config.yaml +chown grok2api:grok2api /app/config.yaml +chmod 0600 /app/config.yaml + +exec su-exec grok2api:grok2api "$@" diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f1d07f4c29c11a169c473e2cae744ebd4915e322 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,55 @@ +# Grok2API Frontend + +Grok2API 的管理端 SPA,用于管理账号池、模型路由、客户端密钥、请求审计和运行设置。 + +## 技术栈 + +- React 19 + TypeScript +- Vite 8 + Tailwind CSS +- shadcn/ui + Radix UI +- TanStack Query、React Hook Form、Zod + +## 本地开发 + +先启动根目录中的 Go 后端,再运行: + +```bash +cd frontend +pnpm install +pnpm dev +``` + +开发服务器默认地址为 `http://127.0.0.1:5173`,并将 `/api`、`/v1`、`/healthz` 和 `/readyz` 代理到 `http://127.0.0.1:8000`。 + +需要使用其他后端地址时: + +```bash +VITE_DEV_API_TARGET=http://127.0.0.1:9000 pnpm dev +``` + +## 生产构建 + +```bash +pnpm build +``` + +构建结果输出到 `dist/`。后端通过根配置中的 `frontend.staticPath` 同源托管该目录,并为非 API 路径提供 SPA 回退。前端不读取原始 YAML,公开运行信息由后端受控接口提供。 + +## 代码结构 + +```text +src/app/ 路由与应用壳层 +src/features/ 按业务能力组织的页面与交互 +src/entities/ 领域 DTO 与查询接口 +src/shared/ API、鉴权、配置、组件和通用工具 +src/components/ui/ shadcn/ui 基础组件 +``` + +业务请求统一通过 `shared/api`,服务端状态由 TanStack Query 管理;页面只组合业务能力,不直接维护重复的请求、鉴权或格式化逻辑。 + +## 验证 + +```bash +pnpm lint +pnpm build +``` diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000000000000000000000000000000000000..218ba4aa848d71008856a07088e0c6eb24fe53f1 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/shared/lib/cn", + "ui": "@/components/ui", + "lib": "@/shared/lib", + "hooks": "@/shared/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..8db7db4f56b067487293d1e1812bd030f5a22932 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "src/components/ui"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-explicit-any": "error", + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..4a714c6fcdad7a9d2f78fee579d3d54c120846db --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + Grok2API + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9009f7f0e9caaf042b4951e6b83056f4244f8567 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,63 @@ +{ + "name": "grok2api-frontend", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@11.5.2", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-alert-dialog": "^1.1.19", + "@radix-ui/react-checkbox": "^1.3.7", + "@radix-ui/react-dialog": "^1.1.19", + "@radix-ui/react-dropdown-menu": "^2.1.20", + "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-popover": "^1.1.19", + "@radix-ui/react-select": "^2.3.3", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.17", + "@radix-ui/react-tooltip": "^1.2.12", + "@shadcn/react": "^0.2.1", + "@tanstack/react-query": "^5.101.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "i18next": "^26.3.6", + "lucide-react": "^1.24.0", + "marked": "^18.0.6", + "next-themes": "^0.4.6", + "react": "^19.2.7", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.7", + "react-hook-form": "^7.81.0", + "react-i18next": "^17.0.9", + "react-router-dom": "^7.18.1", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "tailwindcss": "^4.3.2", + "typescript": "^6.0.2", + "typescript-eslint": "^8.63.0", + "vite": "^8.1.4" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b33205275fc5e6eac76dd8f2f1cb226445613f16 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3525 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.81.0(react@19.2.7)) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': + specifier: ^1.3.7 + version: 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.20 + version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': + specifier: ^2.1.11 + version: 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': + specifier: ^2.3.3 + version: 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': + specifier: ^1.1.17 + version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@shadcn/react': + specifier: ^0.2.1 + version: 0.2.1(@types/react@19.2.17)(react@19.2.7) + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@6.0.2) + lucide-react: + specifier: ^1.24.0 + version: 1.24.0(react@19.2.7) + marked: + specifier: ^18.0.6 + version: 18.0.6 + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.17)(react@19.2.7) + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.81.0 + version: 7.81.0(react@19.2.7) + react-i18next: + specifier: ^17.0.9 + version: 17.0.9(i18next@26.3.6(typescript@6.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.2) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + eslint: + specifier: ^10.6.0 + version: 10.6.0(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@10.6.0(jiti@2.7.0)) + globals: + specifier: ^17.7.0 + version: 17.7.0 + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + typescript-eslint: + specifier: ^8.63.0 + version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + + '@radix-ui/primitive@1.1.5': + resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} + + '@radix-ui/react-alert-dialog@1.1.19': + resolution: {integrity: sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.11': + resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.7': + resolution: {integrity: sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.12': + resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.0': + resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.19': + resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.15': + resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.20': + resolution: {integrity: sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.12': + resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.11': + resolution: {integrity: sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.20': + resolution: {integrity: sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.19': + resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.3': + resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.13': + resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.7': + resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.15': + resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.3': + resolution: {integrity: sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.3': + resolution: {integrity: sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.17': + resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.12': + resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.1': + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.7': + resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shadcn/react@0.2.1': + resolution: {integrity: sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ==} + peerDependencies: + '@types/react': '>=19' + react: '>=19' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.5: + resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001803: + resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + electron-to-chromium@1.5.389: + resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.6.0: + resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.4.1: + resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==} + engines: {node: '>=6.0.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.24.0: + resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@18.0.6: + resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-form@7.81.0: + resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-i18next@17.0.9: + resolution: {integrity: sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.5 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@date-fns/tz@1.5.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': + dependencies: + eslint: 10.6.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.6.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.7))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.81.0(react@19.2.7) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@radix-ui/number@1.1.2': {} + + '@radix-ui/primitive@1.1.5': {} + + '@radix-ui/react-alert-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-checkbox@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-label@2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-select@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-switch@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.2': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@shadcn/react@0.2.1(@types/react@19.2.17)(react@19.2.7)': + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 10.6.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 10.6.0(jiti@2.7.0) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.2) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@6.0.2)': + dependencies: + typescript: 6.0.2 + + '@typescript-eslint/type-utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + debug: 4.4.3 + eslint: 10.6.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.2) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.2) + eslint: 10.6.0(jiti@2.7.0) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.42: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.5: + dependencies: + baseline-browser-mapping: 2.10.42 + caniuse-lite: 1.0.30001803 + electron-to-chromium: 1.5.389 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.5) + + caniuse-lite@1.0.30001803: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + date-fns@4.4.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + + electron-to-chromium@1.5.389: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.6.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)): + dependencies: + eslint: 10.6.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.6.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + eventemitter3@4.0.7: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@5.4.1: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.7.0: {} + + graceful-fs@4.2.11: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.6(typescript@6.0.2): + optionalDependencies: + typescript: 6.0.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + internmap@2.0.3: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.24.0(react@19.2.7): + dependencies: + react: 19.2.7 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@18.0.6: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + node-releases@2.0.51: {} + + object-assign@4.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-form@7.81.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-i18next@17.0.9(i18next@26.3.6(typescript@6.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.2): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.6(typescript@6.0.2) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 6.0.2 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react-smooth@4.0.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + fast-equals: 5.4.1 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-transition-group: 4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + source-map-js@1.2.1: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + ts-api-utils@2.5.0(typescript@6.0.2): + dependencies: + typescript: 6.0.2 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.2) + eslint: 10.6.0(jiti@2.7.0) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + typescript@6.0.2: {} + + undici-types@8.3.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.5): + dependencies: + browserslist: 4.28.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + jiti: 2.7.0 + + void-elements@3.1.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d00516a0723936aba817310025fdc1a2aaf8f8d5 --- /dev/null +++ b/frontend/public/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17661ab551bcf3f907b39951251c8db4f4a65baaf15911c5a5c195afa12c2e98 +size 15406 diff --git a/frontend/public/grok2api.png b/frontend/public/grok2api.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d9d2754e6d1d6d6bd630020e3ed07ce84b9e12 --- /dev/null +++ b/frontend/public/grok2api.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a332c8169154e0bacf8c2267b6919ccb4ddf09dac3ba5175b89a62d6a7e01d07 +size 514203 diff --git a/frontend/public/runtime-config.js b/frontend/public/runtime-config.js new file mode 100644 index 0000000000000000000000000000000000000000..a24667546bda77285bed05d370864d9113b3eae7 --- /dev/null +++ b/frontend/public/runtime-config.js @@ -0,0 +1,4 @@ +window.__GROK2API_RUNTIME_CONFIG__ = { + apiBaseUrl: "", + publicApiBaseUrl: "" +}; diff --git a/frontend/public/sponner/deeix-chat_deeix-ai.png b/frontend/public/sponner/deeix-chat_deeix-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..a6ae4b83d4f98385ab7375ad3022cf3735ffdf8e --- /dev/null +++ b/frontend/public/sponner/deeix-chat_deeix-ai.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63d882d2f09c0c82fa343d021964ac070c36a414d80ff77f0cff192469fddeff +size 207412 diff --git a/frontend/public/sponner/rightcode.jpg b/frontend/public/sponner/rightcode.jpg new file mode 100644 index 0000000000000000000000000000000000000000..be16151ae98aeb0d9853ba639ee06e93d89e676e --- /dev/null +++ b/frontend/public/sponner/rightcode.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d4d650868ea7ea01a7ef5eb605e3ecb66e857946a3f2d5e9c38a5c2b25a6381 +size 97495 diff --git a/frontend/src/app/app-shell.tsx b/frontend/src/app/app-shell.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28056d8a6dd3e2449cea0911cc06dc16cb7d23d7 --- /dev/null +++ b/frontend/src/app/app-shell.tsx @@ -0,0 +1,280 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box, ChevronDown, Eye, Image, KeyRound, Languages, LayoutDashboard, LogOut, Menu, MessageSquareText, Monitor, Moon, MoreHorizontal, Settings, Sparkles, Sun, Users, Video } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useState, type ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { useAuth } from "@/shared/auth/use-auth"; +import { GitHubMark } from "@/shared/components/github-mark"; +import { SiteFooter } from "@/shared/components/site-footer"; +import { cn } from "@/shared/lib/cn"; +import { CurrentVersionLabel } from "@/features/system/version-update"; + +const navigation = [ + { href: "/dashboard", label: "nav.dashboard", icon: LayoutDashboard }, + { href: "/accounts", label: "nav.accounts", icon: Users }, + { href: "/client-keys", label: "nav.clientKeys", icon: KeyRound }, + { href: "/models", label: "nav.models", icon: Box }, + { href: "/gallery", label: "nav.gallery", icon: Image }, + { href: "/video-gallery", label: "nav.videoGallery", icon: Video }, + { href: "/request-audits", label: "nav.audits", icon: Eye }, + { href: "/creative-console", label: "nav.creativeConsole", icon: Sparkles }, +] as const; + +const documentation = [ + { + label: "Chat", + icon: MessageSquareText, + items: [ + { href: "/docs/chat/completions", label: "Chat Completions", method: "POST" }, + { href: "/docs/chat/responses", label: "Responses", method: "POST" }, + { href: "/docs/chat/messages", label: "Messages", method: "POST" }, + ], + }, + { + label: "Image", + icon: Image, + items: [ + { href: "/docs/image/generations", label: "Image Generations", method: "POST" }, + { href: "/docs/image/edits", label: "Image Edits", method: "POST" }, + ], + }, + { + label: "Video", + icon: Video, + items: [ + { href: "/docs/video/generations", label: "Video Generations", method: "POST" }, + { href: "/docs/video/get", label: "Get Video", method: "GET" }, + ], + }, +] as const; + +export function AppShell() { + const { t, i18n } = useTranslation(); + const { admin, logout, changePassword } = useAuth(); + const location = useLocation(); + const { setTheme } = useTheme(); + const [mobileOpen, setMobileOpen] = useState(false); + const [passwordOpen, setPasswordOpen] = useState(false); + const [documentationOpen, setDocumentationOpen] = useState>({}); + const isMediaWorkspace = ["/creative-console", "/gallery", "/video-gallery"].includes(location.pathname); + + const passwordSchema = z.object({ + currentPassword: z.string().min(1, t("errors.required")), + newPassword: z.string().min(8, t("errors.minPassword")), + }); + type PasswordForm = z.infer; + const passwordForm = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { currentPassword: "", newPassword: "" }, + }); + + async function submitPassword(values: PasswordForm): Promise { + try { + await changePassword(values.currentPassword, values.newPassword); + toast.success(t("auth.passwordUpdated")); + passwordForm.reset(); + setPasswordOpen(false); + await logout(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("errors.generic")); + } + } + + function navigationLinks(): ReactNode { + return navigation.map(({ href, label, icon: Icon }) => ( + setMobileOpen(false)} + className={({ isActive }) => cn( + "group flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-normal text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground", + isActive && "bg-secondary/60 text-foreground", + )} + > + {({ isActive }) => ( + <> + + + + {t(label)} + + )} + + )); + } + + function documentationLinks(): ReactNode { + return documentation.map(({ label, icon: Icon, items }) => { + const open = documentationOpen[label] ?? false; + return ( +
+ +
+
+
+ {items.map((item) => ( + setMobileOpen(false)} + className={({ isActive }) => cn( + "group flex h-7 min-w-0 items-center gap-2 rounded-md pl-[38px] pr-2.5 text-xs text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground", + isActive && "bg-secondary/60 text-foreground", + )} + > + {item.label} + + {item.method} + + + ))} +
+
+
+
+ ); + }); + } + + const navigationContent = ( + + ); + + const accountControl = ( +
+ {admin?.username} + + + + + + + {t("shell.appearance")} + + setTheme("light")}>{t("shell.light")} + setTheme("dark")}>{t("shell.dark")} + setTheme("system")}>{t("shell.system")} + + + + {t("shell.language")} + + void i18n.changeLanguage("zh-CN")}>简体中文 + void i18n.changeLanguage("en")}>English + + + setPasswordOpen(true)}>{t("auth.changePassword")} + + void logout()}>{t("auth.signOut")} + + + setMobileOpen(false)} + className={({ isActive }) => cn("flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground", isActive && "bg-secondary/60 text-foreground")} + aria-label={t("nav.settings")} + > + + +
+ ); + + return ( +
+ + +
+
+ + + + + {t("appName")} + {t("shell.navigation")} + + +
{accountControl}
+
+
+ {t("appName")} + +
+ +
+ +
+ {!isMediaWorkspace ? : null} +
+ + + + {t("auth.changePassword")}{admin?.username} +
+
{passwordForm.formState.errors.currentPassword ?

{passwordForm.formState.errors.currentPassword.message}

: null}
+
{passwordForm.formState.errors.newPassword ?

{passwordForm.formState.errors.newPassword.message}

: null}
+ +
+
+
+
+ ); +} diff --git a/frontend/src/app/auth-boundary.tsx b/frontend/src/app/auth-boundary.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b3a3a32f97ea64903096a4d728a9d244a88c102 --- /dev/null +++ b/frontend/src/app/auth-boundary.tsx @@ -0,0 +1,56 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { useAuth } from "@/shared/auth/use-auth"; + +export function AuthBoundary() { + const { status, retryRestore } = useAuth(); + const location = useLocation(); + + if (status === "restoring") { + return ; + } + + if (status === "anonymous") { + return ; + } + if (status === "unavailable") { + return ; + } + + return ; +} + +export function AnonymousBoundary() { + const { status, retryRestore } = useAuth(); + if (status === "restoring") { + return ; + } + if (status === "unavailable") { + return ; + } + return status === "authenticated" ? : ; +} + +function RestoringScreen() { + return ( +
+ +
+ ); +} + +function SessionUnavailableScreen({ onRetry }: { onRetry: () => Promise }) { + const { t } = useTranslation(); + return ( +
+
+

{t("auth.sessionUnavailable")}

+

{t("auth.sessionUnavailableDescription")}

+ +
+
+ ); +} diff --git a/frontend/src/app/deferred-pages.tsx b/frontend/src/app/deferred-pages.tsx new file mode 100644 index 0000000000000000000000000000000000000000..16a4e4409e6dd1d5bda53a5277907771326358ba --- /dev/null +++ b/frontend/src/app/deferred-pages.tsx @@ -0,0 +1,75 @@ +import { lazy, Suspense, type ComponentType, type LazyExoticComponent } from "react"; + +import { Spinner } from "@/components/ui/spinner"; + +const AccountsPage = lazyNamed(() => import("@/features/accounts/accounts-page"), "AccountsPage"); +const AppShell = lazyNamed(() => import("@/app/app-shell"), "AppShell"); +const RequestAuditsPage = lazyNamed(() => import("@/features/audits/request-audits-page"), "RequestAuditsPage"); +const ClientKeysPage = lazyNamed(() => import("@/features/client-keys/client-keys-page"), "ClientKeysPage"); +const CreativeConsolePage = lazyNamed(() => import("@/features/creative-console/creative-console-page"), "CreativeConsolePage"); +const DashboardPage = lazyNamed(() => import("@/features/dashboard/dashboard-page"), "DashboardPage"); +const ApiDocsPage = lazyNamed(() => import("@/features/docs/api-docs-page"), "ApiDocsPage"); +const GalleryPage = lazyNamed(() => import("@/features/media/gallery-page"), "GalleryPage"); +const VideoGalleryPage = lazyNamed(() => import("@/features/media/video-gallery-page"), "VideoGalleryPage"); +const ModelsPage = lazyNamed(() => import("@/features/models/models-page"), "ModelsPage"); +const SettingsPage = lazyNamed(() => import("@/features/settings/settings-page"), "SettingsPage"); + +function lazyNamed, K extends keyof T>(loader: () => Promise, exportName: K): LazyExoticComponent { + return lazy(async () => ({ default: (await loader())[exportName] })); +} + +function DeferredPage({ page: Page }: { page: ComponentType }) { + return }>; +} + +export function DeferredAccountsPage() { + return ; +} + +export function DeferredAppShell() { + return }>; +} + +export function DeferredDashboardPage() { + return ; +} + +export function DeferredModelsPage() { + return ; +} + +export function DeferredClientKeysPage() { + return ; +} + +export function DeferredCreativeConsolePage() { + return ; +} + +export function DeferredRequestAuditsPage() { + return ; +} + +export function DeferredGalleryPage() { + return ; +} + +export function DeferredVideoGalleryPage() { + return ; +} + +export function DeferredApiDocsPage() { + return ; +} + +export function DeferredSettingsPage() { + return ; +} + +function PageLoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2611efc42127398fa14bbf3194b33ce09acf5be7 --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "next-themes"; +import { useState, type ReactNode } from "react"; +import { Toaster } from "sonner"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AuthProvider } from "@/shared/auth/auth-context"; + +export function AppProviders({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { retry: 1, staleTime: 15_000, refetchOnWindowFocus: false }, + mutations: { retry: 0 }, + }, + })); + + return ( + + + + + {children} + + + + + + ); +} + diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc50cd4a47a7d47a4d3bd9c6f7ead4ff513bd21e --- /dev/null +++ b/frontend/src/app/router.tsx @@ -0,0 +1,35 @@ +import { Navigate, createBrowserRouter } from "react-router-dom"; + +import { AnonymousBoundary, AuthBoundary } from "@/app/auth-boundary"; +import { DeferredAccountsPage, DeferredApiDocsPage, DeferredAppShell, DeferredClientKeysPage, DeferredCreativeConsolePage, DeferredDashboardPage, DeferredGalleryPage, DeferredModelsPage, DeferredRequestAuditsPage, DeferredSettingsPage, DeferredVideoGalleryPage } from "@/app/deferred-pages"; +import { LoginPage } from "@/features/auth/login-page"; + +export const router = createBrowserRouter([ + { + element: , + children: [{ path: "/login", element: }], + }, + { + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + { path: "/dashboard", element: }, + { path: "/accounts", element: }, + { path: "/models", element: }, + { path: "/creative-console", element: }, + { path: "/client-keys", element: }, + { path: "/gallery", element: }, + { path: "/video-gallery", element: }, + { path: "/request-audits", element: }, + { path: "/docs", element: }, + { path: "/docs/:category/:endpoint", element: }, + { path: "/settings", element: }, + ], + }, + ], + }, + { path: "*", element: }, +]); diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..52dbbf31b9ed73bcd2a23a36c070e3f874ca283d --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/shared/lib/cn" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..49e636aef1ab8e2381b616b886340a6af107e547 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/shared/lib/cn" + +const badgeVariants = cva( + "inline-flex h-5 items-center justify-center rounded-full border px-1.5 py-0 text-[11px] font-normal leading-none shadow-none transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary/10 text-primary hover:bg-primary/15", + secondary: + "border-transparent bg-secondary/70 text-secondary-foreground hover:bg-secondary", + destructive: + "border-transparent bg-destructive/10 text-destructive hover:bg-destructive/15", + outline: "border-border bg-transparent text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba52edb2f616855f8a8f9c475c76089dd6ba7876 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/shared/lib/cn" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/84", + destructive: + "bg-destructive text-white hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-8 px-3 text-xs", + sm: "h-8 px-3 text-xs", + lg: "h-9 px-5 text-sm", + icon: "h-8 w-8 text-xs", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad77fea3be276eafc4d7d1850d88650d4e696b87 --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,45 @@ +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react" +import { DayPicker, type DayPickerProps } from "react-day-picker" + +import { cn } from "@/shared/lib/cn" + +function Calendar({ className, classNames, showOutsideDays = true, components, ...props }: DayPickerProps) { + return ( + button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary/90 [&>button]:hover:text-primary-foreground", + today: "[&>button]:bg-accent [&>button]:text-accent-foreground", + outside: "text-muted-foreground opacity-40", + disabled: "text-muted-foreground opacity-35", + hidden: "invisible", + ...classNames, + }} + components={{ + Chevron: ({ className: chevronClassName, orientation }) => { + const Icon = orientation === "left" ? ChevronLeft : orientation === "right" ? ChevronRight : orientation === "up" ? ChevronUp : ChevronDown + return + }, + ...components, + }} + {...props} + /> + ) +} + +export { Calendar } diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02bb41b91cd0ea36b18d1b3406ac62853a73831d --- /dev/null +++ b/frontend/src/components/ui/chart.tsx @@ -0,0 +1,367 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/shared/lib/cn" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +