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 @@
+
+
+
+
+
+ 面向 Grok Build、Grok Web 与 Grok Console 的多账号 API 网关
+
+
+
+ English | 简体中文
+
+
+
+
+
+
+
+
+
+
+
+
+> [!TIP]
+> 推荐个人新项目 [DEEIX-AI / DEEIX-Chat](https://github.com/DEEIX-AI/DEEIX-Chat):面向多模型路由、对话、文件、工具、计费与运维的一体化轻量 AI 平台。
+
+> [!NOTE]
+> 本项目仅供技术研究与学习交流。使用时请务必遵循 Grok 官方的使用条款及当地法律法规,否则一切后果自负!
+
+## 赞助商
+
+> [希望赞助这个项目?](mailto:chenyme03@gmail.com)
+
+
+
+ |
+感谢 Krill AI 赞助了本项目!Krill 提供 GPT / Claude / Gemini / 多款国产模型的官方稳定极速的 API 中转服务,支持企业级定制、报销开票、7×16h 专属技术支持。更有独家适配的 WebSocket 连接,畅享极速首字速度。Krill 为本项目提供了特别优惠,使用此链接注册并在下订单时填写「grok2api」优惠码,首购套餐可享 Codex 77 折优惠! |
+
+
+ |
+DEEIX-Chat 是一款开源可部署的 AI Chat 平台,面向需要长期、稳定、统一使用多模型能力的个人、团队与企业,将模型、对话、文件、工具调用与后台管理整合为一套可部署、可扩展的系统。点击 此处 开始部署! |
+
+
+ |
+Right 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 ""
+ }
+ if value, _ := item["b64_json"].(string); value != "" {
+ mimeType, _ := item["mime_type"].(string)
+ if mimeType == "" {
+ mimeType = "image/jpeg"
+ }
+ return ""
+ }
+ 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, "
+ if start < 0 {
+ break
+ }
+ value = value[start+len(":]
+ 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}
+
+
+
+
+ );
+}
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 (
+